diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee6e7fd2bc80..74ab1f7108dc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ @@ -636,6 +636,9 @@ android:launchMode="singleTop" android:theme="@style/Theme.ownCloud.Dialog.NoTitle" android:windowSoftInputMode="adjustResize" /> + + * SPDX-FileCopyrightText: 2024-2025 TSI-mc * SPDX-FileCopyrightText: 2020 Chris Narkiewicz * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ @@ -32,6 +32,7 @@ import com.nextcloud.ui.ImageDetailFragment; import com.nextcloud.ui.SetOnlineStatusBottomSheet; import com.nextcloud.ui.SetStatusMessageBottomSheet; +import com.nextcloud.ui.albumItemActions.AlbumItemActionsBottomSheet; import com.nextcloud.ui.composeActivity.ComposeActivity; import com.nextcloud.ui.fileactions.FileActionsBottomSheet; import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsBottomSheet; @@ -82,6 +83,7 @@ import com.owncloud.android.ui.dialog.ChooseTemplateDialogFragment; import com.owncloud.android.ui.dialog.ConfirmationDialogFragment; import com.owncloud.android.ui.dialog.ConflictsResolveDialog; +import com.owncloud.android.ui.dialog.CreateAlbumDialogFragment; import com.owncloud.android.ui.dialog.CreateFolderDialogFragment; import com.owncloud.android.ui.dialog.ExpirationDatePickerDialogFragment; import com.owncloud.android.ui.dialog.IndeterminateProgressDialog; @@ -114,6 +116,9 @@ import com.owncloud.android.ui.fragment.OCFileListFragment; import com.owncloud.android.ui.fragment.SharedListFragment; 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.activity.AlbumsPickerActivity; import com.owncloud.android.ui.fragment.contactsbackup.BackupFragment; import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment; import com.owncloud.android.ui.preview.FileDownloadFragment; @@ -505,4 +510,19 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract SetStatusMessageBottomSheet setStatusMessageBottomSheet(); + + @ContributesAndroidInjector + abstract AlbumsPickerActivity albumsPickerActivity(); + + @ContributesAndroidInjector + abstract CreateAlbumDialogFragment createAlbumDialogFragment(); + + @ContributesAndroidInjector + abstract AlbumsFragment albumsFragment(); + + @ContributesAndroidInjector + abstract AlbumItemsFragment albumItemsFragment(); + + @ContributesAndroidInjector + abstract AlbumItemActionsBottomSheet albumItemActionsBottomSheet(); } diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index 3e67df51fa92..a3a1493445d0 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -2,6 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2026 TSI-mc * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ package com.nextcloud.client.jobs @@ -27,6 +28,7 @@ import com.nextcloud.client.jobs.autoUpload.FileSystemRepository import com.nextcloud.client.jobs.download.FileDownloadWorker import com.nextcloud.client.jobs.metadata.MetadataWorker import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker +import com.nextcloud.client.jobs.upload.AlbumFileUploadWorker import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.logger.Logger @@ -96,6 +98,7 @@ class BackgroundJobFactory @Inject constructor( CalendarImportWork::class -> createCalendarImportWork(context, workerParameters) FilesExportWork::class -> createFilesExportWork(context, workerParameters) FileUploadWorker::class -> createFilesUploadWorker(context, workerParameters) + AlbumFileUploadWorker::class -> createAlbumsFilesUploadWorker(context, workerParameters) FileDownloadWorker::class -> createFilesDownloadWorker(context, workerParameters) GeneratePdfFromImagesWork::class -> createPDFGenerateWork(context, workerParameters) HealthStatusWork::class -> createHealthStatusWork(context, workerParameters) @@ -250,6 +253,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 abde68ad4040..5fd873f673b3 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -2,6 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2026 TSI-mc * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ package com.nextcloud.client.jobs @@ -138,6 +139,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(accountName: String): 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 0ad01e66c7ad..b5c8d56362ac 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -2,6 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2020 Chris Narkiewicz + * SPDX-FileCopyrightText: 2026 TSI-mc * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ package com.nextcloud.client.jobs @@ -32,6 +33,7 @@ import com.nextcloud.client.jobs.download.FileDownloadWorker import com.nextcloud.client.jobs.folderDownload.FolderDownloadWorker import com.nextcloud.client.jobs.metadata.MetadataWorker import com.nextcloud.client.jobs.offlineOperations.OfflineOperationsWorker +import com.nextcloud.client.jobs.upload.AlbumFileUploadWorker import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.preferences.AppPreferences @@ -86,6 +88,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" @@ -640,6 +643,8 @@ internal class BackgroundJobManagerImpl( private fun startFileUploadJobTag(accountName: String): String = JOB_FILES_UPLOAD + accountName + private fun startAlbumsFileUploadJobTag(accountName: String): String = ALBUM_JOB_FILES_UPLOAD + accountName + override fun isStartFileUploadJobScheduled(accountName: String): Boolean = workManager.isWorkScheduled(startFileUploadJobTag(accountName)) @@ -703,6 +708,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.accountName) + + 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..832ef80d5ed2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/AlbumFileUploadWorker.kt @@ -0,0 +1,429 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs.upload + +import android.app.Notification +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +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.jobs.utils.UploadErrorNotificationManager +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.model.WorkerState +import com.nextcloud.model.WorkerStateObserver +import com.nextcloud.utils.ForegroundServiceHelper +import com.nextcloud.utils.extensions.getPercent +import com.nextcloud.utils.extensions.updateStatus +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.ForegroundServiceType +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +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.ui.notifications.NotificationUtils +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", "TooGenericExceptionCaught") +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 notificationId = Random.nextInt() + private val notificationManager = UploadNotificationManager(context, viewThemeUtils, notificationId) + private val intents = FileUploaderIntents(context) + private val fileUploaderDelegate = FileUploaderDelegate() + + override suspend fun doWork(): Result = try { + Log_OC.d(TAG, "AlbumFileUploadWorker started") + val workerName = BackgroundJobManagerImpl.formatClassTag(this::class) + backgroundJobManager.logStartOfWorker(workerName) + + trySetForeground() + + val result = uploadFiles() + backgroundJobManager.logEndOfWorker(workerName, 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 suspend fun trySetForeground() { + try { + val notificationTitle = notificationManager.currentOperationTitle + ?: context.getString(R.string.foreground_service_upload) + val notification = createNotification(notificationTitle) + updateForegroundInfo(notification) + } catch (e: Exception) { + // Continue without foreground service - uploads will still work + Log_OC.w(TAG, "Could not set foreground service: ${e.message}") + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notificationTitle = notificationManager.currentOperationTitle + ?: context.getString(R.string.foreground_service_upload) + val notification = createNotification(notificationTitle) + + return ForegroundServiceHelper.createWorkerForegroundInfo( + notificationId, + notification, + ForegroundServiceType.DataSync + ) + } + + private suspend fun updateForegroundInfo(notification: Notification) { + val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo( + notificationId, + notification, + ForegroundServiceType.DataSync + ) + setForeground(foregroundInfo) + } + + private fun createNotification(title: String): Notification = + NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_UPLOAD) + .setContentTitle(title) + .setSmallIcon(R.drawable.uploads) + .setOngoing(true) + .setSound(null) + .setVibrate(null) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setSilent(true) + .build() + + private fun cleanup() { + Log_OC.e(TAG, "AlbumFileUploadWorker stopped") + + setIdleWorkerState() + currentUploadFileOperation?.cancel(null) + notificationManager.dismissNotification() + } + + private fun setWorkerState(user: User?) { + WorkerStateObserver.send(WorkerState.FileUploadStarted(user)) + } + + private fun setIdleWorkerState() { + WorkerStateObserver.send(WorkerState.FileUploadCompleted(currentUploadFileOperation?.file)) + } + + @Suppress("ReturnCount", "LongMethod", "DEPRECATION") + 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.openUploadListIntent(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, + startIntent = intents.openUploadListIntent(operation), + currentUploadIndex = currentUploadIndex, + totalUploadSize = totalUploadSize + ) + + val result = withContext(Dispatchers.IO) { + upload(operation, albumName, user, client) + } + val entity = uploadsStorageManager.uploadDao.getUploadById(upload.uploadId, accountName) + uploadsStorageManager.updateStatus(entity, result.isSuccess) + currentUploadFileOperation = null + + if (result.code == ResultCode.QUOTA_EXCEEDED) { + Log_OC.w(TAG, "Quota exceeded, stopping uploads") + notificationManager.showQuotaExceedNotification(operation) + break + } + + sendUploadFinishEvent(totalUploadSize, currentUploadIndex, operation, result) + } + + return@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 suspend fun upload( + operation: UploadFileOperation, + albumName: String, + user: User, + client: OwnCloudClient + ): RemoteOperationResult = withContext(Dispatchers.IO) { + lateinit var result: RemoteOperationResult + + try { + val storageManager = operation.storageManager + result = operation.execute(client) + val task = ThumbnailsCacheManager.ThumbnailGenerationTask(storageManager, user) + val file = File(operation.originalStoragePath) + val remoteId: String? = operation.file.remoteId + task.execute(ThumbnailsCacheManager.ThumbnailGenerationTaskObject(file, remoteId)) + val copyAlbumFileOperation = + CopyFileToAlbumOperation(operation.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 { + if (!isStopped) { + uploadsStorageManager.updateDatabaseUploadResult(result, operation) + // 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 + // so we are checking it first and removing the upload + if (result.code == ResultCode.SYNC_CONFLICT) { + uploadsStorageManager.removeUpload( + operation.user.accountName, + operation.remotePath + ) + } else { + UploadErrorNotificationManager.handleResult( + context, + notificationManager, + operation, + result, + showSameFileAlreadyExistsNotification = { + withContext(Dispatchers.Main) { + val showSameFileAlreadyExistsNotification = + inputData.getBoolean(SHOW_SAME_FILE_ALREADY_EXISTS_NOTIFICATION, false) + if (showSameFileAlreadyExistsNotification) { + notificationManager.showSameFileAlreadyExistsNotification(operation.fileName) + } + } + } + ) + } + } + } + + return@withContext result + } + + @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 62b3294b73af..5cd21cb5ca45 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 @@ -3,6 +3,7 @@ * * SPDX-FileCopyrightText: 2023 Alper Ozturk * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-FileCopyrightText: 2026 TSI-mc * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ package com.nextcloud.client.jobs.upload @@ -261,6 +262,44 @@ 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 -> + val result = 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 + } + + val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity()) + result.uploadId = id + result + } + backgroundJobManager.startAlbumFilesUploadJob( + user, + uploads.getUploadIds(), + albumName, + showSameFileAlreadyExistsNotification + ) + } + fun removeFileUpload(remotePath: String, accountName: String) { uploadsStorageManager.uploadDao.deleteByAccountAndRemotePath(remotePath, accountName) } @@ -372,7 +411,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..7c3c7e6d7572 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt @@ -0,0 +1,127 @@ +/* + * 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.appcompat.content.res.AppCompatResources +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.android.common.ui.theme.utils.ColorRole +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 + + viewThemeUtils.platform.colorViewBackground(binding.bottomSheet, ColorRole.SURFACE) + + 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) { + val drawable = + viewThemeUtils.platform.tintDrawable( + requireContext(), + AppCompatResources.getDrawable(requireContext(), action.icon)!! + ) + icon.setImageDrawable(drawable) + } + } + 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 = AlbumItemActionsBottomSheet() + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java index 2213a77b7439..ef5e53c8d12d 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -7,6 +7,7 @@ * SPDX-FileCopyrightText: 2019 Chris Narkiewicz * SPDX-FileCopyrightText: 2015 ownCloud Inc. * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-FileCopyrightText: 2026 TSI-mc * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) */ package com.owncloud.android.datamodel; @@ -434,6 +435,7 @@ public static class ThumbnailGenerationTask extends AsyncTask asyncTasks, boolean gridViewEnabled, - String imageKey) + String imageKey, + boolean hideVideoOverlay) throws IllegalArgumentException { this(imageView, storageManager, user, asyncTasks); this.gridViewEnabled = gridViewEnabled; mImageKey = imageKey; + this.hideVideoOverlay = hideVideoOverlay; } public GetMethod getGetMethod() { @@ -505,7 +509,7 @@ protected Bitmap doInBackground(ThumbnailGenerationTaskObject... params) { if (mFile instanceof ServerFileInterface) { thumbnail = doThumbnailFromOCFileInBackground(); - if (MimeTypeUtil.isVideo((ServerFileInterface) mFile) && thumbnail != null) { + if (MimeTypeUtil.isVideo((ServerFileInterface) mFile) && thumbnail != null && !hideVideoOverlay) { thumbnail = addVideoOverlay(thumbnail, MainApp.getAppContext()); } } else if (mFile instanceof File) { @@ -514,7 +518,7 @@ protected Bitmap doInBackground(ThumbnailGenerationTaskObject... params) { String url = ((File) mFile).getAbsolutePath(); String mMimeType = FileStorageUtils.getMimeTypeFromName(url); - if (MimeTypeUtil.isVideo(mMimeType) && thumbnail != null) { + if (MimeTypeUtil.isVideo(mMimeType) && thumbnail != null && !hideVideoOverlay) { thumbnail = addVideoOverlay(thumbnail, MainApp.getAppContext()); } //} else { do nothing 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..e1fa9db746ff 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java +++ b/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2025 TSI-mc * SPDX-FileCopyrightText: 2017 Tobias Kaminsky * SPDX-FileCopyrightText: 2017 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only @@ -12,5 +13,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..e4bd58a55764 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/FetchRemoteFileOperation.kt @@ -0,0 +1,112 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 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") + @Suppress("ReturnCount") + 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..c4d35628f6a5 --- /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.lib.resources.albums.CopyFileToAlbumRemoteOperation +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/ReadAlbumItemsOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/ReadAlbumItemsOperation.kt new file mode 100644 index 000000000000..f4f031d3f3da --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/albums/ReadAlbumItemsOperation.kt @@ -0,0 +1,80 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 TSI-mc + * 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.datamodel.FileDataStorageManager +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.resources.albums.ReadAlbumItemsRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.utils.FileStorageUtils + +class ReadAlbumItemsOperation +@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 + try { + result = ReadAlbumItemsRemoteOperation(mRemotePath, sessionTimeOut).execute(client) + if (result.isSuccess) { + // get data from remote folder + val mFolderAndFiles = saveAlbumData(result.resultData, storageManager) + + // Result of the operation + result.apply { + // Add data to the result + resultData = mFolderAndFiles + } + } + } catch (e: Exception) { + result = RemoteOperationResult(e) + } + + return result + } + + companion object { + private val TAG: String = ReadAlbumItemsRemoteOperation::class.java.simpleName + + private fun saveAlbumData( + remoteFiles: List, + storageManager: FileDataStorageManager? + ): List { + val files = mutableListOf() + + for (remoteFile in remoteFiles) { + // if no fileId received then skip adding + if (remoteFile.localId <= 0) { + continue + } + // check if file already exit in db or not + // if not then store it in db to show thumbnail and image preview + var ocFile = storageManager?.getFileByLocalId(remoteFile.localId) + if (ocFile == null) { + ocFile = FileStorageUtils.fillOCFile(remoteFile) + // no remote id is received from response + // so localId will be remoteId else thumb will not generated + ocFile.remoteId = remoteFile.localId.toString() + ocFile.lastSyncDateForProperties = System.currentTimeMillis() + storageManager?.saveFile(ocFile) + } + files.add(remoteFile) + } + + return files + } + } +} 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..6d99e5dd83cf 100644 --- a/app/src/main/java/com/owncloud/android/services/OperationsService.java +++ b/app/src/main/java/com/owncloud/android/services/OperationsService.java @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2018-2023 Tobias Kaminsky - * SPDX-FileCopyrightText: 2021 TSI-mc + * SPDX-FileCopyrightText: 2021-2025 TSI-mc * SPDX-FileCopyrightText: 2019 Chris Narkiewicz * SPDX-FileCopyrightText: 2017-2018 Andy Scherzinger * SPDX-FileCopyrightText: 2015 ownCloud Inc. @@ -42,6 +42,9 @@ 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.albums.CreateNewAlbumRemoteOperation; +import com.owncloud.android.lib.resources.albums.RemoveAlbumRemoteOperation; +import com.owncloud.android.lib.resources.albums.RenameAlbumRemoteOperation; import com.owncloud.android.lib.resources.files.RestoreFileVersionRemoteOperation; import com.owncloud.android.lib.resources.files.model.FileVersion; import com.owncloud.android.lib.resources.shares.OCShare; @@ -64,6 +67,7 @@ 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 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..cc810f6790c7 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/AlbumsPickerActivity.kt @@ -0,0 +1,226 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 TSI-mc + * 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.albums.CreateNewAlbumRemoteOperation +import com.owncloud.android.lib.resources.files.SearchRemoteOperation +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() + 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.folderPickerBtnCopy.visibility = View.GONE + folderPickerBinding.folderPickerBtnMove.visibility = View.GONE + folderPickerBinding.folderPickerBtnChoose.visibility = View.GONE + folderPickerBinding.folderPickerBtnCancel.visibility = View.GONE + folderPickerBinding.chooseButtonSpacer.visibility = View.GONE + folderPickerBinding.moveOrCopyButtonSpacer.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 = + Intent(context, AlbumsPickerActivity::class.java).apply { + putExtra(EXTRA_ACTION, CHOOSE_ALBUM) + } + + fun intentForPickingMediaFiles(context: FragmentActivity): Intent = + 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 4f089971528f..30e50af4743c 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 @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2021-2024 TSI-mc + * SPDX-FileCopyrightText: 2021-2026 TSI-mc * SPDX-FileCopyrightText: 2020 Infomaniak Network SA * SPDX-FileCopyrightText: 2020 Chris Narkiewicz * SPDX-FileCopyrightText: 2017 Tobias Kaminsky @@ -96,6 +96,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; @@ -128,6 +130,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; @@ -543,7 +546,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()); @@ -563,6 +567,17 @@ private void onNavigationItemClicked(final MenuItem menuItem) { openFavoritesTab(); } else if (itemId == R.id.nav_gallery) { openMediaTab(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) { showOnDeviceFiles(); } else if (itemId == R.id.nav_uploads) { @@ -640,6 +655,8 @@ private void handleBottomNavigationViewClicks() { startAssistantScreen(); } else if (menuItemId == R.id.nav_gallery) { openMediaTab(menuItem.getItemId()); + } else if (menuItemId == R.id.nav_album) { + replaceAlbumFragment(); } // Remove extra icon from the action bar @@ -664,6 +681,26 @@ private void resetFileDepthAndConfigureMenuItem() { } } + 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 startAssistantScreen() { final var destination = ComposeDestination.Companion.getAssistantScreen(this); Intent composeActivity = new Intent(getApplicationContext(), ComposeActivity.class); 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 2e87286462a2..28addd24dcc1 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 @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2021 TSI-mc + * SPDX-FileCopyrightText: 2021-2026 TSI-mc * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2017-2023 Tobias Kaminsky * SPDX-FileCopyrightText: 2019 Chris Narkiewicz @@ -87,10 +87,11 @@ import com.owncloud.android.ui.dialog.SslUntrustedCertDialog; import com.owncloud.android.ui.events.DialogEvent; import com.owncloud.android.ui.events.DialogEventType; -import com.owncloud.android.ui.events.FavoriteEvent; 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 +108,7 @@ import java.lang.ref.WeakReference; import java.util.ArrayList; +import java.util.Objects; import javax.inject.Inject; @@ -823,11 +825,18 @@ 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(); + // 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 c599740c49fb..087f641cd842 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 @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2025 Alper Ozturk - * SPDX-FileCopyrightText: 2023-2024 TSI-mc + * SPDX-FileCopyrightText: 2023-2026 TSI-mc * SPDX-FileCopyrightText: 2023 Archontis E. Kostis * SPDX-FileCopyrightText: 2019 Chris Narkiewicz * SPDX-FileCopyrightText: 2018-2022 Tobias Kaminsky @@ -97,6 +97,9 @@ 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.albums.CreateNewAlbumRemoteOperation +import com.owncloud.android.lib.resources.albums.RemoveAlbumRemoteOperation +import com.owncloud.android.lib.resources.albums.RenameAlbumRemoteOperation import com.owncloud.android.lib.resources.files.RestoreFileVersionRemoteOperation import com.owncloud.android.lib.resources.files.SearchRemoteOperation import com.owncloud.android.lib.resources.notifications.GetNotificationsRemoteOperation @@ -109,6 +112,7 @@ 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.syncadapter.FileSyncAdapter import com.owncloud.android.ui.CompletionCallback import com.owncloud.android.ui.asynctasks.CheckAvailableSpaceTask @@ -134,6 +138,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 @@ -572,7 +578,9 @@ class FileDisplayActivity : // Using `is OCFileListFragment` would also match subclasses, // its needed because reinitializing OCFileListFragment itself causes an empty screen leftFragment?.let { - if (it::class != OCFileListFragment::class) { + // check for albums fragment to load All Files + // when user is on AlbumsFragment and click on All Files + if (it::class != OCFileListFragment::class || isAlbumsFragment()) { leftFragment = OCFileListFragment() supportFragmentManager.executePendingTransactions() } @@ -587,6 +595,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() + } ON_DEVICE == action -> { refreshOrInitOCFileListFragment() @@ -964,7 +978,8 @@ class FileDisplayActivity : private fun shouldOpenDrawer(): Boolean = !isDrawerOpen && !isSearchOpen() && isRoot(getCurrentDir()) && - this.leftFragment is OCFileListFragment + this.leftFragment is OCFileListFragment && + !isAlbumItemsFragment() /** * Called, when the user selected something for uploading @@ -1200,6 +1215,13 @@ class FileDisplayActivity : after() } + // pop back if current fragment is AlbumItemsFragment + isAlbumItemsFragment() -> { + before() + popBack() + after() + } + leftFragment is OCFileListFragment -> { before() handleOCFileListFragmentBackPress() @@ -1737,6 +1759,13 @@ class FileDisplayActivity : } } } + + // notify when upload is finished and user is on albums screen + if (isAlbumsFragment()) { + (supportFragmentManager.findFragmentByTag(AlbumsFragment.TAG) as AlbumsFragment).refreshAlbums() + } else if (isAlbumItemsFragment()) { + (supportFragmentManager.findFragmentByTag(AlbumItemsFragment.TAG) as AlbumItemsFragment).refreshData() + } } // TODO refactor this receiver, and maybe DownloadFinishReceiver; this method is duplicated :S @@ -2075,6 +2104,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) + } } } @@ -2323,6 +2368,92 @@ 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} */ @@ -2747,7 +2878,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 { @@ -3112,6 +3246,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" const val ON_DEVICE = "ON_DEVICE" 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 edbbe0d4a19d..77803aef003a 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 @@ -6,7 +6,7 @@ * @author TSI-mc * Copyright (C) 2022 Tobias Kaminsky * Copyright (C) 2022 Nextcloud GmbH - * Copyright (C) 2023 TSI-mc + * Copyright (C) 2023-2025 TSI-mc * * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ @@ -253,6 +253,12 @@ class GalleryAdapter( notifyDataSetChanged() } + @SuppressLint("NotifyDataSetChanged") + fun showAlbumItems(albumItems: List) { + files = albumItems.toGalleryItems() + notifyDataSetChanged() + } + private fun transformToRows(list: List): List { if (list.isEmpty()) return emptyList() @@ -314,6 +320,10 @@ class GalleryAdapter( } } + fun setCheckedItem(files: Set?) { + ocFileListDelegate.setCheckedItem(files) + } + override fun setMultiSelect(boolean: Boolean) { ocFileListDelegate.isMultiSelect = 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 3d3437785e5d..61adf3da249d 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 @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2025 TSI-mc * SPDX-FileCopyrightText: 2022 Tobias Kaminsky * SPDX-FileCopyrightText: 2022 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only @@ -34,6 +35,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.DisplayUtils import com.owncloud.android.utils.EncryptionUtils @@ -159,8 +162,16 @@ class OCFileListDelegate( GalleryImageGenerationJob.storeJob(job, imageView) imageView.setOnClickListener { - ocFileListFragmentInterface.onItemClicked(file) - GalleryFragment.setLastMediaItemPosition(galleryRowHolder.absoluteAdapterPosition) + // 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..652295388c68 --- /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.lib.resources.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..c2dea316e85e --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt @@ -0,0 +1,27 @@ +/* + * 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 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..a1bfa865942b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt @@ -0,0 +1,27 @@ +/* + * 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 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..fa4be74ae6be --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt @@ -0,0 +1,118 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 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.lib.resources.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 = 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 = 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, + DisplayUtils.getDateByPattern(file.createdDate, "MMM yyyy") + ) + + 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, + true + ) + } else { + gridViewHolder.thumbnail.setImageResource(R.drawable.file_image) + 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 { + // 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..9dbeb1f2ff81 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 @@ -8,6 +8,7 @@ * SPDX-FileCopyrightText: 2016 Juan Carlos González Cabrero * SPDX-FileCopyrightText: 2015 María Asensio Valverde * SPDX-FileCopyrightText: 2014 David A. Velasco + * SPDX-FileCopyrightText: 2026 TSI-mc * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) */ package com.owncloud.android.ui.asynctasks; @@ -18,6 +19,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 +57,9 @@ public class CopyAndUploadContentUrisTask extends AsyncTask @@ -98,9 +103,10 @@ public static Object[] makeParamsToExecute( }; } - public CopyAndUploadContentUrisTask(OnCopyTmpFilesTaskListener listener, Context context) { + public CopyAndUploadContentUrisTask(OnCopyTmpFilesTaskListener listener, Context context, String albumName) { mListener = new WeakReference<>(listener); mAppContext = context.getApplicationContext(); + mAlbumName = albumName; } /** @@ -180,16 +186,31 @@ protected ResultCode doInBackground(Object[] params) { } } - FileUploadHelper.Companion.instance().uploadNewFiles( - user, - localPaths, - currentRemotePaths, - behaviour, - false, // do not create parent folder if not existent - UploadFileOperation.CREATED_BY_USER, - false, - false, - NameCollisionPolicy.ASK_USER); + if (TextUtils.isEmpty(mAlbumName)) { + FileUploadHelper.Companion.instance().uploadNewFiles( + user, + localPaths, + currentRemotePaths, + behaviour, + false, // do not create parent folder if not existent + UploadFileOperation.CREATED_BY_USER, + false, + false, + NameCollisionPolicy.ASK_USER); + } else { + FileUploadHelper.Companion.instance().uploadAndCopyNewFilesForAlbum( + user, + localPaths, + currentRemotePaths, + mAlbumName, + behaviour, + true, // create parent folder if not existent + UploadFileOperation.CREATED_BY_USER, + false, + false, + // use RENAME policy to make sure all files are uploaded + NameCollisionPolicy.RENAME); + } result = ResultCode.OK; diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/CreateAlbumDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/CreateAlbumDialogFragment.kt new file mode 100644 index 000000000000..77c2876d605a --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/dialog/CreateAlbumDialogFragment.kt @@ -0,0 +1,199 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.dialog + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nextcloud.client.account.CurrentAccountProvider +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.utils.extensions.typedActivity +import com.owncloud.android.R +import com.owncloud.android.databinding.EditBoxDialogBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.ui.activity.ComponentsGetter +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.KeyboardUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +/** + * Dialog to input the name for a new folder to create. + * + * + * Triggers the folder creation when name is confirmed. + */ +class CreateAlbumDialogFragment : + DialogFragment(), + DialogInterface.OnClickListener, + Injectable { + + @Inject + lateinit var fileDataStorageManager: FileDataStorageManager + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var keyboardUtils: KeyboardUtils + + @Inject + lateinit var connectivityService: ConnectivityService + + @Inject + lateinit var accountProvider: CurrentAccountProvider + + private var positiveButton: MaterialButton? = null + + private lateinit var binding: EditBoxDialogBinding + + private var albumName: String? = null + + override fun onStart() { + super.onStart() + bindButton() + } + + private fun bindButton() { + val dialog = dialog + + if (dialog is AlertDialog) { + positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) as? MaterialButton + positiveButton?.let { + it.isEnabled = false + viewThemeUtils.material.colorMaterialButtonPrimaryTonal(it) + } + + val negativeButton = dialog.getButton(AlertDialog.BUTTON_NEGATIVE) as? MaterialButton + negativeButton?.let { + viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(it) + } + } + } + + override fun onResume() { + super.onResume() + bindButton() + keyboardUtils.showKeyboardForEditText(requireDialog().window, binding.userInput) + } + + @Suppress("EmptyFunctionBlock") + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + albumName = arguments?.getString(ARG_ALBUM_NAME) + + val inflater = requireActivity().layoutInflater + binding = EditBoxDialogBinding.inflate(inflater, null, false) + + binding.userInput.setText(albumName ?: "") + viewThemeUtils.material.colorTextInputLayout(binding.userInputContainer) + albumName?.let { + binding.userInput.setSelection(0, it.length) + } + + binding.userInput.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable) {} + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + checkFileNameAfterEachType() + } + }) + + val builder = buildMaterialAlertDialog(binding.root) + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(binding.userInputContainer.context, builder) + return builder.create() + } + + private fun checkFileNameAfterEachType() { + val newAlbumName = binding.userInput.text?.toString() ?: "" + + val errorMessage = when { + newAlbumName.isBlank() -> getString(R.string.album_name_empty) + else -> null + } + + if (errorMessage != null) { + binding.userInputContainer.error = errorMessage + positiveButton?.isEnabled = false + if (positiveButton == null) { + bindButton() + } + } else { + binding.userInputContainer.error = null + binding.userInputContainer.isErrorEnabled = false + positiveButton?.isEnabled = true + } + } + + private fun buildMaterialAlertDialog(view: View): MaterialAlertDialogBuilder = + MaterialAlertDialogBuilder(requireActivity()) + .setView(view) + .setPositiveButton( + if (albumName == null) R.string.folder_confirm_create else R.string.rename_dialog_button, + this + ) + .setNegativeButton(R.string.common_cancel, this) + .setTitle(if (albumName == null) R.string.create_album_dialog_title else R.string.rename_album_dialog_title) + .setMessage(R.string.create_album_dialog_message) + + override fun onClick(dialog: DialogInterface, which: Int) { + if (which == AlertDialog.BUTTON_POSITIVE) { + val newAlbumName = (getDialog()?.findViewById(R.id.user_input) as TextView) + .text.toString().trim() + + val errorMessage = when { + newAlbumName.isBlank() -> getString(R.string.album_name_empty) + else -> null + } + + if (errorMessage != null) { + DisplayUtils.showSnackMessage(requireActivity(), errorMessage) + return + } + + connectivityService.isNetworkAndServerAvailable { result -> + if (result) { + if (albumName != null) { + typedActivity()?.fileOperationsHelper?.renameAlbum(albumName, newAlbumName) + } else { + typedActivity()?.fileOperationsHelper?.createAlbum(newAlbumName) + } + } else { + DisplayUtils.showSnackMessage(requireActivity(), getString(R.string.offline_mode)) + } + } + } + } + + companion object { + val TAG: String = CreateAlbumDialogFragment::class.java.simpleName + private const val ARG_ALBUM_NAME = "album_name" + + /** + * Public factory method to create new CreateFolderDialogFragment instances. + * + * @return Dialog ready to show. + */ + @JvmStatic + fun newInstance(albumName: String? = null): CreateAlbumDialogFragment = CreateAlbumDialogFragment().apply { + val argsBundle = bundleOf( + ARG_ALBUM_NAME to albumName + ) + arguments = argsBundle + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt index 96e61a9dd5ea..e778438dee1c 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2025 TSI-mc * SPDX-FileCopyrightText: 2025 Alper Ozturk * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2018-2021 Tobias Kaminsky @@ -163,7 +164,9 @@ open class ExtendedListFragment : @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - val item = menu.findItem(R.id.action_search) + // while picking Media files from Gallery Fragment through AlbumPickerActivity + // there will be no search option so it we have to return it + val item = menu.findItem(R.id.action_search) ?: return searchView = item.actionView as SearchView? viewThemeUtils.androidx.themeToolbarSearchView(searchView!!) closeButton = searchView?.findViewById(androidx.appcompat.R.id.search_close_btn) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java index cf1a649c2dc0..ef53bd453c86 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java @@ -1,13 +1,14 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2023-2025 TSI-mc * SPDX-FileCopyrightText: 2019 Tobias Kaminsky * SPDX-FileCopyrightText: 2019 Nextcloud GmbH * SPDX-License-Identifier: GPL-3.0-or-later AND AGPL-3.0-or-later */ package com.owncloud.android.ui.fragment; +import android.app.Activity; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -15,6 +16,7 @@ import android.content.res.Configuration; import android.os.AsyncTask; import android.os.Bundle; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -22,6 +24,7 @@ import android.view.View; import android.view.ViewGroup; +import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.utils.extensions.IntentExtensionsKt; import com.owncloud.android.BuildConfig; import com.owncloud.android.R; @@ -37,9 +40,17 @@ import com.owncloud.android.ui.adapter.GalleryAdapter; import com.owncloud.android.ui.asynctasks.GallerySearchTask; import com.owncloud.android.ui.events.ChangeMenuEvent; +import com.owncloud.android.ui.fragment.albums.AlbumsFragment; +import com.owncloud.android.ui.activity.AlbumsPickerActivity; +import com.owncloud.android.utils.DisplayUtils; + +import java.util.ArrayList; +import java.util.Set; import javax.inject.Inject; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.localbroadcastmanager.content.LocalBroadcastManager; @@ -66,10 +77,15 @@ public class GalleryFragment extends OCFileListFragment implements GalleryFragme private GalleryFragmentBottomSheetDialog galleryFragmentBottomSheetDialog; @Inject FileDataStorageManager fileDataStorageManager; + @Inject ConnectivityService connectivityService; private final static int maxColumnSizeLandscape = 5; private final static int maxColumnSizePortrait = 2; private int columnSize; + // required for Albums + private Set checkedFiles; + private boolean isFromAlbum; // when opened from Albums to add items + protected void setPhotoSearchQueryRunning(boolean value) { this.photoSearchQueryRunning = value; } @@ -83,7 +99,12 @@ public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); searchFragment = true; - setHasOptionsMenu(true); + if (getArguments() != null) { + isFromAlbum = getArguments().getBoolean(AlbumsPickerActivity.Companion.getEXTRA_FROM_ALBUM(), false); + } + + // only show menu when not opened from media picker + setHasOptionsMenu(!isFromAlbum); if (galleryFragmentBottomSheetDialog == null) { galleryFragmentBottomSheetDialog = new GalleryFragmentBottomSheetDialog(this); @@ -406,6 +427,11 @@ public void showAllGalleryItems() { } private void updateSubtitle(GalleryFragmentBottomSheetDialog.MediaState mediaState) { + // while picking media don't show subtitle + if (isFromAlbum) { + return; + } + requireActivity().runOnUiThread(() -> { if (!isAdded()) { return; @@ -432,4 +458,48 @@ protected void setGridViewColumns(float scaleFactor) { public void markAsFavorite(String remotePath, boolean favorite) { mAdapter.markAsFavorite(remotePath, favorite); } + + final ActivityResultLauncher activityResult = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), intentResult -> { + if (Activity.RESULT_OK == intentResult.getResultCode() && intentResult.getData() != null) { + String albumName = intentResult.getData().getStringExtra(AlbumsFragment.ARG_SELECTED_ALBUM_NAME); + Log_OC.e(TAG, "Selected album name: " + albumName); + addFilesToAlbum(albumName); + } + }); + + public void addImagesToAlbum(Set checkedFiles) { + this.checkedFiles = checkedFiles; + if (isFromAlbum) { + addFilesToAlbum(null); + } else { + activityResult.launch(AlbumsPickerActivity.Companion.intentForPickingAlbum(requireActivity())); + } + } + + private void addFilesToAlbum(@Nullable String albumName) { + connectivityService.isNetworkAndServerAvailable(result -> { + if (result) { + if (checkedFiles == null || checkedFiles.isEmpty()) { + return; + } + final ArrayList paths = new ArrayList<>(checkedFiles.size()); + for (OCFile file : checkedFiles) { + paths.add(file.getRemotePath()); + } + checkedFiles = null; + exitSelectionMode(); + if (!TextUtils.isEmpty(albumName)) { + mContainerActivity.getFileOperationsHelper().albumCopyFiles(paths, albumName); + } else { + Intent resultIntent = new Intent(); + resultIntent.putStringArrayListExtra(AlbumsPickerActivity.Companion.getEXTRA_MEDIA_FILES_PATH(), paths); + requireActivity().setResult(Activity.RESULT_OK, resultIntent); + requireActivity().finish(); + } + } else { + DisplayUtils.showSnackMessage(requireActivity(), getString(R.string.offline_mode)); + } + }); + } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index cfe5f17f3f57..ad97bad9f58b 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2023-2025 TSI-mc * SPDX-FileCopyrightText: 2018-2023 Tobias Kaminsky * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2020 Joris Bodin @@ -81,6 +81,7 @@ import com.owncloud.android.lib.resources.status.E2EVersion; import com.owncloud.android.lib.resources.status.OCCapability; import com.owncloud.android.lib.resources.status.Type; +import com.owncloud.android.ui.activity.AlbumsPickerActivity; import com.owncloud.android.ui.activity.DrawerActivity; import com.owncloud.android.ui.activity.FileActivity; import com.owncloud.android.ui.activity.FileDisplayActivity; @@ -496,7 +497,7 @@ public void registerFabListener() { Log_OC.w(TAG, "currentDir is null cannot open bottom sheet dialog"); return; } - + final OCFileListBottomSheetDialog dialog = new OCFileListBottomSheetDialog(fileActivity, this, deviceInfo, @@ -816,6 +817,17 @@ public boolean onCreateActionMode(ActionMode mode, Menu menu) { // hide FAB in multi selection mode setFabVisible(false); + if (OCFileListFragment.this instanceof GalleryFragment) { + final MenuItem addAlbumItem = menu.findItem(R.id.add_to_album); + // show add to album button for gallery to add media to Album + addAlbumItem.setVisible(true); + + // hide the 3 dot menu icon while picking media for Albums + if (requireActivity() instanceof AlbumsPickerActivity) { + item.setVisible(false); + } + } + getCommonAdapter().setMultiSelect(true); return true; } @@ -852,6 +864,10 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { final Set checkedFiles = getCommonAdapter().getCheckedItems(); if (item.getItemId() == R.id.custom_menu_placeholder_item) { openActionsMenu(getCommonAdapter().getFilesCount(), checkedFiles, false); + } else if (item.getItemId() == R.id.add_to_album){ + if (OCFileListFragment.this instanceof GalleryFragment galleryFragment) { + galleryFragment.addImagesToAlbum(checkedFiles); + } } return true; } @@ -1583,7 +1599,7 @@ private void updateLayout() { invalidateActionMode(); } - + private void updateSortButton() { if (mSortButton != null) { FileSortOrder sortOrder; @@ -2169,6 +2185,14 @@ public void setFabVisible(final boolean visible) { return; } + // to hide the fab if user is on Albums Fragment + if (requireActivity() instanceof FileDisplayActivity fda + && (fda.isAlbumsFragment() + || fda.isAlbumItemsFragment())) { + mFabMain.hide(); + return; + } + final var activity = getActivity(); if (activity == null) { return; diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt new file mode 100644 index 000000000000..078d0270b45a --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt @@ -0,0 +1,1205 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment.albums + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Activity.RESULT_OK +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Parcelable +import android.view.ActionMode +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.AbsListView +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider +import androidx.core.view.get +import androidx.core.view.size +import androidx.drawerlayout.widget.DrawerLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.appbar.AppBarLayout +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.network.ClientFactory.CreationException +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.client.utils.Throttler +import com.nextcloud.ui.albumItemActions.AlbumItemActionsBottomSheet +import com.nextcloud.ui.fileactions.FileActionsBottomSheet +import com.nextcloud.utils.extensions.getTypedActivity +import com.nextcloud.utils.extensions.isDialogFragmentReady +import com.owncloud.android.R +import com.owncloud.android.databinding.ListFragmentBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.datamodel.VirtualFolderType +import com.owncloud.android.db.ProviderMeta +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.albums.RemoveAlbumFileRemoteOperation +import com.owncloud.android.lib.resources.albums.ToggleAlbumFavoriteRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.lib.resources.status.Type +import com.owncloud.android.operations.albums.ReadAlbumItemsOperation +import com.owncloud.android.ui.activity.AlbumsPickerActivity +import com.owncloud.android.ui.activity.AlbumsPickerActivity.Companion.intentForPickingMediaFiles +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.activity.FileActivity.REQUEST_CODE__LAST_SHARED +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.adapter.GalleryAdapter +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment.ConfirmationDialogFragmentListener +import com.owncloud.android.ui.dialog.CreateAlbumDialogFragment +import com.owncloud.android.ui.events.FavoriteEvent +import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.ui.helpers.UriUploader +import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface +import com.owncloud.android.ui.preview.PreviewImageFragment +import com.owncloud.android.ui.preview.PreviewMediaActivity.Companion.canBePreviewed +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.ErrorMessageAdapter +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import java.util.Optional +import java.util.function.Supplier +import javax.inject.Inject + +@Suppress("TooManyFunctions", "LargeClass") +class AlbumItemsFragment : + Fragment(), + OCFileListFragmentInterface, + Injectable { + + private var adapter: GalleryAdapter? = null + private var client: OwnCloudClient? = null + private var optionalUser: Optional? = null + + private lateinit var binding: ListFragmentBinding + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var accountManager: UserAccountManager + + @Inject + lateinit var clientFactory: ClientFactory + + @Inject + lateinit var preferences: AppPreferences + + @Inject + lateinit var syncedFolderProvider: SyncedFolderProvider + + @Inject + lateinit var throttler: Throttler + + private var mContainerActivity: FileFragment.ContainerActivity? = null + + private var columnSize = 0 + + private lateinit var albumName: String + private var isNewAlbum: Boolean = false + + private var mMultiChoiceModeListener: MultiChoiceModeListener? = null + + private var albumRemoteFileList = listOf() + + private val refreshFlow = MutableSharedFlow(extraBufferCapacity = 1) + + override fun onAttach(context: Context) { + super.onAttach(context) + try { + mContainerActivity = context as FileFragment.ContainerActivity + } catch (e: ClassCastException) { + throw IllegalArgumentException( + context.toString() + " must implement " + + FileFragment.ContainerActivity::class.java.simpleName, + e + ) + } + arguments?.let { + albumName = it.getString(ARG_ALBUM_NAME) ?: "" + isNewAlbum = it.getBoolean(ARG_IS_NEW_ALBUM) + } + } + + override fun onDetach() { + mContainerActivity = null + super.onDetach() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + columnSize = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + MAX_COLUMN_SIZE_LANDSCAPE + } else { + MAX_COLUMN_SIZE_PORTRAIT + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = ListFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + @OptIn(FlowPreview::class) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + optionalUser = Optional.of(accountManager.user) + showAppBar() + createMenu() + setupContainingList() + setupContent() + + // if fragment is opened when new albums is created + // then open gallery to choose media to add + if (isNewAlbum) { + openGalleryToAddMedia() + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + refreshFlow.onStart { emit(Unit) } // default fetch + .onEach { binding.swipeContainingList.isRefreshing = true } // show progress on each call + .debounce(DEBOUNCE_DELAY) // debounce background triggers + .collect { + fetchAndSetData() + } + } + } + } + + private fun showAppBar() { + if (requireActivity() is FileDisplayActivity) { + val appBarLayout = requireActivity().findViewById(R.id.appbar) + appBarLayout?.setExpanded(true, false) + } + } + + private fun setUpActionMode() { + if (mMultiChoiceModeListener != null) return + + mMultiChoiceModeListener = MultiChoiceModeListener( + requireActivity(), + adapter, + viewThemeUtils + ) { filesCount, checkedFiles -> openActionsMenu(filesCount, checkedFiles) } + (requireActivity() as FileDisplayActivity).addDrawerListener(mMultiChoiceModeListener) + } + + private fun createMenu() { + val menuHost: MenuHost = requireActivity() + menuHost.addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menu.clear() // important: clears any existing activity menu + menuInflater.inflate(R.menu.fragment_album_items, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.action_three_dot_icon -> { + openAlbumActionsMenu() + true + } + + R.id.action_add_from_camera_roll -> { + // we don't want quick media access bottom sheet for Android 13+ devices + // to avoid that we are not using image/* and video/* mime types + // we are validating mime types when selection is made + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + type = "*/*" + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + addCategory(Intent.CATEGORY_OPENABLE) + } + startActivityForResult( + Intent.createChooser(intent, getString(R.string.upload_chooser_title)), + REQUEST_CODE__SELECT_MEDIA_FROM_APPS + ) + true + } + + R.id.action_add_from_account -> { + // open Gallery fragment as selection then add items to current album + openGalleryToAddMedia() + true + } + + else -> false + } + + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + for (i in 0 until menu.size) { + val item = menu[i] + item.icon?.let { + item.setIcon( + viewThemeUtils.platform.colorDrawable( + it, + ContextCompat.getColor(requireContext(), R.color.fontAppbar) + ) + ) + } + } + } + }, + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + } + + private fun openAlbumActionsMenu() { + throttler.run("overflowClick") { + val supportFragmentManager = requireActivity().supportFragmentManager + + AlbumItemActionsBottomSheet.newInstance() + .setResultListener( + supportFragmentManager, + this + ) { id: Int -> + onAlbumActionChosen(id) + } + .show(supportFragmentManager, "album_actions") + } + } + + private fun onAlbumActionChosen(@IdRes itemId: Int): Boolean = when (itemId) { + // action to rename album + R.id.action_rename_file -> { + CreateAlbumDialogFragment.newInstance(albumName) + .show( + requireActivity().supportFragmentManager, + CreateAlbumDialogFragment.TAG + ) + true + } + + // action to delete album + R.id.action_delete -> { + showConfirmationDialog(true, null) + true + } + + else -> false + } + + private fun setupContent() { + binding.listRoot.setEmptyView(binding.emptyList.emptyListView) + val layoutManager = GridLayoutManager(requireContext(), 1) + binding.listRoot.layoutManager = layoutManager + } + + private fun setupContainingList() { + viewThemeUtils.androidx.themeSwipeRefreshLayout(binding.swipeContainingList) + binding.swipeContainingList.setOnRefreshListener { + binding.swipeContainingList.isRefreshing = true + refreshData() + } + } + + @VisibleForTesting + fun populateList(albums: List) { + // exit action mode on data refresh + mMultiChoiceModeListener?.exitSelectionMode() + + if (requireActivity() is FileDisplayActivity) { + (requireActivity() as FileDisplayActivity).setMainFabVisible(false) + } + initializeAdapter() + adapter?.showAlbumItems(albums) + } + + private fun fetchAndSetData() { + binding.swipeContainingList.isRefreshing = true + mMultiChoiceModeListener?.exitSelectionMode() + initializeAdapter() + setEmptyListLoadingMessage() + lifecycleScope.launch(Dispatchers.IO) { + val readAlbumItemsRemoteOperation = ReadAlbumItemsOperation(albumName, mContainerActivity?.storageManager) + val result = client?.let { readAlbumItemsRemoteOperation.execute(it) } + val ocFileList = mutableListOf() + + if (result?.isSuccess == true && result.resultData != null) { + mContainerActivity?.storageManager?.deleteVirtuals(VirtualFolderType.ALBUM) + val contentValues = mutableListOf() + albumRemoteFileList = result.resultData.toMutableList() + + for (remoteFile in albumRemoteFileList) { + val ocFile = mContainerActivity?.storageManager?.getFileByLocalId(remoteFile.localId) + ocFile?.let { + ocFileList.add(it) + + val cv = ContentValues() + cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_TYPE, VirtualFolderType.ALBUM.toString()) + cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_OCFILE_ID, it.fileId) + + contentValues.add(cv) + } + } + + mContainerActivity?.storageManager?.saveVirtuals(contentValues) + } + withContext(Dispatchers.Main) { + if (result?.isSuccess == true && result.resultData != null) { + if (result.resultData.isEmpty() || ocFileList.isEmpty()) { + setMessageForEmptyList( + R.string.file_list_empty_headline_server_search, + resources.getString(R.string.file_list_empty_gallery), + R.drawable.file_image, + false + ) + } + populateList(ocFileList) + } else { + Log_OC.d(TAG, result?.logMessage) + // show error + setMessageForEmptyList( + R.string.file_list_empty_headline_server_search, + resources.getString(R.string.file_list_empty_gallery), + R.drawable.file_image, + false + ) + } + hideRefreshLayoutLoader() + } + } + } + + private fun hideRefreshLayoutLoader() { + binding.swipeContainingList.isRefreshing = false + } + + private fun setEmptyListLoadingMessage() { + val fileActivity = this.getTypedActivity(FileActivity::class.java) + fileActivity?.connectivityService?.isNetworkAndServerAvailable { result: Boolean? -> + if (!result!!) return@isNetworkAndServerAvailable + binding.emptyList.emptyListViewHeadline.setText(R.string.file_list_loading) + binding.emptyList.emptyListViewText.text = "" + binding.emptyList.emptyListIcon.visibility = View.GONE + } + } + + private fun initializeClient() { + if (client == null && optionalUser?.isPresent == true) { + try { + val user = optionalUser?.get() + client = clientFactory.create(user) + } catch (e: CreationException) { + Log_OC.e(TAG, "Error initializing client", e) + } + } + } + + private fun initializeAdapter() { + initializeClient() + if (adapter == null) { + adapter = GalleryAdapter( + requireContext(), + accountManager.user, + this, + preferences, + mContainerActivity!!, + viewThemeUtils, + columnSize, + ThumbnailsCacheManager.getThumbnailDimension() + ) + adapter?.setHasStableIds(true) + setUpActionMode() + } + binding.listRoot.adapter = adapter + + lastMediaItemPosition?.let { + binding.listRoot.layoutManager?.scrollToPosition(it) + } + } + + private fun setMessageForEmptyList( + @StringRes headline: Int, + message: String, + @DrawableRes icon: Int, + tintIcon: Boolean + ) { + binding.emptyList.emptyListViewHeadline.setText(headline) + binding.emptyList.emptyListViewText.text = message + + if (tintIcon) { + if (context != null) { + binding.emptyList.emptyListIcon.setImageDrawable( + viewThemeUtils.platform.tintPrimaryDrawable(requireContext(), icon) + ) + } + } else { + binding.emptyList.emptyListIcon.setImageResource(icon) + } + + binding.emptyList.emptyListIcon.visibility = View.VISIBLE + binding.emptyList.emptyListViewText.visibility = View.VISIBLE + } + + override fun onResume() { + super.onResume() + if (requireActivity() is FileDisplayActivity) { + (requireActivity() as FileDisplayActivity).setupToolbar() + (requireActivity() as FileDisplayActivity).supportActionBar?.let { actionBar -> + viewThemeUtils.files.themeActionBar(requireContext(), actionBar, albumName) + } + (requireActivity() as FileDisplayActivity).showSortListGroup(false) + (requireActivity() as FileDisplayActivity).setMainFabVisible(false) + + // clear the subtitle while navigating to any other screen from Media screen + (requireActivity() as FileDisplayActivity).clearToolbarSubtitle() + } + } + + override fun onPause() { + super.onPause() + adapter?.cancelAllPendingTasks() + } + + @SuppressLint("NotifyDataSetChanged") + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) { + columnSize = MAX_COLUMN_SIZE_LANDSCAPE + } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { + columnSize = MAX_COLUMN_SIZE_PORTRAIT + } + adapter?.changeColumn(columnSize) + adapter?.notifyDataSetChanged() + } + + override fun onDestroyView() { + lastMediaItemPosition = 0 + super.onDestroyView() + } + + override fun getColumnsCount(): Int = columnSize + + override fun onShareIconClick(file: OCFile?) { + // nothing to do here + } + + override fun showShareDetailView(file: OCFile?) { + // nothing to do here + } + + override fun showActivityDetailView(file: OCFile?) { + // nothing to do here + } + + override fun onOverflowIconClicked(file: OCFile?, view: View?) { + // nothing to do here + } + + override fun onItemClicked(file: OCFile) { + if (adapter?.isMultiSelect() == true) { + toggleItemToCheckedList(file) + } else { + if (PreviewImageFragment.canBePreviewed(file)) { + (mContainerActivity as FileDisplayActivity).startImagePreview( + file, + VirtualFolderType.ALBUM, + !file.isDown + ) + } else if (file.isDown) { + if (canBePreviewed(file)) { + (mContainerActivity as FileDisplayActivity).startMediaPreview(file, 0, true, true, false, true) + } else { + mContainerActivity?.getFileOperationsHelper()?.openFile(file) + } + } else { + if (canBePreviewed(file) && !file.isEncrypted) { + (mContainerActivity as FileDisplayActivity).startMediaPreview(file, 0, true, true, true, true) + } else { + Log_OC.d(TAG, "Couldn't handle item click") + } + } + } + } + + override fun onLongItemClicked(file: OCFile): Boolean { + // Create only once instance of action mode + if (mMultiChoiceModeListener?.mActiveActionMode != null) { + toggleItemToCheckedList(file) + } else { + requireActivity().startActionMode(mMultiChoiceModeListener) + adapter?.addCheckedFile(file) + } + mMultiChoiceModeListener?.updateActionModeFile(file) + return true + } + + /** + * Will toggle a file selection status from the action mode + * + * @param file The concerned OCFile by the selection/deselection + */ + private fun toggleItemToCheckedList(file: OCFile) { + adapter?.run { + if (isCheckedFile(file)) { + removeCheckedFile(file) + } else { + addCheckedFile(file) + } + } + mMultiChoiceModeListener?.updateActionModeFile(file) + } + + override fun isLoading(): Boolean = false + + override fun onHeaderClicked() { + // nothing to do here + } + + fun onAlbumRenamed(newAlbumName: String) { + albumName = newAlbumName + if (requireActivity() is FileDisplayActivity) { + (requireActivity() as FileDisplayActivity).updateActionBarTitleAndHomeButtonByString(albumName) + } + } + + fun onAlbumDeleted() { + requireActivity().supportFragmentManager.popBackStack() + } + + @Suppress("LongMethod") + private fun openActionsMenu(filesCount: Int, checkedFiles: Set) { + throttler.run("overflowClick") { + var toHide: MutableList? = ArrayList() + for (file in checkedFiles) { + if (file.isOfflineOperation) { + toHide = ArrayList( + listOf( + R.id.action_favorite, + R.id.action_move_or_copy, + R.id.action_sync_file, + R.id.action_encrypted, + R.id.action_unset_encrypted, + R.id.action_edit, + R.id.action_download_file, + R.id.action_export_file, + R.id.action_set_as_wallpaper + ) + ) + break + } + } + + toHide?.apply { + addAll( + listOf( + R.id.action_move_or_copy, + R.id.action_sync_file, + R.id.action_encrypted, + R.id.action_unset_encrypted, + R.id.action_edit, + R.id.action_download_file, + R.id.action_export_file, + R.id.action_set_as_wallpaper, + R.id.action_send_file, + R.id.action_send_share_file, + R.id.action_see_details, + R.id.action_rename_file, + R.id.action_pin_to_homescreen + ) + ) + } + + val childFragmentManager = childFragmentManager + val endpoints = mContainerActivity?.storageManager?.getCapability( + optionalUser?.get() + )?.getClientIntegrationEndpoints( + Type.CONTEXT_MENU, + checkedFiles.iterator().next().mimeType + ) + + val actionBottomSheet = FileActionsBottomSheet.newInstance( + filesCount, + checkedFiles, + true, + toHide, + false, + endpoints!! + ) + .setResultListener( + childFragmentManager, + this + ) { id: Int -> onFileActionChosen(id, checkedFiles) } + if (this.isDialogFragmentReady()) { + actionBottomSheet.show(childFragmentManager, "actions") + } + } + } + + @Suppress("ReturnCount") + private fun onFileActionChosen(@IdRes itemId: Int, checkedFiles: Set): Boolean { + if (checkedFiles.isEmpty()) { + return false + } + + when (itemId) { + R.id.action_remove_file -> { + showConfirmationDialog(false, checkedFiles) + return true + } + + R.id.action_favorite -> { + mContainerActivity?.fileOperationsHelper?.toggleFavoriteFiles(checkedFiles, true) + return true + } + + R.id.action_unset_favorite -> { + mContainerActivity?.fileOperationsHelper?.toggleFavoriteFiles(checkedFiles, false) + return true + } + + R.id.action_open_file_with -> { + // use only first element as this option will only be shown for single file selection + mContainerActivity?.fileOperationsHelper?.openFile(checkedFiles.first()) + return true + } + + R.id.action_stream_media -> { + // use only first element as this option will only be shown for single file selection + mContainerActivity?.fileOperationsHelper?.streamMediaFile(checkedFiles.first()) + return true + } + + R.id.action_select_all_action_menu -> { + selectAllFiles(true) + return true + } + + R.id.action_deselect_all_action_menu -> { + selectAllFiles(false) + return true + } + + else -> return true + } + } + + /** + * De-/select all elements in the current list view. + * + * @param select `true` to select all, `false` to deselect all + */ + @SuppressLint("NotifyDataSetChanged") + private fun selectAllFiles(select: Boolean) { + adapter?.let { + it.selectAll(select) + it.notifyDataSetChanged() + mMultiChoiceModeListener?.invalidateActionMode() + } + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + fun onMessageEvent(event: FavoriteEvent) { + try { + val user = accountManager.user + val client = clientFactory.create(user) + val toggleFavoriteOperation = ToggleAlbumFavoriteRemoteOperation( + event.shouldFavorite, + event.remotePath + ) + val remoteOperationResult = toggleFavoriteOperation.execute(client) + + if (remoteOperationResult.isSuccess) { + Handler(Looper.getMainLooper()).post { + mMultiChoiceModeListener?.exitSelectionMode() + } + adapter?.markAsFavorite(event.remotePath, event.shouldFavorite) + } + } catch (e: CreationException) { + Log_OC.e(TAG, "Error processing event", e) + } + } + + private fun onRemoveFileOperation(files: Collection) { + lifecycleScope.launch(Dispatchers.IO) { + val removeFailedFiles = mutableListOf() + try { + val user = accountManager.user + val client = clientFactory.create(user) + withContext(Dispatchers.Main) { + showDialog(true) + } + if (files.size == 1) { + val removeAlbumFileRemoteOperation = RemoveAlbumFileRemoteOperation( + getAlbumRemotePathForRemoval(files.first()) + ) + val remoteOperationResult = removeAlbumFileRemoteOperation.execute(client) + + if (!remoteOperationResult.isSuccess) { + withContext(Dispatchers.Main) { + DisplayUtils.showSnackMessage( + requireActivity(), + ErrorMessageAdapter.getErrorCauseMessage( + remoteOperationResult, + removeAlbumFileRemoteOperation, + resources + ) + ) + } + } + } else { + for (file in files) { + val removeAlbumFileRemoteOperation = RemoveAlbumFileRemoteOperation( + getAlbumRemotePathForRemoval(file) + ) + val remoteOperationResult = removeAlbumFileRemoteOperation.execute(client) + + if (!remoteOperationResult.isSuccess) { + removeFailedFiles.add(file) + } + } + } + } catch (e: CreationException) { + Log_OC.e(TAG, "Error processing event", e) + } + + Log_OC.d(TAG, "Files removed: ${removeFailedFiles.size}") + + withContext(Dispatchers.Main) { + if (removeFailedFiles.isNotEmpty()) { + DisplayUtils.showSnackMessage( + requireActivity(), + requireContext().resources.getString(R.string.album_delete_failed_message) + ) + } + showDialog(false) + + // refresh data + refreshData() + } + } + } + + // since after files data are fetched in media the file remote path will be actual instead of Albums prefixed + // to remove the file properly form the albums we have to provide the correct album path + private fun getAlbumRemotePathForRemoval(ocFile: OCFile): String { + if (!ocFile.remotePath.startsWith("/albums/$albumName")) { + return albumRemoteFileList.find { it.etag == ocFile.etag || it.etag == ocFile.etagOnServer }?.remotePath + ?: ocFile.remotePath + } + return ocFile.remotePath + } + + private fun showConfirmationDialog(isAlbum: Boolean, files: Collection?) { + val messagePair = getConfirmationDialogMessage(isAlbum, files) + val errorDialog = ConfirmationDialogFragment.newInstance( + messageResId = messagePair.first, + messageArguments = arrayOf(messagePair.second), + titleResId = -1, + positiveButtonTextId = R.string.file_delete, + negativeButtonTextId = R.string.file_keep, + neutralButtonTextId = -1 + ) + errorDialog.setCancelable(false) + errorDialog.setOnConfirmationListener( + object : ConfirmationDialogFragmentListener { + override fun onConfirmation(callerTag: String?) { + if (isAlbum) { + mContainerActivity?.getFileOperationsHelper()?.removeAlbum(albumName) + } else { + files?.let { + onRemoveFileOperation(it) + } + } + } + + override fun onNeutral(callerTag: String?) { + // not used at the moment + } + + override fun onCancel(callerTag: String?) { + // not used at the moment + } + } + ) + errorDialog.show(requireActivity().supportFragmentManager, ConfirmationDialogFragment.FTAG_CONFIRMATION) + } + + private fun getConfirmationDialogMessage(isAlbum: Boolean, files: Collection?): Pair { + if (isAlbum) { + return Pair(R.string.confirmation_remove_folder_alert, albumName) + } + + return if (files?.size == SINGLE_SELECTION) { + Pair(R.string.confirmation_remove_file_alert, files.first().fileName) + } else { + Pair(R.string.confirmation_remove_files_alert, null) + } + } + + private fun showDialog(isShow: Boolean) { + if (requireActivity() is FileDisplayActivity) { + if (isShow) { + (requireActivity() as FileDisplayActivity).showLoadingDialog( + requireContext().resources.getString( + R.string.wait_a_moment + ) + ) + } else { + (requireActivity() as FileDisplayActivity).dismissLoadingDialog() + } + } + } + + override fun onStart() { + super.onStart() + EventBus.getDefault().register(this) + } + + override fun onStop() { + EventBus.getDefault().unregister(this) + super.onStop() + } + + /** + * Handler for multiple selection mode. + * + * + * Manages input from the user when one or more files or folders are selected in the list. + * + * + * Also listens to changes in navigation drawer to hide and recover multiple selection when it's opened and closed. + */ + internal class MultiChoiceModeListener( + val activity: FragmentActivity, + val adapter: GalleryAdapter?, + val viewThemeUtils: ViewThemeUtils, + val openActionsMenu: (Int, Set) -> Unit + ) : AbsListView.MultiChoiceModeListener, + DrawerLayout.DrawerListener { + + var mActiveActionMode: ActionMode? = null + private var mIsActionModeNew = false + + /** + * True when action mode is finished because the drawer was opened + */ + private var mActionModeClosedByDrawer = false + + /** + * Selected items in list when action mode is closed by drawer + */ + private val mSelectionWhenActionModeClosedByDrawer: MutableSet = HashSet() + + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { + // nothing to do + } + + override fun onDrawerOpened(drawerView: View) { + // nothing to do + } + + /** + * When the navigation drawer is closed, action mode is recovered in the same state as was when the drawer was + * (started to be) opened. + * + * @param drawerView Navigation drawer just closed. + */ + override fun onDrawerClosed(drawerView: View) { + if (mActionModeClosedByDrawer && mSelectionWhenActionModeClosedByDrawer.isNotEmpty()) { + activity.startActionMode(this) + + adapter?.setCheckedItem(mSelectionWhenActionModeClosedByDrawer) + + mActiveActionMode?.invalidate() + + mSelectionWhenActionModeClosedByDrawer.clear() + } + } + + /** + * If the action mode is active when the navigation drawer starts to move, the action mode is closed and the + * selection stored to be recovered when the drawer is closed. + * + * @param newState One of STATE_IDLE, STATE_DRAGGING or STATE_SETTLING. + */ + override fun onDrawerStateChanged(newState: Int) { + if (DrawerLayout.STATE_DRAGGING == newState && mActiveActionMode != null) { + adapter?.let { + mSelectionWhenActionModeClosedByDrawer.addAll( + it.getCheckedItems() + ) + } + + mActiveActionMode?.finish() + mActionModeClosedByDrawer = true + } + } + + /** + * Update action mode bar when an item is selected / unselected in the list + */ + override fun onItemCheckedStateChanged(mode: ActionMode, position: Int, id: Long, checked: Boolean) { + // nothing to do here + } + + /** + * Load menu and customize UI when action mode is started. + */ + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mActiveActionMode = mode + // Determine if actionMode is "new" or not (already affected by item-selection) + mIsActionModeNew = true + + // fake menu to be able to use bottom sheet instead + val inflater: MenuInflater = activity.menuInflater + inflater.inflate(R.menu.custom_menu_placeholder, menu) + val item = menu.findItem(R.id.custom_menu_placeholder_item) + item.icon?.let { + item.setIcon( + viewThemeUtils.platform.colorDrawable( + it, + ContextCompat.getColor(activity, R.color.white) + ) + ) + } + + mode.invalidate() + + // set actionMode color + viewThemeUtils.platform.colorStatusBar( + activity, + ContextCompat.getColor(activity, R.color.action_mode_background) + ) + + adapter?.setMultiSelect(true) + return true + } + + /** + * Updates available action in menu depending on current selection. + */ + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + val checkedFiles: Set = adapter?.getCheckedItems() ?: emptySet() + val checkedCount = checkedFiles.size + val title: String = + activity.resources.getQuantityString(R.plurals.items_selected_count, checkedCount, checkedCount) + mode.title = title + + // Determine if we need to finish the action mode because there are no items selected + if (checkedCount == 0 && !mIsActionModeNew) { + exitSelectionMode() + } + + return true + } + + /** + * Exits the multi file selection mode. + */ + fun exitSelectionMode() { + mActiveActionMode?.run { + finish() + } + } + + /** + * Will update (invalidate) the action mode adapter/mode to refresh an item selection change + * + * @param file The concerned OCFile to refresh in adapter + */ + fun updateActionModeFile(file: OCFile) { + mIsActionModeNew = false + mActiveActionMode?.let { + it.invalidate() + adapter?.notifyItemChanged(file) + } + } + + fun invalidateActionMode() { + mActiveActionMode?.invalidate() + } + + /** + * Starts the corresponding action when a menu item is tapped by the user. + */ + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + adapter?.let { + val checkedFiles: Set = it.getCheckedItems() + if (item.itemId == R.id.custom_menu_placeholder_item) { + openActionsMenu(it.getFilesCount(), checkedFiles) + } + return true + } + return false + } + + /** + * Restores UI. + */ + override fun onDestroyActionMode(mode: ActionMode) { + mActiveActionMode = null + + viewThemeUtils.platform.resetStatusBar(activity) + + adapter?.setMultiSelect(false) + adapter?.clearCheckedItems() + } + } + + private val activityResult: ActivityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { intentResult: ActivityResult -> + if (Activity.RESULT_OK == intentResult.resultCode) { + intentResult.data?.let { + val paths = it.getStringArrayListExtra(AlbumsPickerActivity.EXTRA_MEDIA_FILES_PATH) + paths?.let { p -> + addMediaToAlbum(p.toMutableList()) + } + } + } + } + + private fun openGalleryToAddMedia() { + activityResult.launch(intentForPickingMediaFiles(requireActivity())) + } + + private fun addMediaToAlbum(filePaths: MutableList) { + viewLifecycleOwner.lifecycleScope.launch { + // short delay to let other transactions finish + // else showLoadingDialog will throw exception + delay(SLEEP_DELAY) + mContainerActivity?.fileOperationsHelper?.albumCopyFiles(filePaths, albumName) + } + } + + fun refreshData() { + refreshFlow.tryEmit(Unit) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (data != null && + requestCode == REQUEST_CODE__SELECT_MEDIA_FROM_APPS && resultCode == RESULT_OK + ) { + requestUploadOfContentFromApps(data) + } + super.onActivityResult(requestCode, resultCode, data) + } + + // method referenced from FileDisplayActivity#requestUploadOfContentFromApps + private fun requestUploadOfContentFromApps(contentIntent: Intent) { + val clipData = contentIntent.clipData + val uris = mutableListOf() + + if (clipData != null) { + for (i in 0 until clipData.itemCount) { + uris.add(clipData.getItemAt(i).uri) + } + } else { + contentIntent.data?.let { uris.add(it) } + } + + // only accept images and videos mime type + val validUris = uris.filter { uri -> + val type = requireActivity().contentResolver.getType(uri) + type?.startsWith("image/") == true || type?.startsWith("video/") == true + } + + if (validUris.isEmpty()) { + DisplayUtils.showSnackMessage(requireActivity(), R.string.album_unsupported_file) + return + } + + val streamsToUpload = ArrayList() + streamsToUpload.addAll(validUris) + + // albums remote path for uploading + val remotePath = + "${resources.getString(R.string.instant_upload_path)}/${resources.getString(R.string.drawer_item_album)}/" + + if (requireActivity() is FileDisplayActivity) { + val uploader = UriUploader( + requireActivity() as FileDisplayActivity, + streamsToUpload, + remotePath, + albumName, + (requireActivity() as FileDisplayActivity).user.orElseThrow( + Supplier { RuntimeException() } + ), + FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + false, // Not show waiting dialog while file is being copied from private storage + null // Not needed copy temp task listener + ) + + uploader.uploadUris() + } + } + + companion object { + val TAG: String = AlbumItemsFragment::class.java.simpleName + + const val REQUEST_CODE__SELECT_MEDIA_FROM_APPS: Int = REQUEST_CODE__LAST_SHARED + 10 + + private const val SINGLE_SELECTION = 1 + + private const val ARG_ALBUM_NAME = "album_name" + private const val ARG_IS_NEW_ALBUM = "is_new_album" + var lastMediaItemPosition: Int? = null + + private const val MAX_COLUMN_SIZE_LANDSCAPE: Int = 5 + private const val MAX_COLUMN_SIZE_PORTRAIT: Int = 2 + + private const val SLEEP_DELAY = 100L + private const val DEBOUNCE_DELAY = 500L + + fun newInstance(albumName: String, isNewAlbum: Boolean = false): AlbumItemsFragment { + val args = Bundle() + + val fragment = AlbumItemsFragment() + fragment.arguments = args + args.putString(ARG_ALBUM_NAME, albumName) + args.putBoolean(ARG_IS_NEW_ALBUM, isNewAlbum) + return fragment + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt new file mode 100644 index 000000000000..fe705566ddec --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt @@ -0,0 +1,385 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment.albums + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import android.os.Parcelable +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.appbar.AppBarLayout +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.network.ClientFactory.CreationException +import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.client.utils.Throttler +import com.owncloud.android.R +import com.owncloud.android.databinding.AlbumsFragmentBinding +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.albums.PhotoAlbumEntry +import com.owncloud.android.lib.resources.albums.ReadAlbumsRemoteOperation +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.adapter.albums.AlbumFragmentInterface +import com.owncloud.android.ui.adapter.albums.AlbumsAdapter +import com.owncloud.android.ui.dialog.CreateAlbumDialogFragment +import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Optional +import javax.inject.Inject + +class AlbumsFragment : + Fragment(), + AlbumFragmentInterface, + Injectable { + + private var adapter: AlbumsAdapter? = null + private var client: OwnCloudClient? = null + private var optionalUser: Optional? = null + + private lateinit var binding: AlbumsFragmentBinding + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var accountManager: UserAccountManager + + @Inject + lateinit var clientFactory: ClientFactory + + @Inject + lateinit var preferences: AppPreferences + + @Inject + lateinit var syncedFolderProvider: SyncedFolderProvider + + @Inject + lateinit var throttler: Throttler + + private var mContainerActivity: FileFragment.ContainerActivity? = null + + private var isGridView = true + private var maxColumnSize = 2 + private var isSelectionMode = false + private var listState: Parcelable? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + try { + mContainerActivity = context as FileFragment.ContainerActivity + } catch (e: ClassCastException) { + throw IllegalArgumentException( + context.toString() + " must implement " + + FileFragment.ContainerActivity::class.java.simpleName, + e + ) + } + arguments?.let { + isSelectionMode = it.getBoolean(ARG_IS_SELECTION_MODE, false) + if (isSelectionMode) { + isGridView = false + } + } + } + + override fun onDetach() { + mContainerActivity = null + super.onDetach() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + maxColumnSize = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + MAX_COLUMN_SIZE_LANDSCAPE + } else { + MAX_COLUMN_SIZE_PORTRAIT + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = AlbumsFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + optionalUser = Optional.of(accountManager.user) + showAppBar() + createMenu() + setupContainingList() + setupContent() + binding.createAlbum.setOnClickListener { + showCreateAlbumDialog() + } + } + + private fun showAppBar() { + if (requireActivity() is FileDisplayActivity) { + val appBarLayout = requireActivity().findViewById(R.id.appbar) + appBarLayout?.setExpanded(true, false) + } + } + + private fun createMenu() { + val menuHost: MenuHost = requireActivity() + menuHost.addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menu.clear() // important: clears any existing activity menu + menuInflater.inflate(R.menu.fragment_create_album, menu) + + val addItem = menu.findItem(R.id.action_create_new_album) + val coloredTitle = SpannableString(addItem.title).apply { + setSpan( + ForegroundColorSpan(ContextCompat.getColor(requireContext(), R.color.primary)), + 0, + length, + Spannable.SPAN_INCLUSIVE_INCLUSIVE + ) + } + addItem.title = coloredTitle + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.action_create_new_album -> { + showCreateAlbumDialog() + true + } + + else -> false + } + }, + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + } + + private fun showCreateAlbumDialog() { + throttler.run("onCreateAlbumClick") { + val fragment = requireActivity().supportFragmentManager.findFragmentByTag(CreateAlbumDialogFragment.TAG) + if (fragment == null) { + CreateAlbumDialogFragment.newInstance() + .show( + requireActivity().supportFragmentManager, + CreateAlbumDialogFragment.TAG + ) + } + } + } + + private fun setupContent() { + binding.listRoot.setHasFixedSize(true) + if (isGridView) { + val layoutManager = GridLayoutManager(requireContext(), maxColumnSize) + binding.listRoot.layoutManager = layoutManager + } else { + val layoutManager = LinearLayoutManager(requireContext()) + binding.listRoot.layoutManager = layoutManager + } + fetchAndSetData() + } + + private fun setupContainingList() { + viewThemeUtils.androidx.themeSwipeRefreshLayout(binding.swipeContainingList) + binding.swipeContainingList.setOnRefreshListener { + fetchAndSetData() + } + } + + @VisibleForTesting + fun populateList(albums: List?) { + if (requireActivity() is FileDisplayActivity) { + (requireActivity() as FileDisplayActivity).setMainFabVisible(false) + } + initializeAdapter() + adapter?.setAlbumItems(albums) + } + + private fun fetchAndSetData() { + binding.swipeContainingList.isRefreshing = true + initializeAdapter() + updateEmptyView(false) + lifecycleScope.launch(Dispatchers.IO) { + val albumsRemoteOperation = ReadAlbumsRemoteOperation() + val result = client?.let { albumsRemoteOperation.execute(it) } + withContext(Dispatchers.Main) { + if (result?.isSuccess == true && result.resultData != null) { + if (result.resultData.isEmpty()) { + updateEmptyView(true) + } + populateList(result.resultData) + } else { + Log_OC.d(TAG, result?.logMessage) + // show error + updateEmptyView(true) + } + hideRefreshLayoutLoader() + } + } + } + + private fun hideRefreshLayoutLoader() { + binding.swipeContainingList.isRefreshing = false + } + + private fun initializeClient() { + if (client == null && optionalUser?.isPresent == true) { + try { + val user = optionalUser?.get() + client = clientFactory.create(user) + } catch (e: CreationException) { + Log_OC.e(TAG, "Error initializing client", e) + } + } + } + + private fun initializeAdapter() { + initializeClient() + if (adapter == null) { + adapter = AlbumsAdapter( + requireContext(), + mContainerActivity?.storageManager, + accountManager.user, + this, + syncedFolderProvider, + preferences, + viewThemeUtils, + isGridView + ) + } + binding.listRoot.adapter = adapter + + // Restore scroll state + listState?.let { + binding.listRoot.layoutManager?.onRestoreInstanceState(it) + } + } + + private fun updateEmptyView(isEmpty: Boolean) { + binding.emptyViewLayout.visibility = if (isEmpty) View.VISIBLE else View.GONE + binding.listRoot.visibility = if (isEmpty) View.GONE else View.VISIBLE + } + + override fun onResume() { + super.onResume() + if (isSelectionMode) { + binding.root.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.bg_default, null)) + } + if (requireActivity() is FileDisplayActivity) { + (requireActivity() as FileDisplayActivity).setupToolbar() + (requireActivity() as FileDisplayActivity).supportActionBar?.let { actionBar -> + viewThemeUtils.files.themeActionBar( + requireContext(), + actionBar, + R.string.drawer_item_album, + isMenu = true + ) + } + (requireActivity() as FileDisplayActivity).showSortListGroup(false) + (requireActivity() as FileDisplayActivity).setMainFabVisible(false) + + // clear the subtitle while navigating to any other screen from Media screen + (requireActivity() as FileDisplayActivity).clearToolbarSubtitle() + } + } + + fun navigateToAlbumItemsFragment(albumName: String, isNewAlbum: Boolean = false) { + requireActivity().supportFragmentManager.beginTransaction().apply { + addToBackStack(null) + replace( + R.id.left_fragment_container, + AlbumItemsFragment.newInstance(albumName, isNewAlbum = isNewAlbum), + AlbumItemsFragment.TAG + ) + commit() + } + } + + fun refreshAlbums() { + fetchAndSetData() + } + + override fun onPause() { + super.onPause() + adapter?.cancelAllPendingTasks() + listState = binding.listRoot.layoutManager?.onSaveInstanceState() + } + + private val isGridEnabled: Boolean + get() { + return binding.listRoot.layoutManager is GridLayoutManager + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (isGridEnabled) { + if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) { + maxColumnSize = MAX_COLUMN_SIZE_LANDSCAPE + } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { + maxColumnSize = MAX_COLUMN_SIZE_PORTRAIT + } + (binding.listRoot.layoutManager as GridLayoutManager).setSpanCount(maxColumnSize) + } + } + + companion object { + val TAG: String = AlbumsFragment::class.java.simpleName + private const val ARG_IS_SELECTION_MODE = "is_selection_mode" + const val ARG_SELECTED_ALBUM_NAME = "selected_album_name" + + private const val MAX_COLUMN_SIZE_LANDSCAPE: Int = 4 + private const val MAX_COLUMN_SIZE_PORTRAIT: Int = 2 + + fun newInstance(isSelectionMode: Boolean = false): AlbumsFragment { + val args = Bundle() + args.putBoolean(ARG_IS_SELECTION_MODE, isSelectionMode) + val fragment = AlbumsFragment() + fragment.arguments = args + return fragment + } + } + + override fun onItemClick(album: PhotoAlbumEntry) { + if (isSelectionMode) { + val resultIntent = Intent().apply { + putExtra(ARG_SELECTED_ALBUM_NAME, album.albumName) + } + requireActivity().setResult(Activity.RESULT_OK, resultIntent) + requireActivity().finish() + return + } + navigateToAlbumItemsFragment(album.albumName) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java index b071a92a9eff..af19f239ba40 100755 --- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java +++ b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2023-2025 TSI-mc * SPDX-FileCopyrightText: 2020 Chris Narkiewicz * SPDX-FileCopyrightText: 2018-2020 Tobias Kaminsky * SPDX-FileCopyrightText: 2018-2020 Andy Scherzinger @@ -1039,6 +1039,55 @@ public void moveOrCopyFiles(String action, final List filePaths, final O fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)); } + public void createAlbum(String albumName) { + // Create Album + Intent service = new Intent(fileActivity, OperationsService.class); + service.setAction(OperationsService.ACTION_CREATE_ALBUM); + service.putExtra(OperationsService.EXTRA_ACCOUNT, fileActivity.getAccount()); + service.putExtra(OperationsService.EXTRA_ALBUM_NAME, albumName); + mWaitingForOpId = fileActivity.getOperationsServiceBinder().queueNewOperation(service); + + fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)); + } + + public void albumCopyFiles(final List filePaths, final String targetFolder) { + if (filePaths == null || filePaths.isEmpty()) { + return; + } + + for (String path : filePaths) { + Intent service = new Intent(fileActivity, OperationsService.class); + service.setAction(OperationsService.ACTION_ALBUM_COPY_FILE); + service.putExtra(OperationsService.EXTRA_NEW_PARENT_PATH, targetFolder); + service.putExtra(OperationsService.EXTRA_REMOTE_PATH, path); + service.putExtra(OperationsService.EXTRA_ACCOUNT, fileActivity.getAccount()); + mWaitingForOpId = fileActivity.getOperationsServiceBinder().queueNewOperation(service); + } + fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)); + } + + public void renameAlbum(String oldAlbumName, String newAlbumName) { + Intent service = new Intent(fileActivity, OperationsService.class); + + service.setAction(OperationsService.ACTION_RENAME_ALBUM); + service.putExtra(OperationsService.EXTRA_ACCOUNT, fileActivity.getAccount()); + service.putExtra(OperationsService.EXTRA_REMOTE_PATH, oldAlbumName); + service.putExtra(OperationsService.EXTRA_NEWNAME, newAlbumName); + mWaitingForOpId = fileActivity.getOperationsServiceBinder().queueNewOperation(service); + + fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)); + } + + public void removeAlbum(String albumName) { + Intent service = new Intent(fileActivity, OperationsService.class); + service.setAction(OperationsService.ACTION_REMOVE_ALBUM); + service.putExtra(OperationsService.EXTRA_ACCOUNT, fileActivity.getAccount()); + service.putExtra(OperationsService.EXTRA_ALBUM_NAME, albumName); + mWaitingForOpId = fileActivity.getOperationsServiceBinder().queueNewOperation(service); + + fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)); + } + public void exportFiles(Collection files, Context context, View view, diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt b/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt index 9f08ad99bbce..09a984103a7e 100644 --- a/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt +++ b/app/src/main/java/com/owncloud/android/ui/helpers/UriUploader.kt @@ -4,6 +4,7 @@ * SPDX-FileCopyrightText: 2024 Alper Ozturk * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2018-2021 Tobias Kaminsky + * SPDX-FileCopyrightText: 2026 TSI-mc * SPDX-FileCopyrightText: 2022 Nextcloud GmbH * SPDX-FileCopyrightText: 2016 ownCloud Inc. * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) @@ -51,6 +52,29 @@ class UriUploader( private val mShowWaitingDialog: Boolean, private val mCopyTmpTaskListener: OnCopyTmpFilesTaskListener? ) { + // used when uploading from Albums + var albumName: String? = null + + constructor( + activity: FileActivity, + urisToUpload: List, + uploadPath: String, + albumName: String?, + user: User, + behaviour: Int, + showWaitingDialog: Boolean, + copyTmpTaskListener: OnCopyTmpFilesTaskListener? + ) : this( + activity, + urisToUpload, + uploadPath, + user, + behaviour, + showWaitingDialog, + copyTmpTaskListener + ) { + this.albumName = albumName + } enum class UriUploaderResultCode { OK, @@ -132,18 +156,35 @@ class UriUploader( * @param remotePaths Absolute paths in the current OC account to set to the uploaded file. */ private fun requestUpload(localPaths: Array, remotePaths: Array) { - FileUploadHelper.instance().uploadNewFiles( - user, - localPaths, - remotePaths, - mBehaviour, - // do not create parent folder if not existent - false, - UploadFileOperation.CREATED_BY_USER, - requiresWifi = false, - requiresCharging = false, - nameCollisionPolicy = NameCollisionPolicy.ASK_USER - ) + if (albumName.isNullOrEmpty()) { + FileUploadHelper.instance().uploadNewFiles( + user, + localPaths, + remotePaths, + mBehaviour, + // do not create parent folder if not existent + false, + UploadFileOperation.CREATED_BY_USER, + requiresWifi = false, + requiresCharging = false, + nameCollisionPolicy = NameCollisionPolicy.ASK_USER + ) + } else { + FileUploadHelper.instance().uploadAndCopyNewFilesForAlbum( + user, + localPaths, + remotePaths, + albumName!!, + mBehaviour, + // create parent folder if not existent + true, + UploadFileOperation.CREATED_BY_USER, + requiresWifi = false, + requiresCharging = false, + // use RENAME policy to make sure all files are uploaded + nameCollisionPolicy = NameCollisionPolicy.RENAME + ) + } } /** @@ -155,7 +196,7 @@ class UriUploader( if (mShowWaitingDialog) { mActivity.showLoadingDialog(mActivity.resources.getString(R.string.wait_for_tmp_copy_from_private_storage)) } - val copyTask = CopyAndUploadContentUrisTask(mCopyTmpTaskListener, mActivity) + val copyTask = CopyAndUploadContentUrisTask(mCopyTmpTaskListener, mActivity, albumName) val fm = mActivity.supportFragmentManager // Init Fragment without UI to retain AsyncTask across configuration changes diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt index 9b16124a0e6d..2a288ffb2776 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt @@ -8,6 +8,7 @@ * SPDX-FileCopyrightText: 2019 Chris Narkiewicz * SPDX-FileCopyrightText: 2015 ownCloud Inc. * SPDX-FileCopyrightText: 2013-2015 David A. Velasco + * SPDX-FileCopyrightText: 2026 TSI-mc * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) */ package com.owncloud.android.ui.preview @@ -39,6 +40,7 @@ import androidx.core.view.MenuHost import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import com.caverock.androidsvg.SVG import com.caverock.androidsvg.SVGParseException import com.github.chrisbanes.photoview.PhotoView @@ -59,6 +61,7 @@ import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.datamodel.ThumbnailsCacheManager.AsyncResizedImageDrawable import com.owncloud.android.datamodel.ThumbnailsCacheManager.ResizedImageGenerationTask import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.FetchRemoteFileOperation import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.dialog.ConfirmationDialogFragment import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment @@ -69,6 +72,9 @@ import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import pl.droidsonroids.gif.GifDrawable import java.io.FileInputStream import java.io.FileNotFoundException @@ -366,17 +372,7 @@ class PreviewImageFragment : override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.custom_menu_placeholder_item -> { - val file = file - if (containerActivity.storageManager != null && file != null) { - // Update the file - val updatedFile = containerActivity.storageManager.getFileById(file.fileId) - setFile(updatedFile) - - val fileNew = getFile() - if (fileNew != null) { - showFileActions(file) - } - } + onOverflowClick() true } @@ -388,6 +384,60 @@ class PreviewImageFragment : ) } + /** + * @param isManualClick if true skip album check to avoid calling api in loop if file fetch fails + */ + private fun onOverflowClick(isManualClick: Boolean = false) { + val file = file + if (containerActivity.storageManager != null && file != null) { + // Update the file + val updatedFile = containerActivity.storageManager.getFileById(file.fileId) + // check for albums file + // for album file both local and remoteId will be same configured at operation level + if (!isManualClick && updatedFile != null && updatedFile.localId.toString() == updatedFile.remoteId) { + fetchFileMetaDataIfAbsent(updatedFile) + } else { + setFile(updatedFile) + + val fileNew = getFile() + if (fileNew != null) { + showFileActions(file) + } + } + } + } + + private fun fetchFileMetaDataIfAbsent(ocFile: OCFile) { + if (requireActivity() is FileActivity) { + (requireActivity() as FileActivity).showLoadingDialog(getString(R.string.wait_a_moment)) + } + lifecycleScope.launch(Dispatchers.IO) { + val fetchRemoteFileOperation = + FetchRemoteFileOperation( + requireActivity(), + accountManager.user, + ocFile, + removeFileFromDb = true, + storageManager = containerActivity.storageManager + ) + val result = fetchRemoteFileOperation.execute(requireActivity()) + withContext(Dispatchers.Main) { + if (requireActivity() is FileActivity) { + (requireActivity() as FileActivity).dismissLoadingDialog() + } + if (result?.isSuccess == true && result.resultData != null) { + file = result.resultData as OCFile + + onOverflowClick(isManualClick = true) + } else { + Log_OC.d(TAG, result?.logMessage) + // show error + DisplayUtils.showSnackMessage(binding.root, result.getLogMessage(requireContext())) + } + } + } + } + private fun showFileActions(file: OCFile) { val additionalFilter = FileAction.getFilePreviewActions(getFile()) val fragmentManager = childFragmentManager diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt index 9f75a6972e05..2217afb2c69d 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2025 TSI-mc * SPDX-FileCopyrightText: 2023 Alper Ozturk * SPDX-FileCopyrightText: 2018 Tobias Kaminsky * SPDX-FileCopyrightText: 2020 Chris Narkiewicz @@ -97,6 +98,9 @@ class PreviewImagePagerAdapter : FragmentStateAdapter { if (type == VirtualFolderType.GALLERY) { imageFiles = mStorageManager.allGalleryItems imageFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(imageFiles) + } else if (type == VirtualFolderType.ALBUM) { + imageFiles = mStorageManager.getVirtualFolderContent(type, false) + imageFiles = FileStorageUtils.sortOcFolderDescDateModifiedWithoutFavoritesFirst(imageFiles) } else { imageFiles = mStorageManager.getVirtualFolderContent(type, true) } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt index 6ed9b4297b0f..ca561fbefbed 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt @@ -3,7 +3,7 @@ * * SPDX-FileCopyrightText: 2023 Parneet Singh * SPDX-FileCopyrightText: 2023 Alper Ozturk - * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2023-2026 TSI-mc * SPDX-FileCopyrightText: 2020 Andy Scherzinger * SPDX-FileCopyrightText: 2019 Chris Narkiewicz * SPDX-FileCopyrightText: 2016 David A. Velasco @@ -85,6 +85,7 @@ 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.operations.DownloadType +import com.owncloud.android.operations.FetchRemoteFileOperation import com.owncloud.android.operations.RemoveFileOperation import com.owncloud.android.operations.SynchronizeFileOperation import com.owncloud.android.ui.activity.FileActivity @@ -520,17 +521,56 @@ class PreviewMediaActivity : } if (item.itemId == R.id.custom_menu_placeholder_item) { - val file = file + onOverflowClick() + } - if (storageManager != null && file != null) { - val updatedFile = storageManager.getFileById(file.fileId) + return super.onOptionsItemSelected(item) + } + + /** + * @param isManualClick if true skip album check to avoid calling api in loop if file fetch fails + */ + private fun onOverflowClick(isManualClick: Boolean = false) { + val file = file + if (storageManager != null && file != null) { + val updatedFile = storageManager.getFileById(file.fileId) + // check for albums file + // for album file both local and remoteId will be same configured at operation level + if (!isManualClick && updatedFile != null && updatedFile.localId.toString() == updatedFile.remoteId) { + fetchFileMetaDataIfAbsent(updatedFile) + } else { setFile(updatedFile) val fileNew = getFile() fileNew?.let { showFileActions(it) } } } + } - return super.onOptionsItemSelected(item) + private fun fetchFileMetaDataIfAbsent(ocFile: OCFile) { + showLoadingDialog(getString(R.string.wait_a_moment)) + lifecycleScope.launch(Dispatchers.IO) { + val fetchRemoteFileOperation = + FetchRemoteFileOperation( + this@PreviewMediaActivity, + accountManager.user, + ocFile, + removeFileFromDb = true, + storageManager = storageManager + ) + val result = fetchRemoteFileOperation.execute(this@PreviewMediaActivity) + withContext(Dispatchers.Main) { + dismissLoadingDialog() + if (result?.isSuccess == true && result.resultData != null) { + file = result.resultData as OCFile + + onOverflowClick(isManualClick = true) + } else { + Log_OC.d(TAG, result?.logMessage) + // show error + DisplayUtils.showSnackMessage(binding.root, result.getLogMessage(this@PreviewMediaActivity)) + } + } + } } private fun showFileActions(file: OCFile) { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt index de86259e9337..a48e7d19e464 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2023-2026 TSI-mc * SPDX-FileCopyrightText: 2023 Parneet Singh * SPDX-FileCopyrightText: 2020 Andy Scherzinger * SPDX-FileCopyrightText: 2019 Chris Narkiewicz @@ -37,6 +37,7 @@ import androidx.core.view.MenuHost import androidx.core.view.MenuProvider import androidx.drawerlayout.widget.DrawerLayout import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi @@ -65,12 +66,17 @@ import com.owncloud.android.datamodel.OCFile import com.owncloud.android.files.StreamMediaFileOperation import com.owncloud.android.lib.common.OwnCloudClient import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.FetchRemoteFileOperation import com.owncloud.android.ui.activity.DrawerActivity import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.dialog.ConfirmationDialogFragment import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.lang.ref.WeakReference import java.util.concurrent.Executors import javax.inject.Inject @@ -318,13 +324,7 @@ class PreviewMediaFragment : return when (menuItem.itemId) { R.id.custom_menu_placeholder_item -> { if (containerActivity.storageManager == null || file == null) return false - - val updatedFile = containerActivity.storageManager.getFileById(file.fileId) - file = updatedFile - file?.let { newFile -> - showFileActions(newFile) - } - + onOverflowClick() true } @@ -337,6 +337,54 @@ class PreviewMediaFragment : ) } + /** + * @param isManualClick if true skip album check to avoid calling api in loop if file fetch fails + */ + private fun onOverflowClick(isManualClick: Boolean = false) { + val updatedFile = containerActivity.storageManager.getFileById(file.fileId) + // check for albums file + // for album file both local and remoteId will be same configured at operation level + if (!isManualClick && updatedFile != null && updatedFile.localId.toString() == updatedFile.remoteId) { + fetchFileMetaDataIfAbsent(updatedFile) + } else { + file = updatedFile + file?.let { newFile -> + showFileActions(newFile) + } + } + } + + private fun fetchFileMetaDataIfAbsent(ocFile: OCFile) { + if (requireActivity() is FileActivity) { + (requireActivity() as FileActivity).showLoadingDialog(getString(R.string.wait_a_moment)) + } + lifecycleScope.launch(Dispatchers.IO) { + val fetchRemoteFileOperation = + FetchRemoteFileOperation( + requireActivity(), + accountManager.user, + ocFile, + removeFileFromDb = true, + storageManager = containerActivity.storageManager + ) + val result = fetchRemoteFileOperation.execute(requireActivity()) + withContext(Dispatchers.Main) { + if (requireActivity() is FileActivity) { + (requireActivity() as FileActivity).dismissLoadingDialog() + } + if (result?.isSuccess == true && result.resultData != null) { + file = result.resultData as OCFile + + onOverflowClick(isManualClick = true) + } else { + Log_OC.d(TAG, result?.logMessage) + // show error + DisplayUtils.showSnackMessage(binding.root, result.getLogMessage(requireContext())) + } + } + } + } + private fun showFileActions(file: OCFile) { val additionalFilter = FileAction.getFilePreviewActions(getFile()) newInstance(file, false, additionalFilter) diff --git a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java index 1a232702f174..dd60b31db53d 100644 --- a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java @@ -4,7 +4,7 @@ * SPDX-FileCopyrightText: 2024 Alper Ozturk * SPDX-FileCopyrightText: 2023 ZetaTom * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2021 TSI-mc + * SPDX-FileCopyrightText: 2021-2026 TSI-mc * SPDX-FileCopyrightText: 2020 Infomaniak Network SA * SPDX-FileCopyrightText: 2020 Joris Bodin * SPDX-FileCopyrightText: 2020 Kilian Périsset @@ -798,6 +798,33 @@ public static String getDateByPattern(long timestamp, @Nullable Context context, return df.format(timestamp); } + // required to hide video icon overlay for albums + public static void setThumbnail(OCFile file, + ImageView thumbnailView, + User user, + FileDataStorageManager storageManager, + List asyncTasks, + boolean gridView, + Context context, + LoaderImageView shimmerThumbnail, + AppPreferences preferences, + ViewThemeUtils viewThemeUtils, + SyncedFolderProvider syncedFolderProvider + ) { + setThumbnail(file, + thumbnailView, + user, + storageManager, + asyncTasks, + gridView, + context, + shimmerThumbnail, + preferences, + viewThemeUtils, + syncedFolderProvider, + false); + } + /** * Sets a thumbnail for a offline file, file or folder with various display options and states. *

@@ -838,7 +865,8 @@ public static void setThumbnail(OCFile file, LoaderImageView shimmerThumbnail, AppPreferences preferences, ViewThemeUtils viewThemeUtils, - SyncedFolderProvider syncedFolderProvider) { + SyncedFolderProvider syncedFolderProvider, + boolean hideVideoOverlay) { if (file == null || thumbnailView == null || context == null) { return; } @@ -854,16 +882,16 @@ public static void setThumbnail(OCFile file, } if (file.getRemoteId() == null || !file.isPreviewAvailable()) { - setThumbnailFirstTimeForFile(file, thumbnailView, storageManager, asyncTasks, gridView, shimmerThumbnail, user, preferences, context, viewThemeUtils); + setThumbnailFirstTimeForFile(file, thumbnailView, storageManager, asyncTasks, gridView, shimmerThumbnail, user, preferences, context, viewThemeUtils, hideVideoOverlay); return; } - setThumbnailFromCache(file, thumbnailView, storageManager, asyncTasks, gridView, shimmerThumbnail, user, preferences, context, viewThemeUtils); + setThumbnailFromCache(file, thumbnailView, storageManager, asyncTasks, gridView, shimmerThumbnail, user, preferences, context, viewThemeUtils, hideVideoOverlay); } - private static void setThumbnailFirstTimeForFile(OCFile file, ImageView thumbnailView, FileDataStorageManager storageManager, List asyncTasks, boolean gridView, LoaderImageView shimmerThumbnail, User user, AppPreferences preferences, Context context, ViewThemeUtils viewThemeUtils) { + private static void setThumbnailFirstTimeForFile(OCFile file, ImageView thumbnailView, FileDataStorageManager storageManager, List asyncTasks, boolean gridView, LoaderImageView shimmerThumbnail, User user, AppPreferences preferences, Context context, ViewThemeUtils viewThemeUtils, boolean hideVideoOverlay) { if (file.getRemoteId() != null) { - generateNewThumbnail(file, thumbnailView, user, storageManager, new ArrayList<>(asyncTasks), gridView, context, shimmerThumbnail, preferences, viewThemeUtils); + generateNewThumbnail(file, thumbnailView, user, storageManager, new ArrayList<>(asyncTasks), gridView, context, shimmerThumbnail, preferences, viewThemeUtils, hideVideoOverlay); return; } @@ -905,10 +933,10 @@ private static void setThumbnailForFolder(OCFile file, ImageView thumbnailView, thumbnailView.setImageDrawable(fileIcon); } - private static void setThumbnailFromCache(OCFile file, ImageView thumbnailView, FileDataStorageManager storageManager, List asyncTasks, boolean gridView, LoaderImageView shimmerThumbnail, User user, AppPreferences preferences, Context context, ViewThemeUtils viewThemeUtils) { + private static void setThumbnailFromCache(OCFile file, ImageView thumbnailView, FileDataStorageManager storageManager, List asyncTasks, boolean gridView, LoaderImageView shimmerThumbnail, User user, AppPreferences preferences, Context context, ViewThemeUtils viewThemeUtils, boolean hideVideoOverlay) { final var thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.getRemoteId()); if (thumbnail == null || file.isUpdateThumbnailNeeded()) { - generateNewThumbnail(file, thumbnailView, user, storageManager, new ArrayList<>(asyncTasks), gridView, context, shimmerThumbnail, preferences, viewThemeUtils); + generateNewThumbnail(file, thumbnailView, user, storageManager, new ArrayList<>(asyncTasks), gridView, context, shimmerThumbnail, preferences, viewThemeUtils, hideVideoOverlay); setThumbnailBackgroundForPNGFileIfNeeded(file, context, thumbnailView); return; } @@ -916,8 +944,12 @@ private static void setThumbnailFromCache(OCFile file, ImageView thumbnailView, stopShimmer(shimmerThumbnail, thumbnailView); if (MimeTypeUtil.isVideo(file)) { - final var withOverlay = ThumbnailsCacheManager.addVideoOverlay(thumbnail, context); - thumbnailView.setImageBitmap(withOverlay); + if (hideVideoOverlay) { + thumbnailView.setImageBitmap(thumbnail); + } else { + final var withOverlay = ThumbnailsCacheManager.addVideoOverlay(thumbnail, context); + thumbnailView.setImageBitmap(withOverlay); + } } else { BitmapUtils.setRoundedBitmapAccordingToListType(gridView, thumbnail, thumbnailView); } @@ -941,7 +973,8 @@ private static void generateNewThumbnail(OCFile file, Context context, LoaderImageView shimmerThumbnail, AppPreferences preferences, - ViewThemeUtils viewThemeUtils) { + ViewThemeUtils viewThemeUtils, + boolean hideVideoOverlay) { if (!ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, thumbnailView)) { return; } @@ -972,7 +1005,8 @@ private static void generateNewThumbnail(OCFile file, user, asyncTasks, gridView, - file.getRemoteId()); + file.getRemoteId(), + hideVideoOverlay); Drawable drawable = MimeTypeUtil.getFileTypeIcon(file.getMimeType(), file.getFileName(), context, diff --git a/app/src/main/java/com/owncloud/android/utils/ErrorMessageAdapter.java b/app/src/main/java/com/owncloud/android/utils/ErrorMessageAdapter.java index 6795feb714e6..1f35e275d2c5 100644 --- a/app/src/main/java/com/owncloud/android/utils/ErrorMessageAdapter.java +++ b/app/src/main/java/com/owncloud/android/utils/ErrorMessageAdapter.java @@ -5,6 +5,7 @@ * SPDX-FileCopyrightText: 2017 Tobias Kaminsky * SPDX-FileCopyrightText: 2016 ownCloud Inc. * SPDX-FileCopyrightText: 2015 María Asensio Valverde + * SPDX-FileCopyrightText: 2026 TSI-mc * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) */ package com.owncloud.android.utils; @@ -30,6 +31,8 @@ import com.owncloud.android.operations.UpdateSharePermissionsOperation; import com.owncloud.android.operations.UpdateShareViaLinkOperation; import com.owncloud.android.operations.UploadFileOperation; +import com.owncloud.android.operations.albums.CopyFileToAlbumOperation; +import com.owncloud.android.lib.resources.albums.RenameAlbumRemoteOperation; import org.apache.commons.httpclient.ConnectTimeoutException; @@ -148,6 +151,10 @@ String getMessageForResultAndOperation( } else if (operation instanceof CopyFileOperation) { message = getMessageForCopyFileOperation(result, res); + } else if (operation instanceof CopyFileToAlbumOperation) { + message = getMessageForCopyFileToAlbumOperation(result, res); + } else if (operation instanceof RenameAlbumRemoteOperation) { + message = getMessageForRenameAlbumOperation(result, res); } return message; @@ -500,4 +507,20 @@ String getMessageForOperation(RemoteOperation operation, Resources res) { return message; } -} + + private static @Nullable + String getMessageForCopyFileToAlbumOperation(RemoteOperationResult result, Resources res) { + if (result.getCode() == ResultCode.CONFLICT) { + return res.getString(R.string.album_copy_file_conflict); + } + return null; + } + + private static @Nullable + String getMessageForRenameAlbumOperation(RemoteOperationResult result, Resources res) { + if (result.getCode() == ResultCode.INVALID_OVERWRITE) { + return res.getString(R.string.album_rename_conflict); + } + return null; + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-xxxhdpi/bg_image_albums.png b/app/src/main/res/drawable-xxxhdpi/bg_image_albums.png new file mode 100644 index 000000000000..a387dc95fe5a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/bg_image_albums.png differ diff --git a/app/src/main/res/layout/albums_fragment.xml b/app/src/main/res/layout/albums_fragment.xml new file mode 100644 index 000000000000..14469cf62188 --- /dev/null +++ b/app/src/main/res/layout/albums_fragment.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/albums_grid_item.xml b/app/src/main/res/layout/albums_grid_item.xml new file mode 100644 index 000000000000..43978abec0de --- /dev/null +++ b/app/src/main/res/layout/albums_grid_item.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/albums_list_item.xml b/app/src/main/res/layout/albums_list_item.xml new file mode 100644 index 000000000000..afaffb3879bf --- /dev/null +++ b/app/src/main/res/layout/albums_list_item.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/bottom_navigation_menu.xml b/app/src/main/res/menu/bottom_navigation_menu.xml index 74abc6dbc257..3943106dfed5 100644 --- a/app/src/main/res/menu/bottom_navigation_menu.xml +++ b/app/src/main/res/menu/bottom_navigation_menu.xml @@ -1,6 +1,7 @@ @@ -30,4 +31,10 @@ android:icon="@drawable/selector_media" android:title="@string/bottom_navigation_menu_media_label"/> + + diff --git a/app/src/main/res/menu/custom_menu_placeholder.xml b/app/src/main/res/menu/custom_menu_placeholder.xml index f84383a573de..0e85e2230553 100644 --- a/app/src/main/res/menu/custom_menu_placeholder.xml +++ b/app/src/main/res/menu/custom_menu_placeholder.xml @@ -2,12 +2,18 @@

+ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/fragment_create_album.xml b/app/src/main/res/menu/fragment_create_album.xml new file mode 100644 index 000000000000..c1397b8d652c --- /dev/null +++ b/app/src/main/res/menu/fragment_create_album.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/partial_drawer_entries.xml b/app/src/main/res/menu/partial_drawer_entries.xml index 8cb3bb93b88f..a2d73c181abc 100644 --- a/app/src/main/res/menu/partial_drawer_entries.xml +++ b/app/src/main/res/menu/partial_drawer_entries.xml @@ -3,6 +3,7 @@ ~ Nextcloud - Android Client ~ ~ SPDX-FileCopyrightText: 2016-2025 Andy Scherzinger + ~ SPDX-FileCopyrightText: 2025 TSI-mc ~ SPDX-FileCopyrightText: 2018-2024 Tobias Kaminsky ~ SPDX-FileCopyrightText: 2024 Alper Ozturk ~ SPDX-FileCopyrightText: 2024 Bhavesh Kumawat @@ -62,6 +63,11 @@ android:icon="@drawable/selector_media" android:orderInCategory="1" android:title="@string/drawer_item_gallery" /> + ~ SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors ~ SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only --> @@ -1483,4 +1484,29 @@ File upload conflicts Upload conflicts detected. Open uploads to resolve. Resolve conflicts + Album + Create album + New album + Rename album + Enter your new Album name + Album name cannot be empty + Album name cannot start with invalid char + Add more + Rename Album + Rename + Delete Album + Failed to delete few of the files. + %d Items — %s + Album already exists + Pick Album + Pick Media Files + Create Albums for your Photos + You can organize all your photos in as many albums as you like. You haven\'t created an album yet. + Add to Album + File added successfully + Unsupported media + Upload from cameraroll + Select images from account + This name is already in use. + Already exists. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b18ff7db236..b9c1c1443cee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ androidCommonLibraryVersion = "4fc0f29981" androidGifDrawableVersion = "1.2.30" androidImageCropperVersion = "4.7.0" -androidLibraryVersion ="839366c261c52437425cde740c9e5a4ab8c8bace" +androidLibraryVersion = "8d492518fdc1dad42b1b5272359be1c1adfacf51" androidPluginVersion = "9.0.0" androidsvgVersion = "1.4" androidxMediaVersion = "1.5.1" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index f6fca11c340b..57c7a3d5434c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -20092,6 +20092,17 @@ + + + + + + + +