diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7d0bf6464568..e7d96dcbf018 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -598,6 +598,9 @@
android:launchMode="singleTop"
android:theme="@style/Theme.ownCloud.Dialog.NoTitle"
android:windowSoftInputMode="adjustResize" />
+
createFilesExportWork(context, workerParameters)
FileUploadWorker::class -> createFilesUploadWorker(context, workerParameters)
FileDownloadWorker::class -> createFilesDownloadWorker(context, workerParameters)
+ AlbumFileUploadWorker::class -> createAlbumsFilesUploadWorker(context, workerParameters)
GeneratePdfFromImagesWork::class -> createPDFGenerateWork(context, workerParameters)
HealthStatusWork::class -> createHealthStatusWork(context, workerParameters)
TestJob::class -> createTestJob(context, workerParameters)
@@ -245,6 +247,20 @@ class BackgroundJobFactory @Inject constructor(
params
)
+ private fun createAlbumsFilesUploadWorker(context: Context, params: WorkerParameters): AlbumFileUploadWorker =
+ AlbumFileUploadWorker(
+ uploadsStorageManager,
+ connectivityService,
+ powerManagementService,
+ accountManager,
+ viewThemeUtils.get(),
+ localBroadcastManager.get(),
+ backgroundJobManager.get(),
+ preferences,
+ context,
+ params
+ )
+
private fun createPDFGenerateWork(context: Context, params: WorkerParameters): GeneratePdfFromImagesWork =
GeneratePdfFromImagesWork(
appContext = context,
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 2f9d311953e6..b8807794a2d6 100644
--- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt
+++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt
@@ -140,6 +140,12 @@ interface BackgroundJobManager {
fun startNotificationJob(subject: String, signature: String)
fun startAccountRemovalJob(accountName: String, remoteWipe: Boolean)
fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean)
+ fun startAlbumFilesUploadJob(
+ user: User,
+ uploadIds: LongArray,
+ albumName: String,
+ showSameFileAlreadyExistsNotification: Boolean
+ )
fun getFileUploads(user: User): LiveData>
fun cancelFilesUploadJob(user: User)
fun isStartFileUploadJobScheduled(user: User): Boolean
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 5fd6b4ff4ebc..dd0f657df6d5 100644
--- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt
+++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt
@@ -29,6 +29,7 @@ import com.nextcloud.client.documentscan.GeneratePdfFromImagesWork
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.upload.AlbumFileUploadWorker
import com.nextcloud.client.jobs.upload.FileUploadHelper
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.preferences.AppPreferences
@@ -82,6 +83,7 @@ internal class BackgroundJobManagerImpl(
const val JOB_NOTIFICATION = "notification"
const val JOB_ACCOUNT_REMOVAL = "account_removal"
const val JOB_FILES_UPLOAD = "files_upload"
+ const val ALBUM_JOB_FILES_UPLOAD = "album_files_upload"
const val JOB_FOLDER_DOWNLOAD = "folder_download"
const val JOB_FILES_DOWNLOAD = "files_download"
const val JOB_PDF_GENERATION = "pdf_generation"
@@ -607,6 +609,7 @@ internal class BackgroundJobManagerImpl(
}
private fun startFileUploadJobTag(user: User): String = JOB_FILES_UPLOAD + user.accountName
+ private fun startAlbumsFileUploadJobTag(user: User): String = ALBUM_JOB_FILES_UPLOAD + user.accountName
override fun isStartFileUploadJobScheduled(user: User): Boolean =
workManager.isWorkScheduled(startFileUploadJobTag(user))
@@ -670,6 +673,68 @@ internal class BackgroundJobManagerImpl(
}
}
+ /**
+ * This method supports uploading and copying selected files to Album
+ *
+ * @param user The user for whom the upload job is being created.
+ * @param uploadIds Array of upload IDs to be processed. These IDs originate from multiple sources
+ * and cannot be determined directly from the account name or a single function
+ * within the worker.
+ * @param albumName Album on which selected files should be copy after upload
+ */
+ override fun startAlbumFilesUploadJob(
+ user: User,
+ uploadIds: LongArray,
+ albumName: String,
+ showSameFileAlreadyExistsNotification: Boolean
+ ) {
+ defaultDispatcherScope.launch {
+ val batchSize = FileUploadHelper.MAX_FILE_COUNT
+ val batches = uploadIds.toList().chunked(batchSize)
+ val tag = startAlbumsFileUploadJobTag(user)
+
+ 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)
+ .putString(AlbumFileUploadWorker.ALBUM_NAME, albumName)
+
+ val workRequests = batches.mapIndexed { index, batch ->
+ dataBuilder
+ .putLongArray(FileUploadWorker.UPLOAD_IDS, batch.toLongArray())
+ .putInt(FileUploadWorker.CURRENT_BATCH_INDEX, index)
+
+ oneTimeRequestBuilder(AlbumFileUploadWorker::class, ALBUM_JOB_FILES_UPLOAD, user)
+ .addTag(tag)
+ .setInputData(dataBuilder.build())
+ .setConstraints(constraints)
+ .build()
+ }
+
+ // Chain the work requests sequentially
+ if (workRequests.isNotEmpty()) {
+ var workChain = workManager.beginUniqueWork(
+ tag,
+ ExistingWorkPolicy.APPEND_OR_REPLACE,
+ workRequests.first()
+ )
+
+ workRequests.drop(1).forEach { request ->
+ workChain = workChain.then(request)
+ }
+
+ workChain.enqueue()
+ }
+ }
+ }
+
private fun startFileDownloadJobTag(user: User, fileId: Long): String =
JOB_FOLDER_DOWNLOAD + user.accountName + fileId
diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/AlbumFileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/AlbumFileUploadWorker.kt
new file mode 100644
index 000000000000..4aa0cb2284d3
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/jobs/upload/AlbumFileUploadWorker.kt
@@ -0,0 +1,429 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2023 Alper Ozturk
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH
+ * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
+ */
+package com.nextcloud.client.jobs.upload
+
+import android.app.PendingIntent
+import android.content.Context
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+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.jobs.BackgroundJobManagerImpl
+import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.ACCOUNT
+import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.CURRENT_BATCH_INDEX
+import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION
+import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.TOTAL_UPLOAD_SIZE
+import com.nextcloud.client.jobs.upload.FileUploadWorker.Companion.UPLOAD_IDS
+import com.nextcloud.client.network.ConnectivityService
+import com.nextcloud.client.preferences.AppPreferences
+import com.nextcloud.model.WorkerState
+import com.nextcloud.model.WorkerStateLiveData
+import com.nextcloud.utils.extensions.getPercent
+import com.owncloud.android.datamodel.FileDataStorageManager
+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
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.operations.UploadFileOperation
+import com.owncloud.android.operations.albums.CopyFileToAlbumOperation
+import com.owncloud.android.utils.ErrorMessageAdapter
+import com.owncloud.android.utils.theme.ViewThemeUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.withContext
+import java.io.File
+import kotlin.random.Random
+
+/**
+ * this worker is a replica of FileUploadWorker
+ * this worker will take care of upload and then copying the uploaded files to selected Album
+ */
+@Suppress("LongParameterList")
+class AlbumFileUploadWorker(
+ val uploadsStorageManager: UploadsStorageManager,
+ val connectivityService: ConnectivityService,
+ val powerManagementService: PowerManagementService,
+ val userAccountManager: UserAccountManager,
+ val viewThemeUtils: ViewThemeUtils,
+ val localBroadcastManager: LocalBroadcastManager,
+ private val backgroundJobManager: BackgroundJobManager,
+ val preferences: AppPreferences,
+ val context: Context,
+ params: WorkerParameters
+) : CoroutineWorker(context, params),
+ OnDatatransferProgressListener {
+
+ companion object {
+ val TAG: String = AlbumFileUploadWorker::class.java.simpleName
+
+ var currentUploadFileOperation: UploadFileOperation? = null
+
+ private const val BATCH_SIZE = 100
+
+ const val ALBUM_NAME = "album_name"
+ }
+
+ private var lastPercent = 0
+ private val notificationManager = UploadNotificationManager(context, viewThemeUtils, Random.nextInt())
+ private val intents = FileUploaderIntents(context)
+ private val fileUploaderDelegate = FileUploaderDelegate()
+
+ @Suppress("TooGenericExceptionCaught")
+ override suspend fun doWork(): Result = try {
+ Log_OC.d(TAG, "AlbumFileUploadWorker started")
+ backgroundJobManager.logStartOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class))
+ val result = uploadFiles()
+ backgroundJobManager.logEndOfWorker(BackgroundJobManagerImpl.formatClassTag(this::class), result)
+ notificationManager.dismissNotification()
+ if (result == Result.success()) {
+ setIdleWorkerState()
+ }
+ result
+ } catch (t: Throwable) {
+ Log_OC.e(TAG, "Error caught at AlbumFileUploadWorker $t")
+ cleanup()
+ Result.failure()
+ }
+
+ private fun cleanup() {
+ Log_OC.e(TAG, "AlbumFileUploadWorker stopped")
+
+ setIdleWorkerState()
+ currentUploadFileOperation?.cancel(null)
+ notificationManager.dismissNotification()
+ }
+
+ private fun setWorkerState(user: User?) {
+ WorkerStateLiveData.instance().setWorkState(WorkerState.UploadStarted(user))
+ }
+
+ private fun setIdleWorkerState() {
+ WorkerStateLiveData.instance().setWorkState(WorkerState.UploadFinished(currentUploadFileOperation?.file))
+ }
+
+ @Suppress("ReturnCount", "LongMethod")
+ private suspend fun uploadFiles(): Result = withContext(Dispatchers.IO) {
+ val accountName = inputData.getString(ACCOUNT)
+ if (accountName == null) {
+ Log_OC.e(TAG, "accountName is null")
+ return@withContext Result.failure()
+ }
+
+ val uploadIds = inputData.getLongArray(UPLOAD_IDS)
+ if (uploadIds == null) {
+ Log_OC.e(TAG, "uploadIds is null")
+ return@withContext Result.failure()
+ }
+
+ val currentBatchIndex = inputData.getInt(CURRENT_BATCH_INDEX, -1)
+ if (currentBatchIndex == -1) {
+ Log_OC.e(TAG, "currentBatchIndex is -1, cancelling")
+ return@withContext Result.failure()
+ }
+
+ val totalUploadSize = inputData.getInt(TOTAL_UPLOAD_SIZE, -1)
+ if (totalUploadSize == -1) {
+ Log_OC.e(TAG, "totalUploadSize is -1, cancelling")
+ return@withContext Result.failure()
+ }
+
+ // since worker's policy is append or replace and account name comes from there no need check in the loop
+ val optionalUser = userAccountManager.getUser(accountName)
+ if (!optionalUser.isPresent) {
+ Log_OC.e(TAG, "User not found for account: $accountName")
+ return@withContext Result.failure()
+ }
+
+ val albumName = inputData.getString(ALBUM_NAME)
+ if (albumName == null) {
+ Log_OC.e(TAG, "album name is null")
+ return@withContext Result.failure()
+ }
+
+ 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)
+
+ for ((index, upload) in uploads.withIndex()) {
+ ensureActive()
+
+ if (preferences.isGlobalUploadPaused) {
+ Log_OC.d(TAG, "Upload is paused, skip uploading files!")
+ notificationManager.notifyPaused(
+ intents.notificationStartIntent(null)
+ )
+ return@withContext Result.success()
+ }
+
+ if (canExitEarly()) {
+ notificationManager.showConnectionErrorNotification()
+ return@withContext Result.failure()
+ }
+
+ setWorkerState(user)
+ val operation = createUploadFileOperation(upload, user)
+ currentUploadFileOperation = operation
+
+ val currentIndex = (index + 1)
+ val currentUploadIndex = (currentIndex + previouslyUploadedFileSize)
+ notificationManager.prepareForStart(
+ operation,
+ cancelPendingIntent = intents.startIntent(operation),
+ startIntent = intents.notificationStartIntent(operation),
+ currentUploadIndex = currentUploadIndex,
+ totalUploadSize = totalUploadSize
+ )
+
+ val result = withContext(Dispatchers.IO) {
+ upload(operation, albumName, user, client)
+ }
+ currentUploadFileOperation = null
+ sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result)
+ }
+
+ return@withContext Result.success()
+ }
+
+ private fun sendUploadFinishEvent(
+ totalUploadSize: Int,
+ currentUploadIndex: Int,
+ operation: UploadFileOperation,
+ result: RemoteOperationResult<*>
+ ) {
+ val shouldBroadcast =
+ (totalUploadSize > BATCH_SIZE && currentUploadIndex > 0) && currentUploadIndex % BATCH_SIZE == 0
+
+ if (shouldBroadcast) {
+ // delay broadcast
+ fileUploaderDelegate.sendBroadcastUploadFinished(
+ operation,
+ result,
+ operation.oldFile?.storagePath,
+ context,
+ localBroadcastManager
+ )
+ }
+ }
+
+ private fun canExitEarly(): Boolean {
+ val result = !connectivityService.isConnected ||
+ connectivityService.isInternetWalled ||
+ isStopped
+
+ if (result) {
+ Log_OC.d(TAG, "No internet connection, stopping worker.")
+ } else {
+ notificationManager.dismissErrorNotification()
+ }
+
+ return result
+ }
+
+ private fun createUploadFileOperation(upload: OCUpload, user: User): UploadFileOperation = UploadFileOperation(
+ uploadsStorageManager,
+ connectivityService,
+ powerManagementService,
+ user,
+ null,
+ upload,
+ upload.nameCollisionPolicy,
+ upload.localAction,
+ context,
+ upload.isUseWifiOnly,
+ upload.isWhileChargingOnly,
+ true,
+ FileDataStorageManager(user, context.contentResolver)
+ ).apply {
+ addDataTransferProgressListener(this@AlbumFileUploadWorker)
+ }
+
+ @Suppress("TooGenericExceptionCaught", "DEPRECATION")
+ private fun upload(
+ uploadFileOperation: UploadFileOperation,
+ albumName: String,
+ user: User,
+ client: OwnCloudClient
+ ): RemoteOperationResult {
+ lateinit var result: RemoteOperationResult
+
+ try {
+ val storageManager = uploadFileOperation.storageManager
+ result = uploadFileOperation.execute(client)
+ val task = ThumbnailsCacheManager.ThumbnailGenerationTask(storageManager, user)
+ val file = File(uploadFileOperation.originalStoragePath)
+ val remoteId: String? = uploadFileOperation.file.remoteId
+ task.execute(ThumbnailsCacheManager.ThumbnailGenerationTaskObject(file, remoteId))
+ val copyAlbumFileOperation =
+ CopyFileToAlbumOperation(uploadFileOperation.remotePath, albumName, storageManager)
+ val copyResult = copyAlbumFileOperation.execute(client)
+ if (copyResult.isSuccess) {
+ Log_OC.e(TAG, "Successful copied file to Album: $albumName")
+ } else {
+ Log_OC.e(TAG, "Failed to copy file to Album: $albumName due to ${copyResult.logMessage}")
+ }
+ } catch (e: Exception) {
+ Log_OC.e(TAG, "Error uploading", e)
+ result = RemoteOperationResult(e)
+ } finally {
+ cleanupUploadProcess(result, uploadFileOperation)
+ }
+
+ return result
+ }
+
+ private fun cleanupUploadProcess(result: RemoteOperationResult, uploadFileOperation: UploadFileOperation) {
+ if (!isStopped || !result.isCancelled) {
+ uploadsStorageManager.updateDatabaseUploadResult(result, uploadFileOperation)
+ notifyUploadResult(uploadFileOperation, result)
+ }
+ }
+
+ @Suppress("ReturnCount", "LongMethod")
+ private fun notifyUploadResult(
+ uploadFileOperation: UploadFileOperation,
+ uploadResult: RemoteOperationResult
+ ) {
+ Log_OC.d(TAG, "NotifyUploadResult with resultCode: " + uploadResult.code)
+ val showSameFileAlreadyExistsNotification =
+ inputData.getBoolean(SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, false)
+
+ if (uploadResult.isSuccess) {
+ notificationManager.dismissOldErrorNotification(uploadFileOperation)
+ return
+ }
+
+ if (uploadResult.isCancelled) {
+ return
+ }
+
+ // Only notify if it is not same file on remote that causes conflict
+ if (uploadResult.code == ResultCode.SYNC_CONFLICT &&
+ FileUploadHelper().isSameFileOnRemote(
+ uploadFileOperation.user,
+ File(uploadFileOperation.storagePath),
+ uploadFileOperation.remotePath,
+ context
+ )
+ ) {
+ if (showSameFileAlreadyExistsNotification) {
+ notificationManager.showSameFileAlreadyExistsNotification(uploadFileOperation.fileName)
+ }
+
+ uploadFileOperation.handleLocalBehaviour()
+ return
+ }
+
+ val notDelayed = uploadResult.code !in setOf(
+ ResultCode.DELAYED_FOR_WIFI,
+ ResultCode.DELAYED_FOR_CHARGING,
+ ResultCode.DELAYED_IN_POWER_SAVE_MODE
+ )
+
+ val isValidFile = uploadResult.code !in setOf(
+ ResultCode.LOCAL_FILE_NOT_FOUND,
+ ResultCode.LOCK_FAILED
+ )
+
+ if (!notDelayed || !isValidFile) {
+ return
+ }
+
+ if (uploadResult.code == ResultCode.SYNC_CONFLICT) {
+ // NMC: resolving file conflict will trigger normal file upload and shows two upload process
+ // one for normal and one for Album upload
+ // as customizing conflict can break normal upload
+ // so we are removing the upload if it's a conflict
+ // Note: this is fallback logic because default policy while uploading is RENAME
+ // if in some case code reach here it will remove the upload
+ uploadsStorageManager.removeUpload(
+ uploadFileOperation.user.accountName,
+ uploadFileOperation.remotePath
+ )
+ return
+ }
+
+ notificationManager.run {
+ val errorMessage = ErrorMessageAdapter.getErrorCauseMessage(
+ uploadResult,
+ uploadFileOperation,
+ context.resources
+ )
+
+ val credentialIntent: PendingIntent? = if (uploadResult.code == ResultCode.UNAUTHORIZED) {
+ intents.credentialIntent(uploadFileOperation)
+ } else {
+ null
+ }
+
+ notifyForFailedResult(
+ uploadFileOperation,
+ uploadResult.code,
+ null,
+ null,
+ credentialIntent,
+ errorMessage
+ )
+ }
+ }
+
+ @Suppress("MagicNumber")
+ private val minProgressUpdateInterval = 750
+ private var lastUpdateTime = 0L
+
+ /**
+ * Receives from [com.owncloud.android.operations.UploadFileOperation.normalUpload]
+ */
+ @Suppress("MagicNumber")
+ override fun onTransferProgress(
+ progressRate: Long,
+ totalTransferredSoFar: Long,
+ totalToTransfer: Long,
+ fileAbsoluteName: String
+ ) {
+ val percent = getPercent(totalTransferredSoFar, totalToTransfer)
+ val currentTime = System.currentTimeMillis()
+
+ if (percent != lastPercent && (currentTime - lastUpdateTime) >= minProgressUpdateInterval) {
+ notificationManager.run {
+ val accountName = currentUploadFileOperation?.user?.accountName
+ val remotePath = currentUploadFileOperation?.remotePath
+
+ updateUploadProgress(percent, currentUploadFileOperation)
+
+ if (accountName != null && remotePath != null) {
+ val key: String = FileUploadHelper.buildRemoteName(accountName, remotePath)
+ val boundListener = FileUploadHelper.mBoundListeners[key]
+ val filename = currentUploadFileOperation?.fileName ?: ""
+
+ boundListener?.onTransferProgress(
+ progressRate,
+ totalTransferredSoFar,
+ totalToTransfer,
+ filename
+ )
+ }
+
+ dismissOldErrorNotification(currentUploadFileOperation)
+ }
+ lastUpdateTime = currentTime
+ }
+
+ lastPercent = percent
+ }
+}
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 99c5bb82fcdd..f1a256da1109 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
@@ -222,6 +222,41 @@ class FileUploadHelper {
backgroundJobManager.startFilesUploadJob(user, uploads.getUploadIds(), showSameFileAlreadyExistsNotification)
}
+ @JvmOverloads
+ @Suppress("LongParameterList")
+ fun uploadAndCopyNewFilesForAlbum(
+ user: User,
+ localPaths: Array,
+ remotePaths: Array,
+ albumName: String,
+ localBehavior: Int,
+ createRemoteFolder: Boolean,
+ createdBy: Int,
+ requiresWifi: Boolean,
+ requiresCharging: Boolean,
+ nameCollisionPolicy: NameCollisionPolicy,
+ showSameFileAlreadyExistsNotification: Boolean = true
+ ) {
+ val uploads = localPaths.mapIndexed { index, localPath ->
+ OCUpload(localPath, remotePaths[index], user.accountName).apply {
+ this.nameCollisionPolicy = nameCollisionPolicy
+ isUseWifiOnly = requiresWifi
+ isWhileChargingOnly = requiresCharging
+ uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
+ this.createdBy = createdBy
+ isCreateRemoteFolder = createRemoteFolder
+ localAction = localBehavior
+ }
+ }
+ uploadsStorageManager.storeUploads(uploads)
+ backgroundJobManager.startAlbumFilesUploadJob(
+ user,
+ uploads.getUploadIds(),
+ albumName,
+ showSameFileAlreadyExistsNotification
+ )
+ }
+
fun removeFileUpload(remotePath: String, accountName: String) {
try {
val user = accountManager.getUser(accountName).get()
@@ -339,7 +374,7 @@ class FileUploadHelper {
@Suppress("ReturnCount")
fun isUploadingNow(upload: OCUpload?): Boolean {
- val currentUploadFileOperation = currentUploadFileOperation
+ val currentUploadFileOperation = currentUploadFileOperation ?: AlbumFileUploadWorker.currentUploadFileOperation
if (currentUploadFileOperation == null || currentUploadFileOperation.user == null) return false
if (upload == null || upload.accountName != currentUploadFileOperation.user.accountName) return false
diff --git a/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt
new file mode 100644
index 000000000000..9d31afca3def
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt
@@ -0,0 +1,29 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.ui.albumItemActions
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.IdRes
+import androidx.annotation.StringRes
+import com.owncloud.android.R
+
+enum class AlbumItemAction(@IdRes val id: Int, @StringRes val title: Int, @DrawableRes val icon: Int? = null) {
+ RENAME_ALBUM(R.id.action_rename_file, R.string.album_rename, R.drawable.ic_edit),
+ DELETE_ALBUM(R.id.action_delete, R.string.album_delete, R.drawable.ic_delete);
+
+ companion object {
+ /**
+ * All file actions, in the order they should be displayed
+ */
+ @JvmField
+ val SORTED_VALUES = listOf(
+ RENAME_ALBUM,
+ DELETE_ALBUM
+ )
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt
new file mode 100644
index 000000000000..c697d5229b56
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt
@@ -0,0 +1,117 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.ui.albumItemActions
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.IdRes
+import androidx.core.os.bundleOf
+import androidx.core.view.isEmpty
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.setFragmentResult
+import androidx.lifecycle.LifecycleOwner
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.nextcloud.client.di.Injectable
+import com.owncloud.android.databinding.FileActionsBottomSheetBinding
+import com.owncloud.android.databinding.FileActionsBottomSheetItemBinding
+import com.owncloud.android.utils.theme.ViewThemeUtils
+import javax.inject.Inject
+
+class AlbumItemActionsBottomSheet : BottomSheetDialogFragment(), Injectable {
+
+ @Inject
+ lateinit var viewThemeUtils: ViewThemeUtils
+
+ private var _binding: FileActionsBottomSheetBinding? = null
+ val binding
+ get() = _binding!!
+
+ fun interface ResultListener {
+ fun onResult(@IdRes actionId: Int)
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ _binding = FileActionsBottomSheetBinding.inflate(inflater, container, false)
+
+ val bottomSheetDialog = dialog as BottomSheetDialog
+ bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ bottomSheetDialog.behavior.skipCollapsed = true
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.bottomSheetHeader.visibility = View.GONE
+ binding.bottomSheetLoading.visibility = View.GONE
+ displayActions()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ fun setResultListener(
+ fragmentManager: FragmentManager,
+ lifecycleOwner: LifecycleOwner,
+ listener: ResultListener
+ ): AlbumItemActionsBottomSheet {
+ fragmentManager.setFragmentResultListener(REQUEST_KEY, lifecycleOwner) { _, result ->
+ @IdRes val actionId = result.getInt(RESULT_KEY_ACTION_ID, -1)
+ if (actionId != -1) {
+ listener.onResult(actionId)
+ }
+ }
+ return this
+ }
+
+ private fun displayActions() {
+ if (binding.fileActionsList.isEmpty()) {
+ AlbumItemAction.SORTED_VALUES.forEach { action ->
+ val view = inflateActionView(action)
+ binding.fileActionsList.addView(view)
+ }
+ }
+ }
+
+ private fun inflateActionView(action: AlbumItemAction): View {
+ val itemBinding = FileActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false)
+ .apply {
+ root.setOnClickListener {
+ dispatchActionClick(action.id)
+ }
+ text.setText(action.title)
+ if (action.icon != null) {
+ icon.setImageResource(action.icon)
+ }
+ }
+ return itemBinding.root
+ }
+
+ private fun dispatchActionClick(id: Int?) {
+ if (id != null) {
+ setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY_ACTION_ID to id))
+ parentFragmentManager.clearFragmentResultListener(REQUEST_KEY)
+ dismiss()
+ }
+ }
+
+ companion object {
+ private const val REQUEST_KEY = "REQUEST_KEY_ACTION"
+ private const val RESULT_KEY_ACTION_ID = "RESULT_KEY_ACTION_ID"
+
+ @JvmStatic
+ fun newInstance(): AlbumItemActionsBottomSheet {
+ return AlbumItemActionsBottomSheet()
+ }
+ }
+}
diff --git a/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java b/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java
index 1259b706a2b1..ffc68007e616 100644
--- a/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java
+++ b/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java
@@ -12,5 +12,5 @@
* Type for virtual folders
*/
public enum VirtualFolderType {
- FAVORITE, GALLERY, NONE
+ FAVORITE, GALLERY, ALBUM, NONE
}
diff --git a/app/src/main/java/com/owncloud/android/operations/FetchRemoteFileOperation.kt b/app/src/main/java/com/owncloud/android/operations/FetchRemoteFileOperation.kt
new file mode 100644
index 000000000000..875f547056cb
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/FetchRemoteFileOperation.kt
@@ -0,0 +1,111 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
+ */
+package com.owncloud.android.operations
+
+import android.content.Context
+import com.nextcloud.client.account.User
+import com.owncloud.android.MainApp
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation
+import com.owncloud.android.lib.resources.files.SearchRemoteOperation
+import com.owncloud.android.lib.resources.files.model.RemoteFile
+import com.owncloud.android.operations.common.SyncOperation
+import com.owncloud.android.utils.FileStorageUtils
+
+/**
+ * fetch OCFile meta data if not present in local db
+ *
+ * @see com.owncloud.android.ui.asynctasks.FetchRemoteFileTask reference for this operation
+ *
+ * @param ocFile file for which metadata has to retrieve
+ * @param removeFileFromDb if you want to remove ocFile from local db to avoid duplicate entries for same fileId
+ */
+class FetchRemoteFileOperation(
+ private val context: Context,
+ private val user: User,
+ private val ocFile: OCFile,
+ private val removeFileFromDb: Boolean = false,
+ storageManager: FileDataStorageManager,
+) : SyncOperation(storageManager) {
+
+ @Deprecated("Deprecated in Java")
+ override fun run(client: OwnCloudClient?): RemoteOperationResult<*>? {
+ val searchRemoteOperation = SearchRemoteOperation(
+ ocFile.localId.toString(),
+ SearchRemoteOperation.SearchType.FILE_ID_SEARCH,
+ false,
+ storageManager.getCapability(user)
+ )
+ val remoteOperationResult: RemoteOperationResult> =
+ searchRemoteOperation.execute(user, context)
+
+ if (remoteOperationResult.isSuccess && remoteOperationResult.resultData != null) {
+ if (remoteOperationResult.resultData.isEmpty()) {
+ Log_OC.e(TAG, "No remote file found with id: ${ocFile.localId}.")
+ return remoteOperationResult
+ }
+ val remotePath = (remoteOperationResult.resultData[0]).remotePath
+
+ val operation = ReadFileRemoteOperation(remotePath)
+ val result = operation.execute(user, context)
+
+ if (!result.isSuccess) {
+ val exception = result.exception
+ val message =
+ "Fetching file " + remotePath + " fails with: " + result.getLogMessage(MainApp.getAppContext())
+ Log_OC.e(TAG, exception?.message ?: message)
+
+ return result
+ }
+
+ val remoteFile = result.data[0] as RemoteFile
+
+ // remove file from local db
+ if (removeFileFromDb) {
+ storageManager.removeFile(ocFile, true, true)
+ }
+
+ var ocFile = FileStorageUtils.fillOCFile(remoteFile)
+ FileStorageUtils.searchForLocalFileInDefaultPath(ocFile, user.accountName)
+ ocFile = storageManager.saveFileWithParent(ocFile, context)
+
+ // also sync folder content
+ val toSync: OCFile? = if (ocFile?.isFolder == true) {
+ ocFile
+ } else {
+ ocFile?.parentId?.let { storageManager.getFileById(it) }
+ }
+
+ val currentSyncTime = System.currentTimeMillis()
+ val refreshFolderOperation: RemoteOperation = RefreshFolderOperation(
+ toSync,
+ currentSyncTime,
+ true,
+ true,
+ storageManager,
+ user,
+ context
+ )
+ val refreshOperationResult = refreshFolderOperation.execute(user, context)
+
+ // set the fetched ocFile to resultData to be handled at ui end
+ refreshOperationResult.resultData = ocFile
+
+ return refreshOperationResult
+ }
+ return remoteOperationResult
+ }
+
+ companion object {
+ private val TAG = FetchRemoteFileOperation::class.java.simpleName
+ }
+}
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumOperation.kt
new file mode 100644
index 000000000000..5c1d5b4f8fe7
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumOperation.kt
@@ -0,0 +1,76 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package com.owncloud.android.operations.albums
+
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
+import com.owncloud.android.operations.UploadFileOperation
+import com.owncloud.android.operations.common.SyncOperation
+
+/**
+ * Constructor
+ *
+ * @param srcPath Remote path of the [OCFile] to move.
+ * @param targetParentPath Path to the folder where the file will be copied into.
+ */
+class CopyFileToAlbumOperation(
+ private val srcPath: String,
+ private var targetParentPath: String,
+ storageManager: FileDataStorageManager
+) :
+ SyncOperation(storageManager) {
+ init {
+ if (!targetParentPath.endsWith(OCFile.PATH_SEPARATOR)) {
+ this.targetParentPath += OCFile.PATH_SEPARATOR
+ }
+ }
+
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("NestedBlockDepth")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ /** 1. check copy validity */
+ val result: RemoteOperationResult
+
+ if (targetParentPath.startsWith(srcPath)) {
+ result = RemoteOperationResult(ResultCode.INVALID_COPY_INTO_DESCENDANT)
+ } else {
+ val file = storageManager.getFileByPath(srcPath)
+ if (file == null) {
+ result = RemoteOperationResult(ResultCode.FILE_NOT_FOUND)
+ } else {
+ /** 2. remote copy */
+ var targetPath = "$targetParentPath${file.fileName}"
+ if (file.isFolder) {
+ targetPath += OCFile.PATH_SEPARATOR
+ }
+
+ // auto rename, to allow copy
+ if (targetPath == srcPath) {
+ if (file.isFolder) {
+ targetPath = "$targetParentPath${file.fileName}"
+ }
+ targetPath = UploadFileOperation.getNewAvailableRemotePath(client, targetPath, null, false)
+
+ if (file.isFolder) {
+ targetPath += OCFile.PATH_SEPARATOR
+ }
+ }
+
+ result = CopyFileToAlbumRemoteOperation(srcPath, targetPath).execute(client)
+ }
+ }
+ return result
+ }
+}
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumRemoteOperation.kt
new file mode 100644
index 000000000000..f2fd99d6945b
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumRemoteOperation.kt
@@ -0,0 +1,157 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2021 Tobias Kaminsky
+ * SPDX-FileCopyrightText: 2019 Andy Scherzinger
+ * SPDX-FileCopyrightText: 2012-2014 ownCloud Inc.
+ * SPDX-FileCopyrightText: 2014 Jorge Antonio Diaz-Benito Soriano
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+package com.owncloud.android.operations.albums
+
+import android.util.Log
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.DavException
+import org.apache.jackrabbit.webdav.Status
+import org.apache.jackrabbit.webdav.client.methods.CopyMethod
+import java.io.IOException
+
+/**
+ * Remote operation moving a remote file or folder in the ownCloud server to a different folder
+ * in the same account.
+ *
+ *
+ * Allows renaming the moving file/folder at the same time.
+ */
+class CopyFileToAlbumRemoteOperation @JvmOverloads constructor(
+ private val mSrcRemotePath: String,
+ private val mTargetRemotePath: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+) :
+ RemoteOperation() {
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ /** check parameters */
+
+ var result: RemoteOperationResult
+ if (mTargetRemotePath == mSrcRemotePath) {
+ // nothing to do!
+ result = RemoteOperationResult(ResultCode.OK)
+ } else if (mTargetRemotePath.startsWith(mSrcRemotePath)) {
+ result = RemoteOperationResult(ResultCode.INVALID_COPY_INTO_DESCENDANT)
+ } else {
+ /** perform remote operation */
+ var copyMethod: CopyMethod? = null
+ try {
+ copyMethod = CopyMethod(
+ client.getFilesDavUri(this.mSrcRemotePath),
+ "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums${
+ WebdavUtils.encodePath(
+ mTargetRemotePath
+ )
+ }",
+ false
+ )
+ val status = client.executeMethod(
+ copyMethod,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+
+ /** process response */
+ result = when (status) {
+ HttpStatus.SC_MULTI_STATUS -> processPartialError(copyMethod)
+ HttpStatus.SC_PRECONDITION_FAILED -> {
+ client.exhaustResponse(copyMethod.responseBodyAsStream)
+ RemoteOperationResult(ResultCode.INVALID_OVERWRITE)
+ }
+
+ else -> {
+ client.exhaustResponse(copyMethod.responseBodyAsStream)
+ RemoteOperationResult(isSuccess(status), copyMethod)
+ }
+ }
+
+ Log.i(
+ TAG,
+ "Copy $mSrcRemotePath to $mTargetRemotePath : ${result.logMessage}"
+ )
+ } catch (e: Exception) {
+ result = RemoteOperationResult(e)
+ Log.e(
+ TAG,
+ "Copy $mSrcRemotePath to $mTargetRemotePath : ${result.logMessage}", e
+ )
+ } finally {
+ copyMethod?.releaseConnection()
+ }
+ }
+
+ return result
+ }
+
+ /**
+ * Analyzes a multistatus response from the OC server to generate an appropriate result.
+ *
+ *
+ * In WebDAV, a COPY request on collections (folders) can be PARTIALLY successful: some
+ * children are copied, some other aren't.
+ *
+ *
+ * According to the WebDAV specification, a multistatus response SHOULD NOT include partial
+ * successes (201, 204) nor for descendants of already failed children (424) in the response
+ * entity. But SHOULD NOT != MUST NOT, so take carefully.
+ *
+ * @param copyMethod Copy operation just finished with a multistatus response
+ * @return A result for the [CopyFileToAlbumRemoteOperation] caller
+ * @throws java.io.IOException If the response body could not be parsed
+ * @throws org.apache.jackrabbit.webdav.DavException If the status code is other than MultiStatus or if obtaining
+ * the response XML document fails
+ */
+ @Throws(IOException::class, DavException::class)
+ private fun processPartialError(copyMethod: CopyMethod): RemoteOperationResult {
+ // Adding a list of failed descendants to the result could be interesting; or maybe not.
+ // For the moment, let's take the easy way.
+ /** check that some error really occurred */
+
+ val responses = copyMethod.responseBodyAsMultiStatus.responses
+ var status: Array?
+ var failFound = false
+ var i = 0
+ while (i < responses.size && !failFound) {
+ status = responses[i].status
+ failFound = (!status.isNullOrEmpty() && status[0].statusCode > FAILED_STATUS_CODE
+ )
+ i++
+ }
+ val result: RemoteOperationResult = if (failFound) {
+ RemoteOperationResult(ResultCode.PARTIAL_COPY_DONE)
+ } else {
+ RemoteOperationResult(true, copyMethod)
+ }
+
+ return result
+ }
+
+ private fun isSuccess(status: Int): Boolean {
+ return status == HttpStatus.SC_CREATED || status == HttpStatus.SC_NO_CONTENT
+ }
+
+ companion object {
+ private val TAG: String = CopyFileToAlbumRemoteOperation::class.java.simpleName
+ private const val FAILED_STATUS_CODE = 299
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/CreateNewAlbumRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/CreateNewAlbumRemoteOperation.kt
new file mode 100644
index 000000000000..34c4742652d8
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/CreateNewAlbumRemoteOperation.kt
@@ -0,0 +1,76 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2021 Tobias Kaminsky
+ * SPDX-FileCopyrightText: 2019 Andy Scherzinger
+ * SPDX-FileCopyrightText: 2015 ownCloud Inc.
+ * SPDX-FileCopyrightText: 2015 María Asensio Valverde
+ * SPDX-FileCopyrightText: 2014 David A. Velasco
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+package com.owncloud.android.operations.albums
+
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.client.methods.MkColMethod
+
+class CreateNewAlbumRemoteOperation
+ @JvmOverloads
+ constructor(
+ val newAlbumName: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+ ) : RemoteOperation() {
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ var mkCol: MkColMethod? = null
+ var result: RemoteOperationResult
+ try {
+ mkCol =
+ MkColMethod(
+ "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums${
+ WebdavUtils.encodePath(
+ newAlbumName
+ )
+ }"
+ )
+ client.executeMethod(
+ mkCol,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+ if (HttpStatus.SC_METHOD_NOT_ALLOWED == mkCol.statusCode) {
+ result =
+ RemoteOperationResult(RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS)
+ } else {
+ result = RemoteOperationResult(mkCol.succeeded(), mkCol)
+ result.resultData = null
+ }
+
+ Log_OC.d(TAG, "Create album $newAlbumName : ${result.logMessage}")
+ client.exhaustResponse(mkCol.responseBodyAsStream)
+ } catch (e: Exception) {
+ result = RemoteOperationResult(e)
+ Log_OC.e(TAG, "Create album $newAlbumName : ${result.logMessage}", e)
+ } finally {
+ mkCol?.releaseConnection()
+ }
+
+ return result
+ }
+
+ companion object {
+ private val TAG: String = CreateNewAlbumRemoteOperation::class.java.simpleName
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/PhotoAlbumEntry.kt b/app/src/main/java/com/owncloud/android/operations/albums/PhotoAlbumEntry.kt
new file mode 100644
index 000000000000..39ebdd68d220
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/PhotoAlbumEntry.kt
@@ -0,0 +1,104 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.operations.albums
+
+import com.owncloud.android.lib.common.network.WebdavEntry
+import com.owncloud.android.utils.DisplayUtils
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.MultiStatusResponse
+import org.apache.jackrabbit.webdav.property.DavPropertyName
+import org.apache.jackrabbit.webdav.property.DavPropertySet
+import org.apache.jackrabbit.webdav.xml.Namespace
+import org.json.JSONException
+import org.json.JSONObject
+import java.net.URLDecoder
+import java.nio.charset.StandardCharsets
+
+class PhotoAlbumEntry(
+ response: MultiStatusResponse
+) {
+ val href: String
+ val lastPhoto: Long
+ val nbItems: Int
+ val location: String?
+ private val dateRange: String?
+
+ companion object {
+ private const val DATE_PATTERN = "MMM yyyy"
+ private const val MILLIS = 1000L
+ private const val PROPERTY_LAST_PHOTO = "last-photo"
+ private const val PROPERTY_NB_ITEMS = "nbItems"
+ private const val PROPERTY_LOCATION = "location"
+ private const val PROPERTY_DATE_RANGE = "dateRange"
+ private const val PROPERTY_COLLABORATORS = "collaborators"
+ }
+
+ init {
+
+ href = response.href
+
+ val properties = response.getProperties(HttpStatus.SC_OK)
+
+ this.lastPhoto = parseLong(parseString(properties, PROPERTY_LAST_PHOTO))
+ this.nbItems = parseInt(parseString(properties, PROPERTY_NB_ITEMS))
+ this.location = parseString(properties, PROPERTY_LOCATION)
+ this.dateRange = parseString(properties, PROPERTY_DATE_RANGE)
+ }
+
+ private fun parseString(
+ props: DavPropertySet,
+ name: String
+ ): String? {
+ val propName = DavPropertyName.create(name, Namespace.getNamespace("nc", WebdavEntry.NAMESPACE_NC))
+ val prop = props[propName]
+ return if (prop != null && prop.value != null) prop.value.toString() else null
+ }
+
+ private fun parseInt(value: String?): Int =
+ try {
+ value?.toInt() ?: 0
+ } catch (_: NumberFormatException) {
+ 0
+ }
+
+ private fun parseLong(value: String?): Long =
+ try {
+ value?.toLong() ?: 0L
+ } catch (_: NumberFormatException) {
+ 0L
+ }
+
+ val albumName: String
+ get() {
+ // NMC-4610 fix
+ // use decoder to show correct path
+ return URLDecoder.decode(
+ href
+ .removeSuffix("/")
+ .substringAfterLast("/")
+ .takeIf { it.isNotEmpty() } ?: "", StandardCharsets.UTF_8.name())
+ }
+
+ val createdDate: String
+ get() {
+ val defaultDate = DisplayUtils.getDateByPattern(System.currentTimeMillis(), DATE_PATTERN)
+
+ return try {
+ val obj = JSONObject(dateRange ?: return defaultDate)
+ val startTimestamp = obj.optLong("start", 0)
+ if (startTimestamp > 0) {
+ DisplayUtils.getDateByPattern(startTimestamp * MILLIS, DATE_PATTERN)
+ } else {
+ defaultDate
+ }
+ } catch (e: JSONException) {
+ e.printStackTrace()
+ defaultDate
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/ReadAlbumItemsRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/ReadAlbumItemsRemoteOperation.kt
new file mode 100644
index 000000000000..8daf196c3767
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/ReadAlbumItemsRemoteOperation.kt
@@ -0,0 +1,141 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2021 Tobias Kaminsky
+ * SPDX-FileCopyrightText: 2019 Andy Scherzinger
+ * SPDX-FileCopyrightText: 2015 ownCloud Inc.
+ * SPDX-FileCopyrightText: 2015 María Asensio Valverde
+ * SPDX-FileCopyrightText: 2014 David A. Velasco
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+package com.owncloud.android.operations.albums
+
+import androidx.core.net.toUri
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavEntry
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.lib.resources.files.model.RemoteFile
+import com.owncloud.android.utils.FileStorageUtils
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.DavConstants
+import org.apache.jackrabbit.webdav.MultiStatus
+import org.apache.jackrabbit.webdav.client.methods.PropFindMethod
+
+class ReadAlbumItemsRemoteOperation
+@JvmOverloads
+constructor(
+ private val mRemotePath: String,
+ private val storageManager: FileDataStorageManager?,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+) : RemoteOperation>() {
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult> {
+ var result: RemoteOperationResult>? = null
+ var query: PropFindMethod? = null
+ val url = "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums${
+ WebdavUtils.encodePath(
+ mRemotePath
+ )
+ }"
+ try {
+ // remote request
+ query =
+ PropFindMethod(
+ url,
+ WebdavUtils.getAllPropSet(), // PropFind Properties
+ DavConstants.DEPTH_1
+ )
+ val status =
+ client.executeMethod(
+ query,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+
+ // check and process response
+ val isSuccess = (status == HttpStatus.SC_MULTI_STATUS || status == HttpStatus.SC_OK)
+
+ result =
+ if (isSuccess) {
+ // get data from remote folder
+ val dataInServer = query.responseBodyAsMultiStatus
+ val mFolderAndFiles = readAlbumData(dataInServer, client, storageManager)
+
+ // Result of the operation
+ RemoteOperationResult>(true, query).apply {
+ // Add data to the result
+ resultData = mFolderAndFiles
+ }
+ } else {
+ // synchronization failed
+ client.exhaustResponse(query.responseBodyAsStream)
+ RemoteOperationResult(false, query)
+ }
+ } catch (e: Exception) {
+ result = RemoteOperationResult(e)
+ } finally {
+ query?.releaseConnection()
+
+ result = result ?: RemoteOperationResult>(Exception("unknown error")).also {
+ Log_OC.e(TAG, "Synchronized $mRemotePath: failed")
+ }
+ if (result.isSuccess) {
+ Log_OC.i(TAG, "Synchronized $mRemotePath : ${result.logMessage}")
+ } else if (result.isException) {
+ Log_OC.e(TAG, "Synchronized $mRemotePath : ${result.logMessage}", result.exception)
+ } else {
+ Log_OC.e(TAG, "Synchronized $mRemotePath : ${result.logMessage}")
+ }
+ }
+
+ return result
+ }
+
+ companion object {
+ private val TAG: String = ReadAlbumItemsRemoteOperation::class.java.simpleName
+
+ private fun readAlbumData(
+ remoteData: MultiStatus,
+ client: OwnCloudClient,
+ storageManager: FileDataStorageManager?
+ ): List {
+ val baseUrl = "${client.baseUri}/remote.php/dav/photos/${client.userId}"
+ val encodedPath = baseUrl.toUri().encodedPath ?: return emptyList()
+
+ val files = mutableListOf()
+ val responses = remoteData.responses
+
+ // reading from 1 as 0th item will be just the root album path
+ for (i in 1..
+ * SPDX-FileCopyrightText: 2019 Andy Scherzinger
+ * SPDX-FileCopyrightText: 2015 ownCloud Inc.
+ * SPDX-FileCopyrightText: 2015 María Asensio Valverde
+ * SPDX-FileCopyrightText: 2014 David A. Velasco
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+package com.owncloud.android.operations.albums
+
+import android.text.TextUtils
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavEntry
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.DavConstants
+import org.apache.jackrabbit.webdav.client.methods.PropFindMethod
+import org.apache.jackrabbit.webdav.property.DavPropertyName
+import org.apache.jackrabbit.webdav.property.DavPropertyNameSet
+import org.apache.jackrabbit.webdav.xml.Namespace
+
+class ReadAlbumsRemoteOperation
+@JvmOverloads
+constructor(
+ private val mAlbumRemotePath: String? = null,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+) : RemoteOperation>() {
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult> {
+ var propfind: PropFindMethod? = null
+ var result: RemoteOperationResult>
+ var url = "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums"
+ if (!TextUtils.isEmpty(mAlbumRemotePath)) {
+ url += WebdavUtils.encodePath(mAlbumRemotePath)
+ }
+ try {
+ propfind = PropFindMethod(url, getAlbumPropSet(), DavConstants.DEPTH_1)
+ val status =
+ client.executeMethod(
+ propfind,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+ val isSuccess = status == HttpStatus.SC_MULTI_STATUS || status == HttpStatus.SC_OK
+ if (isSuccess) {
+ val albumsList =
+ propfind.responseBodyAsMultiStatus.responses
+ .filter { it.status[0].statusCode == HttpStatus.SC_OK }
+ .map { res -> PhotoAlbumEntry(res) }
+ result = RemoteOperationResult>(true, propfind)
+ result.resultData = albumsList
+ } else {
+ result = RemoteOperationResult>(false, propfind)
+ client.exhaustResponse(propfind.responseBodyAsStream)
+ }
+ } catch (e: Exception) {
+ result = RemoteOperationResult>(e)
+ Log_OC.e(TAG, "Read album failed: ${result.logMessage}", result.exception)
+ } finally {
+ propfind?.releaseConnection()
+ }
+
+ return result
+ }
+
+ companion object {
+ private val TAG: String = ReadAlbumsRemoteOperation::class.java.simpleName
+ private const val PROPERTY_LAST_PHOTO = "last-photo"
+ private const val PROPERTY_NB_ITEMS = "nbItems"
+ private const val PROPERTY_LOCATION = "location"
+ private const val PROPERTY_DATE_RANGE = "dateRange"
+ private const val PROPERTY_COLLABORATORS = "collaborators"
+
+ private fun getAlbumPropSet(): DavPropertyNameSet {
+ val propertySet = DavPropertyNameSet()
+ val ncNamespace: Namespace = Namespace.getNamespace("nc", WebdavEntry.NAMESPACE_NC)
+
+ propertySet.add(DavPropertyName.create(PROPERTY_LAST_PHOTO, ncNamespace))
+ propertySet.add(DavPropertyName.create(PROPERTY_NB_ITEMS, ncNamespace))
+ propertySet.add(DavPropertyName.create(PROPERTY_LOCATION, ncNamespace))
+ propertySet.add(DavPropertyName.create(PROPERTY_DATE_RANGE, ncNamespace))
+ propertySet.add(DavPropertyName.create(PROPERTY_COLLABORATORS, ncNamespace))
+
+ return propertySet
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumFileRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumFileRemoteOperation.kt
new file mode 100644
index 000000000000..529480928572
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumFileRemoteOperation.kt
@@ -0,0 +1,63 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package com.owncloud.android.operations.albums
+
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.lib.resources.files.RemoveFileRemoteOperation
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.client.methods.DeleteMethod
+
+class RemoveAlbumFileRemoteOperation
+ @JvmOverloads
+ constructor(
+ private val mRemotePath: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+ ) : RemoteOperation() {
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ var result: RemoteOperationResult
+ var delete: DeleteMethod? = null
+ val webDavUrl = "${client.davUri}/photos/"
+ val encodedPath = "${client.userId}${WebdavUtils.encodePath(this.mRemotePath)}"
+ val fullFilePath = "$webDavUrl$encodedPath"
+
+ try {
+ delete = DeleteMethod(fullFilePath)
+ val status =
+ client.executeMethod(
+ delete,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+ delete.responseBodyAsString
+ result =
+ RemoteOperationResult(
+ delete.succeeded() || status == HttpStatus.SC_NOT_FOUND,
+ delete
+ )
+ Log_OC.i(TAG, "Remove ${this.mRemotePath} : ${result.logMessage}")
+ } catch (e: Exception) {
+ result = RemoteOperationResult(e)
+ Log_OC.e(TAG, "Remove ${this.mRemotePath} : ${result.logMessage}", e)
+ } finally {
+ delete?.releaseConnection()
+ }
+
+ return result
+ }
+
+ companion object {
+ private val TAG: String = RemoveFileRemoteOperation::class.java.simpleName
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumRemoteOperation.kt
new file mode 100644
index 000000000000..17f29035ab3b
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumRemoteOperation.kt
@@ -0,0 +1,75 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2021 Tobias Kaminsky
+ * SPDX-FileCopyrightText: 2019 Andy Scherzinger
+ * SPDX-FileCopyrightText: 2015 ownCloud Inc.
+ * SPDX-FileCopyrightText: 2015 María Asensio Valverde
+ * SPDX-FileCopyrightText: 2014 David A. Velasco
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+package com.owncloud.android.operations.albums
+
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.client.methods.DeleteMethod
+
+class RemoveAlbumRemoteOperation
+ @JvmOverloads
+ constructor(
+ private val albumName: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+ ) : RemoteOperation() {
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ var result: RemoteOperationResult
+ var delete: DeleteMethod? = null
+
+ try {
+ delete =
+ DeleteMethod(
+ "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums${
+ WebdavUtils.encodePath(
+ albumName
+ )
+ }"
+ )
+ val status =
+ client.executeMethod(
+ delete,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+ delete.responseBodyAsString
+ result =
+ RemoteOperationResult(
+ delete.succeeded() || status == HttpStatus.SC_NOT_FOUND,
+ delete
+ )
+ Log_OC.i(TAG, "Remove ${this.albumName} : ${result.logMessage}")
+ } catch (e: Exception) {
+ result = RemoteOperationResult(e)
+ Log_OC.e(TAG, "Remove ${this.albumName} : ${result.logMessage}", e)
+ } finally {
+ delete?.releaseConnection()
+ }
+
+ return result
+ }
+
+ companion object {
+ private val TAG: String = RemoveAlbumRemoteOperation::class.java.simpleName
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/RenameAlbumRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/RenameAlbumRemoteOperation.kt
new file mode 100644
index 000000000000..523c3cbadc3b
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/RenameAlbumRemoteOperation.kt
@@ -0,0 +1,87 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2021 Tobias Kaminsky
+ * SPDX-FileCopyrightText: 2019 Andy Scherzinger
+ * SPDX-FileCopyrightText: 2015 ownCloud Inc.
+ * SPDX-FileCopyrightText: 2015 María Asensio Valverde
+ * SPDX-FileCopyrightText: 2014 David A. Velasco
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+package com.owncloud.android.operations.albums
+
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.client.methods.MoveMethod
+
+class RenameAlbumRemoteOperation
+ @JvmOverloads
+ constructor(
+ private val mOldRemotePath: String,
+ val newAlbumName: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+ ) : RemoteOperation() {
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult? {
+ var result: RemoteOperationResult? = null
+ var move: MoveMethod? = null
+ val url = "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums"
+ try {
+ if (this.newAlbumName != this.mOldRemotePath) {
+ move =
+ MoveMethod(
+ "$url${WebdavUtils.encodePath(mOldRemotePath)}",
+ "$url${
+ WebdavUtils.encodePath(
+ newAlbumName
+ )
+ }",
+ false
+ )
+ client.executeMethod(
+ move,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+ result = RemoteOperationResult(move.succeeded(), move)
+ Log_OC.i(
+ TAG,
+ "Rename ${this.mOldRemotePath} to ${this.newAlbumName} : ${result.logMessage}"
+ )
+ // NMC-4786 fix
+ // album name already exist
+ if (!result.isSuccess && result.httpCode == HttpStatus.SC_PRECONDITION_FAILED) {
+ result = RemoteOperationResult(RemoteOperationResult.ResultCode.INVALID_OVERWRITE)
+ }
+ client.exhaustResponse(move.responseBodyAsStream)
+ }
+ } catch (e: Exception) {
+ result = RemoteOperationResult(e)
+ Log_OC.e(
+ TAG,
+ "Rename ${this.mOldRemotePath} to ${this.newAlbumName} : ${result.logMessage}",
+ e
+ )
+ } finally {
+ move?.releaseConnection()
+ }
+
+ return result
+ }
+
+ companion object {
+ private val TAG: String = RenameAlbumRemoteOperation::class.java.simpleName
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/ToggleAlbumFavoriteRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/ToggleAlbumFavoriteRemoteOperation.kt
new file mode 100644
index 000000000000..d2f23d5a12a3
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/ToggleAlbumFavoriteRemoteOperation.kt
@@ -0,0 +1,79 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package com.owncloud.android.operations.albums
+
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavEntry
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.resources.files.ToggleFavoriteRemoteOperation
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.client.methods.PropPatchMethod
+import org.apache.jackrabbit.webdav.property.DavPropertyNameSet
+import org.apache.jackrabbit.webdav.property.DavPropertySet
+import org.apache.jackrabbit.webdav.property.DefaultDavProperty
+import org.apache.jackrabbit.webdav.xml.Namespace
+import java.io.IOException
+
+class ToggleAlbumFavoriteRemoteOperation
+ @JvmOverloads
+ constructor(
+ private val makeItFavorited: Boolean,
+ private val filePath: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+ ) : RemoteOperation() {
+ @Deprecated("Deprecated in Java")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ // when file is in local db the remotePath will be actual path instead of albums path
+ // to perform operation we have to call files dav uri
+ if (!filePath.contains("/albums/")) {
+ return ToggleFavoriteRemoteOperation(makeItFavorited, filePath).execute(client)
+ }
+
+ var result: RemoteOperationResult
+ var propPatchMethod: PropPatchMethod? = null
+ val newProps = DavPropertySet()
+ val removeProperties = DavPropertyNameSet()
+ val favoriteProperty =
+ DefaultDavProperty(
+ "oc:favorite",
+ if(this.makeItFavorited) "1" else "0",
+ Namespace.getNamespace(WebdavEntry.NAMESPACE_OC)
+ )
+ newProps.add(favoriteProperty)
+
+ val webDavUrl = "${client.davUri}/photos/"
+ val encodedPath = "${client.userId}${WebdavUtils.encodePath(this.filePath)}"
+ val fullFilePath = "$webDavUrl$encodedPath"
+
+ try {
+ propPatchMethod = PropPatchMethod(fullFilePath, newProps, removeProperties)
+ val status =
+ client.executeMethod(
+ propPatchMethod,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+ val isSuccess = (status == HttpStatus.SC_MULTI_STATUS || status == HttpStatus.SC_OK)
+ if (isSuccess) {
+ result = RemoteOperationResult(true, status, propPatchMethod.responseHeaders)
+ } else {
+ client.exhaustResponse(propPatchMethod.responseBodyAsStream)
+ result = RemoteOperationResult(false, status, propPatchMethod.responseHeaders)
+ }
+ } catch (e: IOException) {
+ result = RemoteOperationResult(e)
+ } finally {
+ propPatchMethod?.releaseConnection()
+ }
+
+ return result
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/services/OperationsService.java b/app/src/main/java/com/owncloud/android/services/OperationsService.java
index 6923606819af..b4c2f901c226 100644
--- a/app/src/main/java/com/owncloud/android/services/OperationsService.java
+++ b/app/src/main/java/com/owncloud/android/services/OperationsService.java
@@ -64,6 +64,10 @@
import com.owncloud.android.operations.UpdateShareInfoOperation;
import com.owncloud.android.operations.UpdateSharePermissionsOperation;
import com.owncloud.android.operations.UpdateShareViaLinkOperation;
+import com.owncloud.android.operations.albums.CopyFileToAlbumOperation;
+import com.owncloud.android.operations.albums.CreateNewAlbumRemoteOperation;
+import com.owncloud.android.operations.albums.RemoveAlbumRemoteOperation;
+import com.owncloud.android.operations.albums.RenameAlbumRemoteOperation;
import java.io.IOException;
import java.util.Optional;
@@ -125,6 +129,11 @@ public class OperationsService extends Service {
public static final String ACTION_CHECK_CURRENT_CREDENTIALS = "CHECK_CURRENT_CREDENTIALS";
public static final String ACTION_RESTORE_VERSION = "RESTORE_VERSION";
public static final String ACTION_UPDATE_FILES_DOWNLOAD_LIMIT = "UPDATE_FILES_DOWNLOAD_LIMIT";
+ public static final String ACTION_CREATE_ALBUM = "CREATE_ALBUM";
+ public static final String EXTRA_ALBUM_NAME = "ALBUM_NAME";
+ public static final String ACTION_ALBUM_COPY_FILE = "ALBUM_COPY_FILE";
+ public static final String ACTION_RENAME_ALBUM = "RENAME_ALBUM";
+ public static final String ACTION_REMOVE_ALBUM = "REMOVE_ALBUM";
private ServiceHandler mOperationsHandler;
private OperationsServiceBinder mOperationsBinder;
@@ -774,6 +783,28 @@ private Pair newOperation(Intent operationIntent) {
}
break;
+ case ACTION_CREATE_ALBUM:
+ String albumName = operationIntent.getStringExtra(EXTRA_ALBUM_NAME);
+ operation = new CreateNewAlbumRemoteOperation(albumName);
+ break;
+
+ case ACTION_ALBUM_COPY_FILE:
+ remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH);
+ newParentPath = operationIntent.getStringExtra(EXTRA_NEW_PARENT_PATH);
+ operation = new CopyFileToAlbumOperation(remotePath, newParentPath, fileDataStorageManager);
+ break;
+
+ case ACTION_RENAME_ALBUM:
+ remotePath = operationIntent.getStringExtra(EXTRA_REMOTE_PATH);
+ String newAlbumName = operationIntent.getStringExtra(EXTRA_NEWNAME);
+ operation = new RenameAlbumRemoteOperation(remotePath, newAlbumName);
+ break;
+
+ case ACTION_REMOVE_ALBUM:
+ String albumNameToRemove = operationIntent.getStringExtra(EXTRA_ALBUM_NAME);
+ operation = new RemoveAlbumRemoteOperation(albumNameToRemove);
+ break;
+
default:
// do nothing
break;
diff --git a/app/src/main/java/com/owncloud/android/ui/activity/AlbumsPickerActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/AlbumsPickerActivity.kt
new file mode 100644
index 000000000000..a417c6cc96c5
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/activity/AlbumsPickerActivity.kt
@@ -0,0 +1,221 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.activity
+
+import android.content.Intent
+import android.content.res.Resources
+import android.os.Bundle
+import android.view.MenuItem
+import android.view.View
+import androidx.fragment.app.FragmentActivity
+import com.nextcloud.client.di.Injectable
+import com.owncloud.android.R
+import com.owncloud.android.databinding.FilesFolderPickerBinding
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.lib.resources.files.SearchRemoteOperation
+import com.owncloud.android.operations.albums.CreateNewAlbumRemoteOperation
+import com.owncloud.android.ui.activity.FolderPickerActivity.Companion.TAG_LIST_OF_FOLDERS
+import com.owncloud.android.ui.events.SearchEvent
+import com.owncloud.android.ui.fragment.FileFragment
+import com.owncloud.android.ui.fragment.GalleryFragment
+import com.owncloud.android.ui.fragment.OCFileListFragment
+import com.owncloud.android.ui.fragment.albums.AlbumsFragment
+import com.owncloud.android.utils.DisplayUtils
+import com.owncloud.android.utils.ErrorMessageAdapter
+
+class AlbumsPickerActivity : FileActivity(), FileFragment.ContainerActivity, OnEnforceableRefreshListener, Injectable {
+
+ private var captionText: String? = null
+
+ private var action: String? = null
+
+ private lateinit var folderPickerBinding: FilesFolderPickerBinding
+
+ private fun initBinding() {
+ folderPickerBinding = FilesFolderPickerBinding.inflate(layoutInflater)
+ setContentView(folderPickerBinding.root)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ Log_OC.d(TAG, "onCreate() start")
+
+ super.onCreate(savedInstanceState)
+
+ initBinding()
+ setupToolbar()
+ showHideDefaultToolbarDivider(true)
+ setupAction()
+ setupActionBar()
+
+ if (savedInstanceState == null) {
+ createFragments()
+ }
+
+ updateActionBarTitleAndHomeButtonByString(captionText)
+ }
+
+ private fun setupActionBar() {
+ findViewById(R.id.sort_list_button_group).visibility =
+ View.GONE
+ findViewById(R.id.switch_grid_view_button).visibility =
+ View.GONE
+ supportActionBar?.let { actionBar ->
+ actionBar.setDisplayHomeAsUpEnabled(true)
+ actionBar.setHomeButtonEnabled(true)
+ captionText?.let {
+ viewThemeUtils.files.themeActionBar(this, actionBar, it)
+ }
+ }
+ }
+
+ private fun setupAction() {
+ action = intent.getStringExtra(EXTRA_ACTION)
+ setupUIForChooseButton()
+ }
+
+ private fun setupUIForChooseButton() {
+ if (action == CHOOSE_ALBUM) {
+ captionText = resources.getText(R.string.album_picker_toolbar_title).toString()
+ } else if (action == CHOOSE_MEDIA_FILES) {
+ captionText = resources.getText(R.string.media_picker_toolbar_title).toString()
+ }
+
+ folderPickerBinding.bottomLayout.visibility = View.GONE
+ folderPickerBinding.divider.visibility = View.GONE
+ }
+
+ private fun createFragments() {
+ if (action == CHOOSE_ALBUM) {
+ val transaction = supportFragmentManager.beginTransaction()
+ transaction.add(
+ R.id.fragment_container,
+ AlbumsFragment.newInstance(isSelectionMode = true),
+ AlbumsFragment.TAG
+ )
+ transaction.commit()
+ } else if (action == CHOOSE_MEDIA_FILES) {
+ createGalleryFragment()
+ }
+ }
+
+ private fun createGalleryFragment() {
+ val photoFragment = GalleryFragment()
+ val bundle = Bundle()
+ bundle.putParcelable(
+ OCFileListFragment.SEARCH_EVENT,
+ SearchEvent("image/%", SearchRemoteOperation.SearchType.PHOTO_SEARCH)
+ )
+ bundle.putBoolean(EXTRA_FROM_ALBUM, true)
+ photoFragment.arguments = bundle
+
+ val transaction = supportFragmentManager.beginTransaction()
+ transaction.add(R.id.fragment_container, photoFragment, TAG_LIST_OF_FOLDERS)
+ transaction.commit()
+ }
+
+ private val listOfFilesFragment: AlbumsFragment?
+ get() {
+ val listOfFiles = supportFragmentManager.findFragmentByTag(AlbumsFragment.TAG)
+
+ return if (listOfFiles != null) {
+ return listOfFiles as AlbumsFragment?
+ } else {
+ Log_OC.e(TAG, "Access to non existing list of albums fragment!!")
+ null
+ }
+ }
+
+ override fun onRemoteOperationFinish(operation: RemoteOperation<*>?, result: RemoteOperationResult<*>) {
+ super.onRemoteOperationFinish(operation, result)
+ if (operation is CreateNewAlbumRemoteOperation) {
+ onCreateAlbumOperationFinish(operation, result)
+ }
+ }
+
+ /**
+ * Updates the view associated to the activity after the finish of an operation trying to create a new folder.
+ *
+ * @param operation Creation operation performed.
+ * @param result Result of the creation.
+ */
+ @Suppress("MaxLineLength")
+ private fun onCreateAlbumOperationFinish(
+ operation: CreateNewAlbumRemoteOperation,
+ result: RemoteOperationResult<*>
+ ) {
+ if (result.isSuccess) {
+ val fileListFragment = listOfFilesFragment
+ fileListFragment?.refreshAlbums()
+ } else {
+ try {
+ DisplayUtils.showSnackMessage(
+ this,
+ ErrorMessageAdapter.getErrorCauseMessage(result, operation, resources)
+ )
+ } catch (e: Resources.NotFoundException) {
+ Log_OC.e(TAG, "Error while trying to show fail message ", e)
+ }
+ }
+ }
+
+ override fun showDetails(file: OCFile?) {
+ // not used at the moment
+ }
+
+ override fun showDetails(file: OCFile?, activeTab: Int) {
+ // not used at the moment
+ }
+
+ override fun onBrowsedDownTo(folder: OCFile?) {
+ // not used at the moment
+ }
+
+ override fun onTransferStateChanged(file: OCFile?, downloading: Boolean, uploading: Boolean) {
+ // not used at the moment
+ }
+
+ companion object {
+ private val EXTRA_ACTION = AlbumsPickerActivity::class.java.canonicalName?.plus(".EXTRA_ACTION")
+ private val CHOOSE_ALBUM = AlbumsPickerActivity::class.java.canonicalName?.plus(".CHOOSE_ALBUM")
+ private val CHOOSE_MEDIA_FILES = AlbumsPickerActivity::class.java.canonicalName?.plus(".CHOOSE_MEDIA_FILES")
+ val EXTRA_FROM_ALBUM = AlbumsPickerActivity::class.java.canonicalName?.plus(".EXTRA_FROM_ALBUM")
+ val EXTRA_MEDIA_FILES_PATH = AlbumsPickerActivity::class.java.canonicalName?.plus(".EXTRA_MEDIA_FILES_PATH")
+
+ private val TAG = AlbumsPickerActivity::class.java.simpleName
+
+ fun intentForPickingAlbum(context: FragmentActivity): Intent {
+ return Intent(context, AlbumsPickerActivity::class.java).apply {
+ putExtra(EXTRA_ACTION, CHOOSE_ALBUM)
+ }
+ }
+
+ fun intentForPickingMediaFiles(context: FragmentActivity): Intent {
+ return Intent(context, AlbumsPickerActivity::class.java).apply {
+ putExtra(EXTRA_ACTION, CHOOSE_MEDIA_FILES)
+ }
+ }
+ }
+
+ override fun onRefresh(enforced: Boolean) {
+ // do nothing
+ }
+
+ override fun onRefresh() {
+ // do nothing
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ android.R.id.home -> super.onBackPressed()
+ }
+ return super.onOptionsItemSelected(item)
+ }
+}
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 29aa804614f2..f26cc5f115fc 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
@@ -94,6 +94,8 @@
import com.owncloud.android.ui.fragment.GroupfolderListFragment;
import com.owncloud.android.ui.fragment.OCFileListFragment;
import com.owncloud.android.ui.fragment.SharedListFragment;
+import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment;
+import com.owncloud.android.ui.fragment.albums.AlbumsFragment;
import com.owncloud.android.ui.preview.PreviewTextStringFragment;
import com.owncloud.android.ui.trashbin.TrashbinActivity;
import com.owncloud.android.utils.BitmapUtils;
@@ -125,6 +127,7 @@
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentTransaction;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import hct.Hct;
import kotlin.Unit;
@@ -263,11 +266,7 @@ private void handleBottomNavigationViewClicks() {
resetOnlyPersonalAndOnDevice();
if (menuItemId == R.id.nav_all_files) {
- showFiles(false,false);
- if (this instanceof FileDisplayActivity fda) {
- fda.browseToRoot();
- }
- EventBus.getDefault().post(new ChangeMenuEvent());
+ onNavigationItemClicked(menuItem);
} else if (menuItemId == R.id.nav_favorites) {
setupToolbar();
handleSearchEvents(new SearchEvent("", SearchRemoteOperation.SearchType.FAVORITE_SEARCH), menuItemId);
@@ -276,6 +275,8 @@ private void handleBottomNavigationViewClicks() {
} else if (menuItemId == R.id.nav_gallery) {
setupToolbar();
startPhotoSearch(menuItem.getItemId());
+ } else if (menuItemId == R.id.nav_album) {
+ replaceAlbumFragment();
}
// Remove extra icon from the action bar
@@ -558,7 +559,8 @@ private void onNavigationItemClicked(final MenuItem menuItem) {
!(fda.getLeftFragment() instanceof GalleryFragment) &&
!(fda.getLeftFragment() instanceof SharedListFragment) &&
!(fda.getLeftFragment() instanceof GroupfolderListFragment) &&
- !(fda.getLeftFragment() instanceof PreviewTextStringFragment)) {
+ !(fda.getLeftFragment() instanceof PreviewTextStringFragment) &&
+ !isAlbumsFragment() && !isAlbumItemsFragment()) {
showFiles(false, itemId == R.id.nav_personal_files);
fda.browseToRoot();
EventBus.getDefault().post(new ChangeMenuEvent());
@@ -580,6 +582,17 @@ private void onNavigationItemClicked(final MenuItem menuItem) {
resetOnlyPersonalAndOnDevice();
setupToolbar();
startPhotoSearch(menuItem.getItemId());
+ } else if (itemId == R.id.nav_album) {
+ if (this instanceof FileDisplayActivity) {
+ replaceAlbumFragment();
+ } else {
+ // when user is not on FileDisplayActivity
+ // if user is on TrashbinActivity then we have to start activity again
+ Intent intent = new Intent(getApplicationContext(), FileDisplayActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ intent.setAction(FileDisplayActivity.ALBUMS);
+ startActivity(intent);
+ }
} else if (itemId == R.id.nav_on_device) {
EventBus.getDefault().post(new ChangeMenuEvent());
showFiles(true, false);
@@ -609,7 +622,7 @@ private void onNavigationItemClicked(final MenuItem menuItem) {
resetOnlyPersonalAndOnDevice();
menuItemId = Menu.NONE;
MenuItem isNewMenuItemChecked = menuItem.setChecked(false);
- Log_OC.d(TAG,"onNavigationItemClicked nav_logout setChecked " + isNewMenuItemChecked);
+ Log_OC.d(TAG, "onNavigationItemClicked nav_logout setChecked " + isNewMenuItemChecked);
final Optional optionalUser = getUser();
if (optionalUser.isPresent()) {
UserInfoActivity.openAccountRemovalDialog(optionalUser.get(), getSupportFragmentManager());
@@ -640,6 +653,26 @@ private void onNavigationItemClicked(final MenuItem menuItem) {
}
}
+ public void replaceAlbumFragment() {
+ if (isAlbumsFragment()) {
+ return;
+ }
+ FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
+ transaction.addToBackStack(null);
+ transaction.replace(R.id.left_fragment_container, AlbumsFragment.Companion.newInstance(false), AlbumsFragment.Companion.getTAG());
+ transaction.commit();
+ }
+
+ public boolean isAlbumsFragment() {
+ Fragment albumsFragment = getSupportFragmentManager().findFragmentByTag(AlbumsFragment.Companion.getTAG());
+ return albumsFragment instanceof AlbumsFragment && albumsFragment.isVisible();
+ }
+
+ public boolean isAlbumItemsFragment() {
+ Fragment albumItemsFragment = getSupportFragmentManager().findFragmentByTag(AlbumItemsFragment.Companion.getTAG());
+ return albumItemsFragment instanceof AlbumItemsFragment && albumItemsFragment.isVisible();
+ }
+
private void startComposeActivity(ComposeDestination destination, int titleId) {
Intent composeActivity = new Intent(getApplicationContext(), ComposeActivity.class);
composeActivity.putExtra(ComposeActivity.DESTINATION, destination);
@@ -701,7 +734,8 @@ public void startPhotoSearch(int id) {
private void handleSearchEvents(SearchEvent searchEvent, int menuItemId) {
if (this instanceof FileDisplayActivity) {
final Fragment leftFragment = ((FileDisplayActivity) this).getLeftFragment();
- if (leftFragment instanceof GalleryFragment || leftFragment instanceof SharedListFragment) {
+ if (leftFragment instanceof GalleryFragment || leftFragment instanceof SharedListFragment
+ || isAlbumsFragment() || isAlbumItemsFragment()) {
launchActivityForSearch(searchEvent, menuItemId);
} else {
EventBus.getDefault().post(searchEvent);
diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java
index f041d0854666..4df770b0592e 100644
--- a/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java
+++ b/app/src/main/java/com/owncloud/android/ui/activity/FileActivity.java
@@ -91,6 +91,8 @@
import com.owncloud.android.ui.fragment.FileDetailFragment;
import com.owncloud.android.ui.fragment.FileDetailSharingFragment;
import com.owncloud.android.ui.fragment.OCFileListFragment;
+import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment;
+import com.owncloud.android.ui.fragment.albums.AlbumsFragment;
import com.owncloud.android.ui.fragment.filesRepository.FilesRepository;
import com.owncloud.android.ui.fragment.filesRepository.RemoteFilesRepository;
import com.owncloud.android.ui.helpers.FileOperationsHelper;
@@ -107,6 +109,7 @@
import java.lang.ref.WeakReference;
import java.util.ArrayList;
+import java.util.Objects;
import javax.inject.Inject;
@@ -819,11 +822,19 @@ private void onUpdateShareInformation(RemoteOperationResult result, @StringRes i
}
public void refreshList() {
- final Fragment fragment = getSupportFragmentManager().findFragmentByTag(FileDisplayActivity.TAG_LIST_OF_FILES);
- if (fragment instanceof OCFileListFragment listFragment) {
- listFragment.onRefresh();
- } else if (fragment instanceof FileDetailFragment detailFragment) {
- detailFragment.goBackToOCFileListFragment();
+ // NMC Customization
+ // first check for album fragments
+ if (isAlbumsFragment()) {
+ ((AlbumsFragment) Objects.requireNonNull(getSupportFragmentManager().findFragmentByTag(AlbumsFragment.Companion.getTAG()))).refreshAlbums();
+ } else if (isAlbumItemsFragment()) {
+ ((AlbumItemsFragment) Objects.requireNonNull(getSupportFragmentManager().findFragmentByTag(AlbumItemsFragment.Companion.getTAG()))).refreshData();
+ } else {
+ final Fragment fragment = getSupportFragmentManager().findFragmentByTag(FileDisplayActivity.TAG_LIST_OF_FILES);
+ if (fragment instanceof OCFileListFragment listFragment) {
+ listFragment.onRefresh();
+ } else if (fragment instanceof FileDetailFragment detailFragment) {
+ detailFragment.goBackToOCFileListFragment();
+ }
}
}
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 74a2a51b0980..4804a9d9dbb4 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
@@ -108,6 +108,10 @@ import com.owncloud.android.operations.RemoveFileOperation
import com.owncloud.android.operations.RenameFileOperation
import com.owncloud.android.operations.SynchronizeFileOperation
import com.owncloud.android.operations.UploadFileOperation
+import com.owncloud.android.operations.albums.CopyFileToAlbumOperation
+import com.owncloud.android.operations.albums.CreateNewAlbumRemoteOperation
+import com.owncloud.android.operations.albums.RemoveAlbumRemoteOperation
+import com.owncloud.android.operations.albums.RenameAlbumRemoteOperation
import com.owncloud.android.syncadapter.FileSyncAdapter
import com.owncloud.android.ui.CompletionCallback
import com.owncloud.android.ui.asynctasks.CheckAvailableSpaceTask
@@ -132,6 +136,8 @@ import com.owncloud.android.ui.fragment.SearchType
import com.owncloud.android.ui.fragment.SharedListFragment
import com.owncloud.android.ui.fragment.TaskRetainerFragment
import com.owncloud.android.ui.fragment.UnifiedSearchFragment
+import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment
+import com.owncloud.android.ui.fragment.albums.AlbumsFragment
import com.owncloud.android.ui.helpers.FileOperationsHelper
import com.owncloud.android.ui.helpers.UriUploader
import com.owncloud.android.ui.interfaces.TransactionInterface
@@ -571,6 +577,12 @@ class FileDisplayActivity :
leftFragment = GroupfolderListFragment()
supportFragmentManager.executePendingTransactions()
}
+ ALBUMS == action -> {
+ Log_OC.d(this, "Switch to list albums fragment")
+ menuItemId = R.id.nav_album
+ replaceAlbumFragment()
+ supportFragmentManager.executePendingTransactions()
+ }
}
}
@@ -955,6 +967,7 @@ class FileDisplayActivity :
!isSearchOpen() &&
isRoot(getCurrentDir()) &&
this.leftFragment is OCFileListFragment
+ && !isAlbumItemsFragment()
) {
openDrawer()
} else {
@@ -1184,6 +1197,12 @@ class FileDisplayActivity :
return
}
+ // NMC Customization: pop back if current fragment is AlbumItemsFragment
+ if (isAlbumItemsFragment()) {
+ popBack()
+ return
+ }
+
if (this.leftFragment is OCFileListFragment) {
if (isRoot(getCurrentDir())) {
finish()
@@ -1685,6 +1704,14 @@ class FileDisplayActivity :
// TODO what about other kind of previews?
}
}
+
+ // NMC Customization
+ // notify when upload is finshed and user is on albums screen
+ if (isAlbumsFragment()) {
+ (supportFragmentManager.findFragmentByTag(AlbumsFragment.Companion.TAG) as AlbumsFragment).refreshAlbums()
+ } else if (isAlbumItemsFragment()) {
+ (supportFragmentManager.findFragmentByTag(AlbumItemsFragment.Companion.TAG) as AlbumItemsFragment).refreshData()
+ }
}
// TODO refactor this receiver, and maybe DownloadFinishReceiver; this method is duplicated :S
@@ -2021,6 +2048,22 @@ class FileDisplayActivity :
is RestoreFileVersionRemoteOperation -> {
onRestoreFileVersionOperationFinish(result)
}
+
+ is CreateNewAlbumRemoteOperation -> {
+ onCreateAlbumOperationFinish(operation, result)
+ }
+
+ is CopyFileToAlbumOperation -> {
+ onCopyAlbumFileOperationFinish(operation, result)
+ }
+
+ is RenameAlbumRemoteOperation -> {
+ onRenameAlbumOperationFinish(operation, result)
+ }
+
+ is RemoveAlbumRemoteOperation -> {
+ onRemoveAlbumOperationFinish(operation, result)
+ }
}
}
@@ -2273,6 +2316,94 @@ class FileDisplayActivity :
}
}
+ private fun onRemoveAlbumOperationFinish(operation: RemoveAlbumRemoteOperation, result: RemoteOperationResult<*>) {
+ if (result.isSuccess) {
+
+ val fragment = supportFragmentManager.findFragmentByTag(AlbumItemsFragment.TAG)
+ if (fragment is AlbumItemsFragment) {
+ fragment.onAlbumDeleted()
+ }
+ } else {
+ DisplayUtils.showSnackMessage(
+ this,
+ ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources())
+ )
+
+ if (result.isSslRecoverableException) {
+ mLastSslUntrustedServerResult = result
+ showUntrustedCertDialog(mLastSslUntrustedServerResult)
+ }
+ }
+ }
+
+ private fun onCopyAlbumFileOperationFinish(operation: CopyFileToAlbumOperation, result: RemoteOperationResult<*>) {
+ if (result.isSuccess) {
+ // when item added from inside of Album
+ val fragment = supportFragmentManager.findFragmentByTag(AlbumItemsFragment.TAG)
+ if (fragment is AlbumItemsFragment) {
+ fragment.refreshData()
+ } else {
+ // files added directly from Media tab
+ DisplayUtils.showSnackMessage(this, getResources().getString(R.string.album_file_added_message))
+ }
+ Log_OC.e(TAG, "Files copied successfully")
+ } else {
+ try {
+ DisplayUtils.showSnackMessage(
+ this,
+ ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources())
+ )
+ } catch (e: Resources.NotFoundException) {
+ Log_OC.e(TAG, "Error while trying to show fail message ", e)
+ }
+ }
+ }
+
+ private fun onRenameAlbumOperationFinish(operation: RenameAlbumRemoteOperation, result: RemoteOperationResult<*>) {
+ if (result.isSuccess) {
+
+ val fragment = supportFragmentManager.findFragmentByTag(AlbumItemsFragment.TAG)
+ if (fragment is AlbumItemsFragment) {
+ fragment.onAlbumRenamed(operation.newAlbumName)
+ }
+ } else {
+ DisplayUtils.showSnackMessage(
+ this,
+ ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources())
+ )
+
+ if (result.isSslRecoverableException) {
+ mLastSslUntrustedServerResult = result
+ showUntrustedCertDialog(mLastSslUntrustedServerResult)
+ }
+ }
+ }
+
+ private fun onCreateAlbumOperationFinish(
+ operation: CreateNewAlbumRemoteOperation,
+ result: RemoteOperationResult<*>
+ ) {
+ if (result.isSuccess) {
+ val fragment = supportFragmentManager.findFragmentByTag(AlbumsFragment.TAG)
+ if (fragment is AlbumsFragment) {
+ fragment.navigateToAlbumItemsFragment(operation.newAlbumName, true)
+ }
+ } else {
+ try {
+ if (RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS == result.code) {
+ DisplayUtils.showSnackMessage(this, R.string.album_already_exists)
+ } else {
+ DisplayUtils.showSnackMessage(
+ this,
+ ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources())
+ )
+ }
+ } catch (e: Resources.NotFoundException) {
+ Log_OC.e(TAG, "Error while trying to show fail message ", e)
+ }
+ }
+ }
+
/**
* {@inheritDoc}
*/
@@ -2696,7 +2827,10 @@ class FileDisplayActivity :
val ocFileListFragment = this.listOfFilesFragment
if (ocFileListFragment != null &&
(ocFileListFragment !is GalleryFragment) &&
- (ocFileListFragment !is SharedListFragment)
+ (ocFileListFragment !is SharedListFragment) &&
+ // album fragment check will help in showing offline files screen
+ // when navigating from Albums to Offline Files
+ !isAlbumsFragment && !isAlbumItemsFragment
) {
ocFileListFragment.refreshDirectory()
} else {
@@ -3000,6 +3134,7 @@ class FileDisplayActivity :
const val RESTART: String = "RESTART"
const val ALL_FILES: String = "ALL_FILES"
const val LIST_GROUPFOLDERS: String = "LIST_GROUPFOLDERS"
+ const val ALBUMS: String = "ALBUMS"
const val SINGLE_USER_SIZE: Int = 1
const val OPEN_FILE: String = "NC_OPEN_FILE"
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 8a08c506f64b..853fc11142be 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
@@ -195,6 +195,12 @@ class GalleryAdapter(
}
}
+ @SuppressLint("NotifyDataSetChanged")
+ fun showAlbumItems(albumItems: List) {
+ files = albumItems.toGalleryItems()
+ Handler(Looper.getMainLooper()).post { notifyDataSetChanged() }
+ }
+
private fun transformToRows(list: List): List = list
.sortedBy { it.modificationTimestamp }
.reversed()
@@ -268,6 +274,10 @@ class GalleryAdapter(
notifyItemChanged(getItemPosition(file))
}
+ fun setCheckedItem(files: Set?) {
+ ocFileListDelegate.setCheckedItem(files)
+ }
+
override fun getFilesCount(): Int = files.fold(0) { acc, item -> acc + item.rows.size }
override fun setMultiSelect(boolean: Boolean) {
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt
index 1a11fe7a3251..fadd900f7499 100644
--- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt
@@ -36,6 +36,8 @@ import com.owncloud.android.ui.activity.ComponentsGetter
import com.owncloud.android.ui.activity.FolderPickerActivity
import com.owncloud.android.ui.fragment.GalleryFragment
import com.owncloud.android.ui.fragment.SearchType
+import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment
+import com.owncloud.android.ui.activity.AlbumsPickerActivity
import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface
import com.owncloud.android.utils.BitmapUtils
import com.owncloud.android.utils.DisplayUtils
@@ -121,8 +123,16 @@ class OCFileListDelegate(
)
imageView.setOnClickListener {
- ocFileListFragmentInterface.onItemClicked(file)
- GalleryFragment.setLastMediaItemPosition(galleryRowHolder.absoluteAdapterPosition)
+ // NMC Customization: while picking media directly perform long click
+ if (context is AlbumsPickerActivity) {
+ ocFileListFragmentInterface.onLongItemClicked(
+ file
+ )
+ } else {
+ ocFileListFragmentInterface.onItemClicked(file)
+ GalleryFragment.setLastMediaItemPosition(galleryRowHolder.absoluteAdapterPosition)
+ AlbumItemsFragment.lastMediaItemPosition = galleryRowHolder.absoluteAdapterPosition
+ }
}
if (!hideItemOptions) {
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumFragmentInterface.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumFragmentInterface.kt
new file mode 100644
index 000000000000..e30ef5b41cd9
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumFragmentInterface.kt
@@ -0,0 +1,14 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import com.owncloud.android.operations.albums.PhotoAlbumEntry
+
+interface AlbumFragmentInterface {
+ fun onItemClick(album: PhotoAlbumEntry)
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt
new file mode 100644
index 000000000000..4d7e5c6ca5d0
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt
@@ -0,0 +1,26 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.elyeproj.loaderviewlibrary.LoaderImageView
+import com.owncloud.android.databinding.AlbumsGridItemBinding
+
+internal class AlbumGridItemViewHolder(private var binding: AlbumsGridItemBinding) :
+ RecyclerView.ViewHolder(binding.root), AlbumItemViewHolder {
+ override val thumbnail: ImageView
+ get() = binding.thumbnail
+ override val shimmerThumbnail: LoaderImageView
+ get() = binding.thumbnailShimmer
+ override val albumName: TextView
+ get() = binding.Filename
+ override val albumInfo: TextView
+ get() = binding.fileInfo
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt
new file mode 100644
index 000000000000..a531404e8e99
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt
@@ -0,0 +1,19 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import android.widget.ImageView
+import android.widget.TextView
+import com.elyeproj.loaderviewlibrary.LoaderImageView
+
+interface AlbumItemViewHolder {
+ val thumbnail: ImageView
+ val shimmerThumbnail: LoaderImageView
+ val albumName: TextView
+ val albumInfo: TextView
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt
new file mode 100644
index 000000000000..99ff63eb9a01
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt
@@ -0,0 +1,26 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.elyeproj.loaderviewlibrary.LoaderImageView
+import com.owncloud.android.databinding.AlbumsListItemBinding
+
+internal class AlbumListItemViewHolder(private var binding: AlbumsListItemBinding) :
+ RecyclerView.ViewHolder(binding.root), AlbumItemViewHolder {
+ override val thumbnail: ImageView
+ get() = binding.thumbnail
+ override val shimmerThumbnail: LoaderImageView
+ get() = binding.thumbnailShimmer
+ override val albumName: TextView
+ get() = binding.Filename
+ override val albumInfo: TextView
+ get() = binding.fileInfo
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt
new file mode 100644
index 000000000000..6598a0f77d83
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt
@@ -0,0 +1,125 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.nextcloud.client.account.User
+import com.nextcloud.client.preferences.AppPreferences
+import com.owncloud.android.R
+import com.owncloud.android.databinding.AlbumsGridItemBinding
+import com.owncloud.android.databinding.AlbumsListItemBinding
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.datamodel.SyncedFolderProvider
+import com.owncloud.android.datamodel.ThumbnailsCacheManager
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.operations.albums.PhotoAlbumEntry
+import com.owncloud.android.utils.DisplayUtils
+import com.owncloud.android.utils.theme.ViewThemeUtils
+
+@Suppress("LongParameterList")
+class AlbumsAdapter(
+ val context: Context,
+ private val storageManager: FileDataStorageManager?,
+ private val user: User,
+ private val albumFragmentInterface: AlbumFragmentInterface,
+ private val syncedFolderProvider: SyncedFolderProvider,
+ private val preferences: AppPreferences,
+ private val viewThemeUtils: ViewThemeUtils,
+ private val gridView: Boolean = true
+) :
+ RecyclerView.Adapter() {
+ private var albumList: MutableList = mutableListOf()
+ private val asyncTasks: MutableList = ArrayList()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ return if (gridView) {
+ AlbumGridItemViewHolder(AlbumsGridItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+ } else {
+ AlbumListItemViewHolder(AlbumsListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+ }
+ }
+
+ override fun getItemCount(): Int {
+ return albumList.size
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ val gridViewHolder = holder as AlbumItemViewHolder
+ val file: PhotoAlbumEntry = albumList[position]
+
+ gridViewHolder.albumName.text = file.albumName
+ gridViewHolder.thumbnail.tag = file.lastPhoto
+ gridViewHolder.albumInfo.text = String.format(
+ context.resources.getString(R.string.album_items_text),
+ file.nbItems,
+ file.createdDate
+ )
+
+ if (file.lastPhoto > 0) {
+ var ocLocal = storageManager?.getFileByLocalId(file.lastPhoto)
+ if (ocLocal == null) {
+ // if local file is not present make dummy file with fake remotePath
+ // without remotePath it won't work
+ // lastPhoto is file id which we can set it to localId and remoteId for thumbnail generation
+ val nFile = OCFile("/" + file.albumName)
+ nFile.localId = file.lastPhoto
+ nFile.remoteId = file.lastPhoto.toString()
+ ocLocal = nFile
+ }
+ DisplayUtils.setThumbnail(
+ ocLocal,
+ gridViewHolder.thumbnail,
+ user,
+ storageManager,
+ asyncTasks,
+ gridView,
+ context,
+ gridViewHolder.shimmerThumbnail,
+ preferences,
+ viewThemeUtils,
+ syncedFolderProvider,
+ // used to avoid build issue
+ true
+ )
+ } else {
+ gridViewHolder.thumbnail.setImageResource(R.drawable.album_no_photo_placeholder)
+ gridViewHolder.thumbnail.visibility = View.VISIBLE
+ gridViewHolder.shimmerThumbnail.visibility = View.GONE
+ }
+
+ holder.itemView.setOnClickListener { albumFragmentInterface.onItemClick(file) }
+ }
+
+ fun cancelAllPendingTasks() {
+ for (task in asyncTasks) {
+ task.cancel(true)
+ if (task.getMethod != null) {
+ Log_OC.d("AlbumsAdapter", "cancel: abort get method directly")
+ task.getMethod.abort()
+ }
+ }
+ asyncTasks.clear()
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ fun setAlbumItems(albumItems: List?) {
+ albumList.clear()
+ albumItems?.let {
+ // NMC-4843 fix
+ // alphabetically sorting
+ albumList.addAll(it.sortedBy { album -> album.albumName.lowercase() })
+ }
+ notifyDataSetChanged()
+ }
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.java b/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.java
index 01d683a71b1e..ca13bd6223bd 100644
--- a/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.java
+++ b/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.java
@@ -18,6 +18,7 @@
import android.net.Uri;
import android.os.AsyncTask;
import android.provider.DocumentsContract;
+import android.text.TextUtils;
import android.widget.Toast;
import com.nextcloud.client.account.User;
@@ -55,6 +56,9 @@ public class CopyAndUploadContentUrisTask extends AsyncTask