diff --git a/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt b/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt
index 00c568d506ad..3f4fd7a3901e 100644
--- a/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt
+++ b/app/src/androidTest/java/com/owncloud/android/files/services/FileUploaderIT.kt
@@ -16,6 +16,7 @@ import com.nextcloud.client.jobs.upload.FileUploadHelper
import com.nextcloud.client.jobs.upload.FileUploadWorker
import com.nextcloud.client.network.Connectivity
import com.nextcloud.client.network.ConnectivityService
+import com.nextcloud.model.OCUploadLocalPathData
import com.owncloud.android.AbstractOnServerIT
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.UploadsStorageManager
@@ -123,7 +124,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
fun testKeepLocalAndOverwriteRemoteStatic() {
val file = getDummyFile("chunkedFile.txt")
- FileUploadHelper().uploadNewFiles(
+ val data = OCUploadLocalPathData(
user,
arrayOf(file.absolutePath),
arrayOf("/testFile.txt"),
@@ -135,6 +136,8 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
NameCollisionPolicy.DEFAULT
)
+ FileUploadHelper().uploadNewFiles(data)
+
longSleep()
val result = ReadFileRemoteOperation("/testFile.txt").execute(client)
@@ -239,7 +242,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
fun testKeepBothStatic() {
val file = getDummyFile("nonEmpty.txt")
- FileUploadHelper().uploadNewFiles(
+ val data = OCUploadLocalPathData(
user,
arrayOf(file.absolutePath),
arrayOf("/testFile.txt"),
@@ -250,6 +253,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
false,
NameCollisionPolicy.DEFAULT
)
+ FileUploadHelper().uploadNewFiles(data)
longSleep()
@@ -347,7 +351,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
fun testKeepServerStatic() {
val file = getDummyFile("chunkedFile.txt")
- FileUploadHelper().uploadNewFiles(
+ val data = OCUploadLocalPathData(
user,
arrayOf(file.absolutePath),
arrayOf("/testFile.txt"),
@@ -358,6 +362,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
false,
NameCollisionPolicy.DEFAULT
)
+ FileUploadHelper().uploadNewFiles(data)
longSleep()
@@ -451,7 +456,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
fun testKeepCancelStatic() {
val file = getDummyFile("chunkedFile.txt")
- FileUploadHelper().uploadNewFiles(
+ val data = OCUploadLocalPathData(
user,
arrayOf(file.absolutePath),
arrayOf("/testFile.txt"),
@@ -462,6 +467,7 @@ abstract class FileUploaderIT : AbstractOnServerIT() {
false,
NameCollisionPolicy.DEFAULT
)
+ FileUploadHelper().uploadNewFiles(data)
longSleep()
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7be81f621b06..409e6dc8ef1b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,7 +1,7 @@
@@ -637,6 +637,9 @@
android:launchMode="singleTop"
android:theme="@style/Theme.ownCloud.Dialog.NoTitle"
android:windowSoftInputMode="adjustResize" />
+
+ * SPDX-FileCopyrightText: 2026 TSI-mc
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.nextcloud.client.jobs
@@ -29,6 +30,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.FileUploadWorker
import com.nextcloud.client.logger.Logger
import com.nextcloud.client.network.ConnectivityService
@@ -97,6 +99,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)
@@ -259,6 +262,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 e7111715764d..5bfe06a64192 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
@@ -132,6 +133,7 @@ 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)
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 eb15648e7a26..cf9b77e227bb 100644
--- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt
+++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt
@@ -33,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.jobs.worker.WorkerFilesPayload
@@ -117,6 +118,7 @@ internal class BackgroundJobManagerImpl(
const val OFFLINE_OPERATIONS_PERIODIC_JOB_INTERVAL_MINUTES = 5L
const val DEFAULT_IMMEDIATE_JOB_DELAY_SEC = 3L
const val DEFAULT_BACKOFF_CRITERIA_DELAY_SEC = 300L
+ const val ALBUM_JOB_FILES_UPLOAD = "album_files_upload"
private const val KEEP_LOG_MILLIS = 1000 * 60 * 60 * 24 * 3L
@@ -669,6 +671,61 @@ internal class BackgroundJobManagerImpl(
}
}
+ private fun startAlbumsFileUploadJobTag(accountName: String): String = ALBUM_JOB_FILES_UPLOAD + accountName
+
+ /**
+ * 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) {
+ 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()
+ .putString(AlbumFileUploadWorker.ACCOUNT, user.accountName)
+ .putInt(AlbumFileUploadWorker.TOTAL_UPLOAD_SIZE, uploadIds.size)
+ .putString(AlbumFileUploadWorker.ALBUM_NAME, albumName)
+
+ val workRequests = batches.mapIndexed { index, batch ->
+ dataBuilder
+ .putLongArray(AlbumFileUploadWorker.UPLOAD_IDS, batch.toLongArray())
+ .putInt(AlbumFileUploadWorker.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(accountName: String, fileId: Long): String =
JOB_FOLDER_DOWNLOAD + accountName + fileId
diff --git a/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt
index 195a11b72a34..dac16b39f366 100644
--- a/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt
+++ b/app/src/main/java/com/nextcloud/client/jobs/notification/WorkerNotificationManager.kt
@@ -89,4 +89,15 @@ open class WorkerNotificationManager(
notification,
ForegroundServiceType.DataSync
)
+
+ fun createSilentNotification(title: String, iconId: Int): Notification = notificationBuilder
+ .setContentTitle(title)
+ .setSmallIcon(iconId)
+ .setOngoing(true)
+ .setSound(null)
+ .setVibrate(null)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setSilent(true)
+ .build()
}
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..f7369dc79c96
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/client/jobs/upload/AlbumFileUploadWorker.kt
@@ -0,0 +1,396 @@
+/*
+ * 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.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.utils.UploadErrorNotificationManager
+import com.nextcloud.client.network.ConnectivityService
+import com.nextcloud.client.preferences.AppPreferences
+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.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"
+
+ const val ACCOUNT = "data_account"
+ const val UPLOAD_IDS = "uploads_ids"
+ const val CURRENT_BATCH_INDEX = "batch_index"
+ const val TOTAL_UPLOAD_SIZE = "total_upload_size"
+ }
+
+ private var lastPercent = 0
+ private val notificationId = Random.nextInt()
+ private val notificationManager = UploadNotificationManager(context, viewThemeUtils, notificationId)
+ private val intents = FileUploaderIntents(context)
+ private val fileUploadEventBroadcaster = FileUploadEventBroadcaster(localBroadcastManager)
+
+ 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()
+ result
+ } catch (t: Throwable) {
+ Log_OC.e(TAG, "exception $t")
+ currentUploadFileOperation?.cancel(null)
+ Result.failure()
+ } finally {
+ // Ensure all database operations are complete before signaling completion
+ uploadsStorageManager.notifyObserversNow()
+ notificationManager.dismissNotification()
+ }
+
+ private suspend fun trySetForeground() {
+ try {
+ val notificationTitle = notificationManager.currentOperationTitle
+ ?: context.getString(R.string.foreground_service_upload)
+
+ val notification = notificationManager.createSilentNotification(notificationTitle, R.drawable.uploads)
+ 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 = notificationManager.createSilentNotification(notificationTitle, R.drawable.uploads)
+
+ return ForegroundServiceHelper.createWorkerForegroundInfo(
+ notificationId,
+ notification,
+ ForegroundServiceType.DataSync
+ )
+ }
+
+ private suspend fun updateForegroundInfo(notification: Notification) {
+ val foregroundInfo = ForegroundServiceHelper.createWorkerForegroundInfo(
+ notificationId,
+ notification,
+ ForegroundServiceType.DataSync
+ )
+ setForeground(foregroundInfo)
+ }
+
+ @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()
+ }
+
+ fileUploadEventBroadcaster.sendUploadEnqueued(context)
+ 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 isLastUpload = currentUploadIndex == totalUploadSize
+
+ val shouldBroadcast =
+ (currentUploadIndex % BATCH_SIZE == 0 && totalUploadSize > BATCH_SIZE) ||
+ isLastUpload
+
+ if (shouldBroadcast) {
+ fileUploadEventBroadcaster.sendUploadCompleted(
+ operation,
+ result,
+ context
+ )
+ }
+ }
+
+ 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}")
+ }
+ fileUploadEventBroadcaster.sendUploadStarted(operation, context)
+ } catch (e: Exception) {
+ Log_OC.e(TAG, "Error uploading", e)
+ result = RemoteOperationResult(e)
+ } finally {
+ if (!isStopped) {
+ uploadsStorageManager.updateDatabaseUploadResult(result, operation)
+ // 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,
+ onSameFileConflict = {
+ withContext(Dispatchers.Main) {
+ 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 634c60827d1c..957ecc0ebdfc 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
@@ -22,6 +22,7 @@ import com.nextcloud.client.jobs.BackgroundJobManager
import com.nextcloud.client.network.Connectivity
import com.nextcloud.client.network.ConnectivityService
import com.nextcloud.client.notifications.AppWideNotificationManager
+import com.nextcloud.model.OCUploadLocalPathData
import com.nextcloud.utils.extensions.checkWCFRestrictions
import com.nextcloud.utils.extensions.getUploadIds
import com.owncloud.android.MainApp
@@ -233,51 +234,48 @@ class FileUploadHelper {
return showNotExistMessage
}
- @JvmOverloads
@Suppress("LongParameterList")
- fun uploadNewFiles(
- user: User,
- localPaths: Array,
- remotePaths: Array,
- localBehavior: Int,
- createRemoteFolder: Boolean,
- createdBy: Int,
- requiresWifi: Boolean,
- requiresCharging: Boolean,
- nameCollisionPolicy: NameCollisionPolicy,
- showSameFileAlreadyExistsNotification: Boolean = true
- ) {
- val uploads = localPaths.mapIndexed { index, localPath ->
- fun createOCUpload(): OCUpload {
- 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
- }
+ fun uploadAndCopyNewFilesForAlbum(data: OCUploadLocalPathData, albumName: String) {
+ val uploads = getUploadsFromLocalPaths(data)
+ backgroundJobManager.startAlbumFilesUploadJob(
+ data.user,
+ uploads.getUploadIds(),
+ albumName
+ )
+ }
+ @JvmOverloads
+ fun uploadNewFiles(data: OCUploadLocalPathData, showSameFileAlreadyExistsNotification: Boolean = true) {
+ val uploads = getUploadsFromLocalPaths(data)
+ backgroundJobManager.startFilesUploadJob(
+ data.user,
+ uploads.getUploadIds(),
+ showSameFileAlreadyExistsNotification
+ )
+ }
+
+ private fun getUploadsFromLocalPaths(data: OCUploadLocalPathData): List =
+ data.localPaths.mapIndexed { index, localPath ->
+ fun createOCUpload(): OCUpload {
+ val result = data.toOCUpload(localPath, index)
val id = uploadsStorageManager.uploadDao.insertOrReplace(result.toUploadEntity())
result.uploadId = id
return result
}
+ val remotePath = data.remotePaths[index]
val entity = getUploadByPaths(
- accountName = user.accountName,
+ accountName = data.user.accountName,
localPath = localPath,
- remotePath = remotePaths[index]
+ remotePath = remotePath
)
if (entity != null) {
- val capability = fileStorageManager.getCapability(user)
+ val capability = fileStorageManager.getCapability(data.user)
entity.toOCUpload(capability) ?: createOCUpload()
} else {
createOCUpload()
}
}
- backgroundJobManager.startFilesUploadJob(user, uploads.getUploadIds(), showSameFileAlreadyExistsNotification)
- }
@Suppress("ReturnCount")
fun getUploadByPaths(accountName: String, localPath: String, remotePath: String): UploadEntity? {
diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt
index 668f01d0d394..ae2042536927 100644
--- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt
+++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt
@@ -9,7 +9,6 @@ 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
@@ -44,7 +43,6 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
import com.owncloud.android.lib.common.utils.Log_OC
import com.owncloud.android.operations.UploadFileOperation
-import com.owncloud.android.ui.notifications.NotificationUtils
import com.owncloud.android.utils.theme.ViewThemeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
@@ -152,7 +150,8 @@ class FileUploadWorker(
try {
val notificationTitle = notificationManager.currentOperationTitle
?: context.getString(R.string.foreground_service_upload)
- val notification = createNotification(notificationTitle)
+
+ val notification = notificationManager.createSilentNotification(notificationTitle, R.drawable.uploads)
updateForegroundInfo(notification)
} catch (e: Exception) {
// Continue without foreground service - uploads will still work
@@ -163,8 +162,7 @@ class FileUploadWorker(
override suspend fun getForegroundInfo(): ForegroundInfo {
val notificationTitle = notificationManager.currentOperationTitle
?: context.getString(R.string.foreground_service_upload)
- val notification = createNotification(notificationTitle)
-
+ val notification = notificationManager.createSilentNotification(notificationTitle, R.drawable.uploads)
return ForegroundServiceHelper.createWorkerForegroundInfo(
notificationId,
notification,
@@ -181,18 +179,6 @@ class FileUploadWorker(
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()
-
@Suppress("ReturnCount", "LongMethod", "DEPRECATION")
private suspend fun uploadFiles(): Result = withContext(Dispatchers.IO) {
val accountName = inputData.getString(ACCOUNT)
diff --git a/app/src/main/java/com/nextcloud/model/OCUploadLocalPathData.kt b/app/src/main/java/com/nextcloud/model/OCUploadLocalPathData.kt
new file mode 100644
index 000000000000..4e7f4f8e133e
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/model/OCUploadLocalPathData.kt
@@ -0,0 +1,121 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.model
+
+import com.nextcloud.client.account.User
+import com.nextcloud.client.jobs.upload.FileUploadWorker
+import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus
+import com.owncloud.android.db.OCUpload
+import com.owncloud.android.files.services.NameCollisionPolicy
+import com.owncloud.android.operations.UploadFileOperation
+
+data class OCUploadLocalPathData(
+ val user: User,
+ val localPaths: Array,
+ val remotePaths: Array,
+ val localBehavior: Int,
+ val createRemoteFolder: Boolean,
+ val creationType: Int,
+ val requiresWifi: Boolean,
+ val requiresCharging: Boolean,
+ val collisionPolicy: NameCollisionPolicy
+) {
+ companion object {
+ fun forDocument(user: User, localPaths: Array, remotePaths: Array): OCUploadLocalPathData =
+ OCUploadLocalPathData(
+ user,
+ localPaths,
+ remotePaths,
+ FileUploadWorker.LOCAL_BEHAVIOUR_DELETE,
+ createRemoteFolder = true,
+ UploadFileOperation.CREATED_BY_USER,
+ requiresWifi = false,
+ requiresCharging = false,
+ NameCollisionPolicy.ASK_USER
+ )
+
+ fun forAlbum(
+ user: User,
+ localPaths: Array,
+ remotePaths: Array,
+ localBehavior: Int
+ ): OCUploadLocalPathData = OCUploadLocalPathData(
+ user,
+ localPaths,
+ remotePaths,
+ localBehavior,
+ createRemoteFolder = true,
+ UploadFileOperation.CREATED_BY_USER,
+ requiresWifi = false,
+ requiresCharging = false,
+ NameCollisionPolicy.RENAME
+ )
+
+ @JvmOverloads
+ fun forFile(
+ user: User,
+ localPaths: Array,
+ remotePaths: Array,
+ localBehavior: Int,
+ createRemoteFolder: Boolean = false
+ ): OCUploadLocalPathData = OCUploadLocalPathData(
+ user,
+ localPaths,
+ remotePaths,
+ localBehavior,
+ createRemoteFolder = createRemoteFolder,
+ UploadFileOperation.CREATED_BY_USER,
+ requiresWifi = false,
+ requiresCharging = false,
+ NameCollisionPolicy.ASK_USER
+ )
+ }
+
+ fun toOCUpload(localPath: String, index: Int): OCUpload =
+ OCUpload(localPath, remotePaths[index], user.accountName).apply {
+ nameCollisionPolicy = collisionPolicy
+ isUseWifiOnly = requiresWifi
+ isWhileChargingOnly = requiresCharging
+ uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS
+ createdBy = creationType
+ isCreateRemoteFolder = createRemoteFolder
+ localAction = localBehavior
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as OCUploadLocalPathData
+
+ if (localBehavior != other.localBehavior) return false
+ if (createRemoteFolder != other.createRemoteFolder) return false
+ if (creationType != other.creationType) return false
+ if (requiresWifi != other.requiresWifi) return false
+ if (requiresCharging != other.requiresCharging) return false
+ if (user != other.user) return false
+ if (!localPaths.contentEquals(other.localPaths)) return false
+ if (!remotePaths.contentEquals(other.remotePaths)) return false
+ if (collisionPolicy != other.collisionPolicy) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = localBehavior
+ result = 31 * result + createRemoteFolder.hashCode()
+ result = 31 * result + creationType
+ result = 31 * result + requiresWifi.hashCode()
+ result = 31 * result + requiresCharging.hashCode()
+ result = 31 * result + user.hashCode()
+ result = 31 * result + localPaths.contentHashCode()
+ result = 31 * result + remotePaths.contentHashCode()
+ result = 31 * result + collisionPolicy.hashCode()
+ return result
+ }
+}
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..b5b313f895ac
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt
@@ -0,0 +1,35 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.ui.albumItemActions
+
+import com.owncloud.android.R
+
+enum class AlbumItemAction(val id: Int, val titleId: Int, val iconId: Int) {
+ UPLOAD_FROM_CAMERA_ROLL(
+ R.id.action_upload_from_camera_roll,
+ R.string.upload_direct_camera_upload,
+ R.drawable.ic_camera
+ ),
+ SELECT_IMAGES_FROM_ACCOUNT(
+ R.id.action_select_images_from_account,
+ R.string.album_upload_from_account,
+ R.drawable.file_image
+ ),
+ 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 {
+ @JvmField
+ val SORTED_VALUES = listOf(
+ UPLOAD_FROM_CAMERA_ROLL,
+ SELECT_IMAGES_FROM_ACCOUNT,
+ 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..8a31fd89355f
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt
@@ -0,0 +1,125 @@
+/*
+ * 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.titleId)
+ val drawable =
+ viewThemeUtils.platform.tintDrawable(
+ requireContext(),
+ AppCompatResources.getDrawable(requireContext(), action.iconId)!!
+ )
+ 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/nextcloud/ui/fileactions/FileActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt
index 6ebd22edf63e..fe9c1ea38954 100644
--- a/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt
+++ b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt
@@ -157,7 +157,8 @@ class FileActionsBottomSheet :
binding.thumbnailLayout.thumbnailShimmer,
syncedFolderProvider.preferences,
viewThemeUtils,
- overlayManager
+ overlayManager,
+ false
)
}
}
diff --git a/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileActionsBottomSheet.kt
index 89573ec5f443..d96e8326e55e 100644
--- a/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileActionsBottomSheet.kt
+++ b/app/src/main/java/com/nextcloud/ui/trashbinFileActions/TrashbinFileActionsBottomSheet.kt
@@ -133,7 +133,8 @@ class TrashbinFileActionsBottomSheet :
binding.thumbnailLayout.thumbnailShimmer,
syncedFolderProvider.preferences,
viewThemeUtils,
- overlayManager
+ overlayManager,
+ false
)
}
}
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 72cfadc35f03..cd3df25971e9 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 d8a645f6d98e..4353373a0f0c 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;
@@ -126,6 +130,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;
@@ -778,6 +787,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..31ac71189202
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/activity/AlbumsPickerActivity.kt
@@ -0,0 +1,221 @@
+/*
+ * 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) {
+ // FIXME: hide fab button
+ captionText = resources.getText(R.string.media_picker_toolbar_title).toString()
+ }
+
+ folderPickerBinding.run {
+ folderPickerBtnCopy.visibility = View.GONE
+ folderPickerBtnMove.visibility = View.GONE
+ folderPickerBtnChoose.visibility = View.GONE
+ folderPickerBtnCancel.visibility = View.GONE
+ chooseButtonSpacer.visibility = View.GONE
+ 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 bundle = Bundle().apply {
+ putParcelable(
+ OCFileListFragment.SEARCH_EVENT,
+ SearchEvent("image/%", SearchRemoteOperation.SearchType.PHOTO_SEARCH)
+ )
+ putBoolean(EXTRA_FROM_ALBUM, true)
+ }
+
+ val fragment = GalleryFragment().apply {
+ arguments = bundle
+ }
+
+ supportFragmentManager.beginTransaction().run {
+ add(R.id.fragment_container, fragment, TAG_LIST_OF_FOLDERS)
+ 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?) = Unit
+
+ override fun showDetails(file: OCFile?, activeTab: Int) = Unit
+
+ override fun onBrowsedDownTo(folder: OCFile?) = Unit
+
+ override fun onTransferStateChanged(file: OCFile?, downloading: Boolean, uploading: Boolean) = Unit
+
+ 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) = Unit
+
+ override fun onRefresh() = Unit
+
+ 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 cff5cb42600a..572e3b2e9899 100644
--- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java
+++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java
@@ -94,6 +94,8 @@
import com.owncloud.android.ui.events.SearchEvent;
import com.owncloud.android.ui.fragment.FileDetailsSharingProcessFragment;
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.navigation.NavigatorActivity;
import com.owncloud.android.ui.navigation.NavigatorScreen;
import com.owncloud.android.ui.trashbin.TrashbinActivity;
@@ -127,6 +129,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;
@@ -615,6 +618,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) {
@@ -699,6 +713,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
@@ -1498,13 +1514,31 @@ public boolean isToolbarStyleSearch() {
menuItemId == R.id.nav_personal_files;
}
- public boolean isMenuItemIdBelongsToSearchType() {
- int menuItemId = getSelectedMenuItemId();
+ 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 Optional getFragment(String tag, Class clazz) {
+ return Optional.ofNullable(getSupportFragmentManager().findFragmentByTag(tag))
+ .filter(clazz::isInstance)
+ .map(clazz::cast);
+ }
+
+ public boolean isAlbumItemsFragment() {
+ return getFragment(AlbumItemsFragment.Companion.getTAG(), AlbumItemsFragment.class)
+ .filter(Fragment::isVisible)
+ .isPresent();
+ }
- return menuItemId == R.id.nav_favorites ||
- menuItemId == R.id.nav_shared ||
- menuItemId == R.id.nav_on_device ||
- menuItemId == R.id.nav_recently_modified ||
- menuItemId == R.id.nav_gallery;
+ public boolean isAlbumsFragment() {
+ return getFragment(AlbumsFragment.Companion.getTAG(), AlbumsFragment.class)
+ .filter(Fragment::isVisible)
+ .isPresent();
}
}
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 280acaf45bad..f3ee30d8910e 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
@@ -91,6 +91,8 @@
import com.owncloud.android.ui.fragment.FileDetailFragment;
import com.owncloud.android.ui.fragment.FileDetailSharingFragment;
import com.owncloud.android.ui.fragment.OCFileListFragment;
+import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment;
+import com.owncloud.android.ui.fragment.albums.AlbumsFragment;
import com.owncloud.android.ui.fragment.filesRepository.FilesRepository;
import com.owncloud.android.ui.fragment.filesRepository.RemoteFilesRepository;
import com.owncloud.android.ui.helpers.FileOperationsHelper;
@@ -175,7 +177,7 @@ public abstract class FileActivity extends DrawerActivity
protected boolean isFileDisplayActivityResumed = false;
@Inject
- UserAccountManager accountManager;
+ public UserAccountManager accountManager;
@Inject public ConnectivityService connectivityService;
@@ -838,7 +840,21 @@ private void onUpdateShareInformation(RemoteOperationResult result, @StringRes i
}
public void refreshList() {
- final Fragment fragment = getSupportFragmentManager().findFragmentByTag(FileDisplayActivity.TAG_LIST_OF_FILES);
+ if (isAlbumsFragment()) {
+ getFragment(AlbumsFragment.Companion.getTAG(), AlbumsFragment.class)
+ .ifPresent(AlbumsFragment::refreshAlbums);
+ return;
+ }
+
+ if (isAlbumItemsFragment()) {
+ getFragment(AlbumItemsFragment.Companion.getTAG(), AlbumItemsFragment.class)
+ .ifPresent(AlbumItemsFragment::refreshData);
+ return;
+ }
+
+ final var fragment =
+ getSupportFragmentManager().findFragmentByTag(FileDisplayActivity.TAG_LIST_OF_FILES);
+
if (fragment instanceof OCFileListFragment listFragment) {
listFragment.onRefresh();
} else if (fragment instanceof FileDetailFragment detailFragment) {
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 b690c9ee9cd6..b1ead5901af1 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
@@ -3,7 +3,7 @@
*
* SPDX-FileCopyrightText: 2026 Philipp Hasper
* 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
@@ -74,6 +74,7 @@ import com.nextcloud.client.media.PlayerServiceConnection
import com.nextcloud.client.network.ClientFactory.CreationException
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.client.utils.IntentUtil
+import com.nextcloud.model.OCUploadLocalPathData
import com.nextcloud.model.WorkerState.OfflineOperationsCompleted
import com.nextcloud.ui.composeActivity.ComposeProcessTextAlias
import com.nextcloud.utils.extensions.getParcelableArgument
@@ -91,11 +92,13 @@ import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.datamodel.VirtualFolderType
-import com.owncloud.android.files.services.NameCollisionPolicy
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
@@ -108,7 +111,7 @@ import com.owncloud.android.operations.RefreshFolderOperation
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
@@ -133,6 +136,9 @@ 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.AlbumOperationListener
+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
@@ -199,7 +205,9 @@ class FileDisplayActivity :
private val folderDownloadStartedReceiver = FolderDownloadStartedReceiver()
private val folderDownloadCompletedReceiver = FolderDownloadCompletedReceiver()
- private var mLastSslUntrustedServerResult: RemoteOperationResult<*>? = null
+ private lateinit var albumOperationListener: AlbumOperationListener
+
+ var mLastSslUntrustedServerResult: RemoteOperationResult<*>? = null
private var mWaitingToPreview: OCFile? = null
@@ -267,6 +275,7 @@ class FileDisplayActivity :
super.onCreate(savedInstanceState)
lastDisplayedAccountName = preferences.lastDisplayedAccountName
+ albumOperationListener = AlbumOperationListener(this)
folderRefreshScheduler = FolderRefreshScheduler(this)
intent?.let {
@@ -596,7 +605,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()
}
@@ -611,6 +622,12 @@ class FileDisplayActivity :
supportFragmentManager.executePendingTransactions()
}
+ ALBUMS == action -> {
+ Log_OC.d(this, "Switch to list albums fragment")
+ replaceAlbumFragment()
+ supportFragmentManager.executePendingTransactions()
+ }
+
ON_DEVICE == action -> {
refreshOrInitOCFileListFragment()
listOfFilesFragment?.setCurrentSearchType(SearchType.ON_DEVICE)
@@ -976,7 +993,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
@@ -1109,19 +1127,15 @@ class FileDisplayActivity :
return@isNetworkAndServerAvailable
}
- FileUploadHelper.instance().uploadNewFiles(
- user.orElseThrow(
- Supplier { RuntimeException() }
- ),
+ val data = OCUploadLocalPathData.forFile(
+ user.orElseThrow(Supplier { RuntimeException() }),
filePaths,
decryptedRemotePaths,
behaviour,
- true,
- UploadFileOperation.CREATED_BY_USER,
- false,
- false,
- NameCollisionPolicy.ASK_USER
+ createRemoteFolder = true
)
+
+ FileUploadHelper.instance().uploadNewFiles(data)
}
} else {
fileDataStorageManager.addCreateFileOfflineOperation(filePaths, decryptedRemotePaths)
@@ -1237,6 +1251,13 @@ class FileDisplayActivity :
after()
}
+ // pop back if current fragment is AlbumItemsFragment
+ isAlbumItemsFragment -> {
+ before()
+ popBack()
+ after()
+ }
+
leftFragment is OCFileListFragment -> {
before()
handleOCFileListFragmentBackPress()
@@ -1374,7 +1395,12 @@ class FileDisplayActivity :
if (ocFileListFragment?.isSearchFragment == true) {
ocFileListFragment?.setSearchArgs(ocFileListFragment?.arguments)
}
- highlightNavigationViewItem(menuItemId)
+ if (isAlbumsFragment || isAlbumItemsFragment) {
+ highlightNavigationViewItem(R.id.nav_album)
+ } else {
+ highlightNavigationViewItem(menuItemId)
+ }
+
if (SettingsActivity.isBackPressed) {
Log_OC.d(TAG, "User returned from settings activity, skipping reset content logic")
@@ -1752,6 +1778,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()
+ }
}
}
@@ -2118,6 +2151,22 @@ class FileDisplayActivity :
is RestoreFileVersionRemoteOperation -> {
onRestoreFileVersionOperationFinish(result)
}
+
+ is CreateNewAlbumRemoteOperation -> {
+ albumOperationListener.onCreateAlbumOperationFinish(operation, result)
+ }
+
+ is CopyFileToAlbumOperation -> {
+ albumOperationListener.onCopyAlbumFileOperationFinish(operation, result)
+ }
+
+ is RenameAlbumRemoteOperation -> {
+ albumOperationListener.onRenameAlbumOperationFinish(operation, result)
+ }
+
+ is RemoveAlbumRemoteOperation -> {
+ albumOperationListener.onRemoveAlbumOperationFinish(operation, result)
+ }
}
}
@@ -2779,7 +2828,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 {
@@ -3145,6 +3197,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/activity/ReceiveExternalFilesActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivity.java
index d3a03f7a7ef5..86046baa27ba 100755
--- a/app/src/main/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivity.java
+++ b/app/src/main/java/com/owncloud/android/ui/activity/ReceiveExternalFilesActivity.java
@@ -53,6 +53,7 @@
import com.nextcloud.client.jobs.upload.FileUploadHelper;
import com.nextcloud.client.jobs.upload.FileUploadWorker;
import com.nextcloud.client.preferences.AppPreferences;
+import com.nextcloud.model.OCUploadLocalPathData;
import com.nextcloud.utils.extensions.BundleExtensionsKt;
import com.nextcloud.utils.extensions.FileExtensionsKt;
import com.nextcloud.utils.extensions.IntentExtensionsKt;
@@ -1013,16 +1014,16 @@ private boolean somethingToUpload() {
}
public void uploadFile(String tmpName, String filename) {
- FileUploadHelper.Companion.instance().uploadNewFiles(
- getUser().orElseThrow(RuntimeException::new),
- new String[]{ tmpName },
- new String[]{ mFile.getRemotePath() + filename},
- FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
- true,
- UploadFileOperation.CREATED_BY_USER,
- false,
- false,
- NameCollisionPolicy.ASK_USER);
+ final var data = new OCUploadLocalPathData(getUser().orElseThrow(RuntimeException::new),
+ new String[]{ tmpName },
+ new String[]{ mFile.getRemotePath() + filename},
+ FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
+ true,
+ UploadFileOperation.CREATED_BY_USER,
+ false,
+ false,
+ NameCollisionPolicy.ASK_USER);
+ FileUploadHelper.Companion.instance().uploadNewFiles(data);
finish();
}
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 864e856999c5..4ead38289eab 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
@@ -226,6 +226,16 @@ class GalleryAdapter(
)
}
+ @SuppressLint("NotifyDataSetChanged")
+ fun showAlbumItems(albumItems: List) {
+ files = albumItems.toGalleryItems()
+ notifyDataSetChanged()
+ }
+
+ fun setCheckedItem(files: Set?) {
+ ocFileListDelegate.setCheckedItem(files)
+ }
+
// Set Image/Video List According to Selection of Hide/Show Image/Video
@SuppressLint("NotifyDataSetChanged")
private fun setMediaFilter(
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 72e619f869b0..d4ec30017fb0 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
@@ -33,6 +34,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
@@ -160,8 +163,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) {
@@ -193,7 +204,8 @@ class OCFileListDelegate(
shimmerThumbnail,
preferences,
viewThemeUtils,
- overlayManager
+ overlayManager,
+ false
)
}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchCurrentDirItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchCurrentDirItemViewHolder.kt
index d34f33c80b08..3976495112c7 100644
--- a/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchCurrentDirItemViewHolder.kt
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchCurrentDirItemViewHolder.kt
@@ -10,13 +10,11 @@ package com.owncloud.android.ui.adapter
import android.content.Context
import android.view.View
import com.afollestad.sectionedrecyclerview.SectionedViewHolder
-import com.nextcloud.android.common.ui.theme.utils.ColorRole
import com.nextcloud.client.account.User
import com.nextcloud.client.preferences.AppPreferences
import com.owncloud.android.databinding.UnifiedSearchCurrentDirectoryItemBinding
import com.owncloud.android.datamodel.FileDataStorageManager
import com.owncloud.android.datamodel.OCFile
-import com.owncloud.android.datamodel.SyncedFolderProvider
import com.owncloud.android.ui.interfaces.UnifiedSearchCurrentDirItemAction
import com.owncloud.android.utils.DisplayUtils
import com.owncloud.android.utils.FileStorageUtils
@@ -61,7 +59,8 @@ class UnifiedSearchCurrentDirItemViewHolder(
binding.thumbnailShimmer,
appPreferences,
viewThemeUtils,
- overlayManager
+ overlayManager,
+ false
)
binding.more.setOnClickListener {
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchItemViewHolder.kt
index 9d9412fd5244..03353ebf878a 100644
--- a/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchItemViewHolder.kt
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/UnifiedSearchItemViewHolder.kt
@@ -127,7 +127,8 @@ class UnifiedSearchItemViewHolder(
user,
preferences,
context,
- viewThemeUtils
+ viewThemeUtils,
+ false
)
}
}
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..152a3d3137ac
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 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..19c8e7114d38
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt
@@ -0,0 +1,18 @@
+/*
+ * 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 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..8797bf3aa9ef
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 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..9f7ab0e00325
--- /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.overlay.OverlayManager
+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 overlayManager: OverlayManager,
+ 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,
+ null,
+ preferences,
+ viewThemeUtils,
+ overlayManager,
+ true
+ )
+ } else {
+ gridViewHolder.thumbnail.setImageResource(R.drawable.file_image)
+ gridViewHolder.thumbnail.visibility = View.VISIBLE
+ }
+
+ 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.kt b/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.kt
index 686514501a46..c9bb66062a1a 100644
--- a/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.kt
+++ b/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.kt
@@ -13,11 +13,10 @@ import android.provider.DocumentsContract
import android.widget.Toast
import com.nextcloud.client.account.User
import com.nextcloud.client.jobs.upload.FileUploadHelper
+import com.nextcloud.model.OCUploadLocalPathData
import com.owncloud.android.R
-import com.owncloud.android.files.services.NameCollisionPolicy
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.utils.FileStorageUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -32,9 +31,9 @@ import java.lang.ref.WeakReference
class CopyAndUploadContentUrisTask(
listener: OnCopyTmpFilesTaskListener?,
context: Context,
- private val scope: CoroutineScope
+ private val scope: CoroutineScope,
+ private val albumName: String?
) {
-
companion object {
private const val TAG = "CopyAndUploadContentUrisTask"
}
@@ -107,17 +106,23 @@ class CopyAndUploadContentUrisTask(
currentTempPath = null
}
- FileUploadHelper.instance().uploadNewFiles(
- user,
- localPaths.requireNoNulls(),
- resolvedRemotePaths.requireNoNulls(),
- behaviour,
- false,
- UploadFileOperation.CREATED_BY_USER,
- false,
- false,
- NameCollisionPolicy.ASK_USER
- )
+ if (albumName.isNullOrEmpty()) {
+ val data = OCUploadLocalPathData.forFile(
+ user,
+ localPaths.requireNoNulls(),
+ resolvedRemotePaths.requireNoNulls(),
+ behaviour
+ )
+ FileUploadHelper.instance().uploadNewFiles(data)
+ } else {
+ val data = OCUploadLocalPathData.forAlbum(
+ user,
+ localPaths.requireNoNulls(),
+ resolvedRemotePaths.requireNoNulls(),
+ behaviour
+ )
+ FileUploadHelper.instance().uploadAndCopyNewFilesForAlbum(data, albumName)
+ }
ResultCode.OK
} catch (e: FileNotFoundException) {
diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/ConflictsResolveDialog.kt b/app/src/main/java/com/owncloud/android/ui/dialog/ConflictsResolveDialog.kt
index db515bb7aa30..70e85e3b3a51 100644
--- a/app/src/main/java/com/owncloud/android/ui/dialog/ConflictsResolveDialog.kt
+++ b/app/src/main/java/com/owncloud/android/ui/dialog/ConflictsResolveDialog.kt
@@ -236,7 +236,8 @@ class ConflictsResolveDialog :
null,
syncedFolderProvider.preferences,
viewThemeUtils,
- overlayManager
+ overlayManager,
+ false
)
}
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/dialog/CreateFolderDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/CreateFolderDialogFragment.kt
index ef2d0e8e12a0..447b9862d7eb 100644
--- a/app/src/main/java/com/owncloud/android/ui/dialog/CreateFolderDialogFragment.kt
+++ b/app/src/main/java/com/owncloud/android/ui/dialog/CreateFolderDialogFragment.kt
@@ -122,8 +122,8 @@ class CreateFolderDialogFragment :
}
binding.userInput.addTextChangedListener(object : TextWatcher {
- override fun afterTextChanged(s: Editable) {}
- override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
+ override fun afterTextChanged(s: Editable) = Unit
+ override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
checkFileNameAfterEachType(fileNames)
}
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 8eec8a5aefa1..814fa5d1a219 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 3f6574233840..45cf4d5299dc 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,13 @@ public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
searchFragment = true;
- setHasOptionsMenu(true);
+ final var arguments = getArguments();
+ if (arguments != null) {
+ isFromAlbum = arguments.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);
@@ -407,6 +429,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;
@@ -433,4 +460,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 177b994e9168..2d18de6c4556 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
@@ -83,6 +83,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;
@@ -868,6 +869,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;
}
@@ -904,6 +916,8 @@ 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 && OCFileListFragment.this instanceof GalleryFragment galleryFragment) {
+ galleryFragment.addImagesToAlbum(checkedFiles);
}
return true;
}
@@ -2256,6 +2270,14 @@ public void setFabVisible(final boolean visible) {
return;
}
+ // to hide the fab if user is on Albums Fragment
+ if (getActivity() 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..094b8836d769
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt
@@ -0,0 +1,1187 @@
+/*
+ * 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.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 android.widget.RelativeLayout
+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.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.google.android.material.floatingactionbutton.FloatingActionButton
+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.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 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)
+
+ private lateinit var addMediaFab: FloatingActionButton
+
+ 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()
+
+ createAddMediaButton()
+
+ // 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 createAddMediaButton() {
+ addMediaFab = FloatingActionButton(requireContext()).apply {
+ id = View.generateViewId()
+ setImageResource(R.drawable.ic_plus)
+ contentDescription = getString(R.string.add_media)
+
+ viewThemeUtils.material.themeFAB(this)
+
+ setOnClickListener {
+ openAlbumActionsMenu()
+ }
+ }
+
+ val layoutParams = RelativeLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ addRule(RelativeLayout.ALIGN_PARENT_END)
+ addRule(RelativeLayout.ALIGN_PARENT_BOTTOM)
+
+ marginEnd = resources.getDimensionPixelSize(R.dimen.standard_margin)
+ bottomMargin = resources.getDimensionPixelSize(R.dimen.bottom_navigation_view_margin)
+ }
+
+ binding.listFragmentLayout.addView(addMediaFab, layoutParams)
+ }
+
+ private fun showAppBar() {
+ getTypedActivity(FileDisplayActivity::class.java)?.let {
+ val appBarLayout = it.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 addFromCameraRoll() {
+ val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
+ type = "*/*"
+ putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
+ addCategory(Intent.CATEGORY_OPENABLE)
+ }
+
+ selectMediaFromAppsLauncher.launch(
+ Intent.createChooser(intent, getString(R.string.upload_chooser_title))
+ )
+ }
+
+ private fun createMenu() {
+ val menuHost: MenuHost = requireActivity()
+ menuHost.addMenuProvider(
+ object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menu.clear()
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = true
+ },
+ 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) {
+ R.id.action_upload_from_camera_roll -> {
+ addFromCameraRoll()
+ true
+ }
+
+ R.id.action_select_images_from_account -> {
+ openGalleryToAddMedia()
+ true
+ }
+
+ R.id.action_rename_file -> {
+ CreateAlbumDialogFragment.newInstance(albumName)
+ .show(
+ requireActivity().supportFragmentManager,
+ CreateAlbumDialogFragment.TAG
+ )
+ true
+ }
+
+ 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()
+ getTypedActivity(FileDisplayActivity::class.java)?.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()
+ getTypedActivity(FileDisplayActivity::class.java)?.run {
+ setupToolbar()
+ supportActionBar?.let { actionBar ->
+ viewThemeUtils.files.themeActionBar(requireContext(), actionBar, albumName)
+ }
+ showSortListGroup(false)
+ setMainFabVisible(false)
+
+ // clear the subtitle while navigating to any other screen from Media screen
+ 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?) = Unit
+
+ override fun showShareDetailView(file: OCFile?) = Unit
+
+ override fun showActivityDetailView(file: OCFile?) = Unit
+
+ override fun onOverflowIconClicked(file: OCFile?, view: View?) = Unit
+
+ override fun onItemClicked(file: OCFile) {
+ if (adapter?.isMultiSelect() == true) {
+ toggleItemToCheckedList(file)
+ return
+ }
+
+ val activity = mContainerActivity as FileDisplayActivity
+
+ when {
+ PreviewImageFragment.canBePreviewed(file) -> {
+ activity.startImagePreview(
+ file,
+ !file.isDown,
+ VirtualFolderType.ALBUM
+ )
+ }
+
+ file.isDown && canBePreviewed(file) -> {
+ activity.startMediaPreview(file, 0, true, true, false, true)
+ }
+
+ file.isDown -> {
+ mContainerActivity
+ ?.getFileOperationsHelper()
+ ?.openFile(file)
+ }
+
+ canBePreviewed(file) && !file.isEncrypted -> {
+ activity.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() = Unit
+
+ fun onAlbumRenamed(newAlbumName: String) {
+ albumName = newAlbumName
+ getTypedActivity(FileDisplayActivity::class.java)?.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?) = Unit
+ override fun onCancel(callerTag: String?) = Unit
+ }
+ )
+ 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) {
+ getTypedActivity(FileDisplayActivity::class.java)?.run {
+ if (isShow) {
+ showLoadingDialog(resources.getString(R.string.wait_a_moment))
+ } else {
+ 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) = Unit
+
+ override fun onDrawerOpened(drawerView: View) = Unit
+
+ /**
+ * 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) = Unit
+
+ /**
+ * 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 (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)
+ }
+
+ private val selectMediaFromAppsLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ result.data?.let { intent ->
+ requestUploadOfContentFromApps(intent)
+ }
+ }
+ }
+
+ // 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)}/"
+
+ getTypedActivity(FileDisplayActivity::class.java)?.let {
+ val optionalUser = it.user
+ if (optionalUser.isEmpty) {
+ return
+ }
+
+ val uploader = UriUploader(
+ activity = it,
+ urisToUpload = streamsToUpload,
+ uploadPath = remotePath,
+ user = optionalUser.get(),
+ behaviour = FileUploadWorker.LOCAL_BEHAVIOUR_COPY,
+ showWaitingDialog = false, // Not show waiting dialog while file is being copied from private storage
+ copyTmpTaskListener = null, // Not needed copy temp task listener,
+ fileDisplayNameTransformer = null,
+ albumName = albumName,
+ )
+
+ uploader.uploadUris()
+ }
+ }
+
+ companion object {
+ val TAG: String = AlbumItemsFragment::class.java.simpleName
+
+ 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 =
+ AlbumItemsFragment().apply {
+ arguments = Bundle().apply {
+ putString(ARG_ALBUM_NAME, albumName)
+ putBoolean(ARG_IS_NEW_ALBUM, isNewAlbum)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumOperationListener.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumOperationListener.kt
new file mode 100644
index 000000000000..dc77887643cf
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumOperationListener.kt
@@ -0,0 +1,111 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Alper Ozturk
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.fragment.albums
+
+import android.content.res.Resources
+import com.owncloud.android.R
+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.operations.albums.CopyFileToAlbumOperation
+import com.owncloud.android.ui.activity.FileDisplayActivity
+import com.owncloud.android.utils.DisplayUtils
+import com.owncloud.android.utils.ErrorMessageAdapter
+
+class AlbumOperationListener(private val activity: FileDisplayActivity) {
+
+ companion object {
+ private const val TAG = "AlbumOperationListener"
+ }
+
+ fun onRemoveAlbumOperationFinish(operation: RemoveAlbumRemoteOperation, result: RemoteOperationResult<*>) {
+ if (result.isSuccess) {
+ val fragment = activity.supportFragmentManager.findFragmentByTag(AlbumItemsFragment.TAG)
+ if (fragment is AlbumItemsFragment) {
+ fragment.onAlbumDeleted()
+ }
+ } else {
+ showErrorMessage(operation, result)
+ showUntrustedCertDialog(result)
+ }
+ }
+
+ fun onCopyAlbumFileOperationFinish(operation: CopyFileToAlbumOperation, result: RemoteOperationResult<*>) {
+ if (result.isSuccess) {
+ // when item added from inside of Album
+ val fragment = activity.supportFragmentManager.findFragmentByTag(AlbumItemsFragment.TAG)
+ if (fragment is AlbumItemsFragment) {
+ fragment.refreshData()
+ } else {
+ // files added directly from Media tab
+ DisplayUtils.showSnackMessage(
+ activity,
+ activity.getResources().getString(R.string.album_file_added_message)
+ )
+ }
+ Log_OC.e(TAG, "Files copied successfully")
+ } else {
+ try {
+ showErrorMessage(operation, result)
+ } catch (e: Resources.NotFoundException) {
+ Log_OC.e(TAG, "Error while trying to show fail message ", e)
+ }
+ }
+ }
+
+ fun onRenameAlbumOperationFinish(operation: RenameAlbumRemoteOperation, result: RemoteOperationResult<*>) {
+ if (result.isSuccess) {
+ val fragment = activity.supportFragmentManager.findFragmentByTag(AlbumItemsFragment.TAG)
+ if (fragment is AlbumItemsFragment) {
+ fragment.onAlbumRenamed(operation.newAlbumName)
+ }
+ } else {
+ showErrorMessage(operation, result)
+ showUntrustedCertDialog(result)
+ }
+ }
+
+ fun onCreateAlbumOperationFinish(
+ operation: CreateNewAlbumRemoteOperation,
+ result: RemoteOperationResult<*>
+ ) {
+ if (result.isSuccess) {
+ val fragment = activity.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(activity, R.string.album_already_exists)
+ } else {
+ showErrorMessage(operation, result)
+ }
+ } catch (e: Resources.NotFoundException) {
+ Log_OC.e(TAG, "Error while trying to show fail message ", e)
+ }
+ }
+ }
+
+ private fun showUntrustedCertDialog(result: RemoteOperationResult<*>) {
+ if (result.isSslRecoverableException) {
+ activity.mLastSslUntrustedServerResult = result
+ activity.showUntrustedCertDialog(activity.mLastSslUntrustedServerResult)
+ }
+ }
+
+ private fun showErrorMessage(operation: RemoteOperation<*>, result: RemoteOperationResult<*>) {
+ DisplayUtils.showSnackMessage(
+ activity,
+ ErrorMessageAdapter.getErrorCauseMessage(result, operation, activity.getResources())
+ )
+ }
+}
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..dbdcc26ccd81
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt
@@ -0,0 +1,351 @@
+/*
+ * 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.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+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.UserAccountManager
+import com.nextcloud.client.di.Injectable
+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.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.BaseActivity
+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.overlay.OverlayManager
+import com.owncloud.android.utils.theme.ViewThemeUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+class AlbumsFragment :
+ Fragment(),
+ AlbumFragmentInterface,
+ Injectable {
+
+ private var adapter: AlbumsAdapter? = null
+
+ private lateinit var binding: AlbumsFragmentBinding
+
+ @Inject
+ lateinit var viewThemeUtils: ViewThemeUtils
+
+ @Inject
+ lateinit var accountManager: UserAccountManager
+
+ @Inject
+ lateinit var preferences: AppPreferences
+
+ @Inject
+ lateinit var overlayManager: OverlayManager
+
+ @Inject
+ lateinit var throttler: Throttler
+
+ private var containerActivity: 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 {
+ containerActivity = 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() {
+ containerActivity = 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)
+ showAppBar()
+ setupContainingList()
+ setupContent()
+ createMenu()
+
+ viewThemeUtils.material.themeFAB(binding.addMediaFab)
+ binding.addMediaFab.setOnClickListener {
+ showCreateAlbumDialog()
+ }
+ }
+
+ private fun showAppBar() {
+ if (requireActivity() is FileDisplayActivity) {
+ val appBarLayout = requireActivity().findViewById(R.id.appbar)
+ appBarLayout?.setExpanded(true, false)
+ }
+ }
+
+ 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()
+ }
+ }
+
+ fun populateList(albums: List?) {
+ Log_OC.d(TAG, "loading album list item size: " + albums?.size)
+ (activity as? FileDisplayActivity)?.setMainFabVisible(false)
+ initializeAdapter()
+ adapter?.setAlbumItems(albums)
+ }
+
+ private fun fetchAndSetData() {
+ binding.swipeContainingList.isRefreshing = true
+ initializeAdapter()
+ updateEmptyView(false)
+ readAlbums()
+ }
+
+ private fun readAlbums() {
+ val activity = activity ?: return
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ if (activity is BaseActivity) {
+ val client = activity.clientRepository.getOwncloudClient()
+ val operation = ReadAlbumsRemoteOperation()
+ val result = operation.execute(client)
+
+ withContext(Dispatchers.Main) {
+ if (result?.isSuccess == true && result.resultData != null) {
+ if (result.resultData.isEmpty()) {
+ updateEmptyView(true)
+ }
+ populateList(result.resultData)
+ } else {
+ Log_OC.d(TAG, "read album operation failed")
+ updateEmptyView(true)
+ }
+
+ hideRefreshLayoutLoader()
+ }
+ }
+ }
+ }
+
+ private fun createMenu() {
+ val menuHost: MenuHost = requireActivity()
+ menuHost.addMenuProvider(
+ object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menu.clear()
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = true
+ },
+ viewLifecycleOwner,
+ Lifecycle.State.RESUMED
+ )
+ }
+
+ private fun hideRefreshLayoutLoader() {
+ binding.swipeContainingList.isRefreshing = false
+ }
+
+ private fun initializeAdapter() {
+ if (adapter == null) {
+ adapter = AlbumsAdapter(
+ requireContext(),
+ containerActivity?.storageManager,
+ accountManager.user,
+ this,
+ overlayManager,
+ 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))
+ }
+
+ (activity as? FileDisplayActivity)?.run {
+ setupToolbar()
+ supportActionBar?.let { actionBar ->
+ viewThemeUtils.files.themeActionBar(
+ requireContext(),
+ actionBar,
+ R.string.drawer_item_album,
+ isMenu = true
+ )
+ }
+ showSortListGroup(false)
+ setMainFabVisible(false)
+
+ // clear the subtitle while navigating to any other screen from Media screen
+ 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 fa9e575e802f..87bfe437d296 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
@@ -1062,6 +1062,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 3ff603c2768e..220fb7c5911c 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
@@ -17,10 +17,9 @@ import androidx.core.util.Function
import androidx.lifecycle.lifecycleScope
import com.nextcloud.client.account.User
import com.nextcloud.client.jobs.upload.FileUploadHelper
+import com.nextcloud.model.OCUploadLocalPathData
import com.owncloud.android.R
-import com.owncloud.android.files.services.NameCollisionPolicy
import com.owncloud.android.lib.common.utils.Log_OC
-import com.owncloud.android.operations.UploadFileOperation
import com.owncloud.android.ui.activity.FileActivity
import com.owncloud.android.ui.asynctasks.CopyAndUploadContentUrisTask
import com.owncloud.android.ui.asynctasks.CopyAndUploadContentUrisTask.OnCopyTmpFilesTaskListener
@@ -43,17 +42,18 @@ import com.owncloud.android.utils.UriUtils.getDisplayNameForUri
"Detekt.LongParameterList",
"Detekt.SpreadOperator",
"Detekt.TooGenericExceptionCaught"
-) // legacy code
+)
class UriUploader @JvmOverloads constructor(
- private val mActivity: FileActivity,
- private val mUrisToUpload: List,
- private val mUploadPath: String,
+ private val activity: FileActivity,
+ private val urisToUpload: List,
+ private val uploadPath: String,
private val user: User,
- private val mBehaviour: Int,
- private val mShowWaitingDialog: Boolean,
- private val mCopyTmpTaskListener: OnCopyTmpFilesTaskListener?,
+ private val behaviour: Int,
+ private val showWaitingDialog: Boolean,
+ private val copyTmpTaskListener: OnCopyTmpFilesTaskListener?,
/** If non-null, this function is called to determine the desired display name (i.e. filename) after upload**/
- private val mFileDisplayNameTransformer: Function? = null
+ private val fileDisplayNameTransformer: Function? = null,
+ private var albumName: String? = null
) {
enum class UriUploaderResultCode {
@@ -68,14 +68,14 @@ class UriUploader @JvmOverloads constructor(
fun uploadUris(): UriUploaderResultCode {
var code = UriUploaderResultCode.OK
try {
- val anySensitiveUri = mUrisToUpload
+ val anySensitiveUri = urisToUpload
.filterNotNull()
.any { isSensitiveUri((it as Uri)) }
if (anySensitiveUri) {
Log_OC.e(TAG, "Sensitive URI detected, aborting upload.")
code = UriUploaderResultCode.ERROR_SENSITIVE_PATH
} else {
- val uris = mUrisToUpload
+ val uris = urisToUpload
.filterNotNull()
.map { it as Uri }
.map { Pair(it, getRemotePathForUri(it)) }
@@ -117,13 +117,13 @@ class UriUploader @JvmOverloads constructor(
}
private fun getRemotePathForUri(sourceUri: Uri): String {
- val displayName = mFileDisplayNameTransformer?.apply(sourceUri)
- ?: getDisplayNameForUri(sourceUri, mActivity)
+ val displayName = fileDisplayNameTransformer?.apply(sourceUri)
+ ?: getDisplayNameForUri(sourceUri, activity)
require(displayName != null) { "Display name cannot be null" }
- return mUploadPath + displayName
+ return uploadPath + displayName
}
- private fun isSensitiveUri(uri: Uri): Boolean = uri.toString().contains(mActivity.packageName)
+ private fun isSensitiveUri(uri: Uri): Boolean = uri.toString().contains(activity.packageName)
/**
* Requests the upload of a file in the local file system to [FileUploadHelper] service.
@@ -137,18 +137,15 @@ class UriUploader @JvmOverloads constructor(
* @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
- )
+ FileUploadHelper.instance().run {
+ if (albumName.isNullOrEmpty()) {
+ val data = OCUploadLocalPathData.forFile(user, localPaths, remotePaths, behaviour)
+ uploadNewFiles(data)
+ } else {
+ val data = OCUploadLocalPathData.forAlbum(user, localPaths, remotePaths, behaviour)
+ uploadAndCopyNewFilesForAlbum(data, albumName!!)
+ }
+ }
}
/**
@@ -157,11 +154,12 @@ class UriUploader @JvmOverloads constructor(
* @param remotePaths Array of absolute paths to set to the uploaded files
*/
private fun copyThenUpload(sourceUris: Array, remotePaths: Array) {
- if (mShowWaitingDialog) {
- mActivity.showLoadingDialog(mActivity.resources.getString(R.string.wait_for_tmp_copy_from_private_storage))
+ if (showWaitingDialog) {
+ activity.showLoadingDialog(activity.resources.getString(R.string.wait_for_tmp_copy_from_private_storage))
}
- val copyTask = CopyAndUploadContentUrisTask(mCopyTmpTaskListener, mActivity, mActivity.lifecycleScope)
- val fm = mActivity.supportFragmentManager
+ val copyTask =
+ CopyAndUploadContentUrisTask(copyTmpTaskListener, activity, activity.lifecycleScope, albumName)
+ val fm = activity.supportFragmentManager
// Init Fragment without UI to retain AsyncTask across configuration changes
val taskRetainerFragment =
@@ -171,8 +169,8 @@ class UriUploader @JvmOverloads constructor(
user,
sourceUris,
remotePaths,
- mBehaviour,
- mActivity.contentResolver
+ behaviour,
+ activity.contentResolver
)
}
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 3fd3acc3874e..ce6444411991 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
@@ -51,6 +53,7 @@ import com.nextcloud.ui.fileactions.FileAction
import com.nextcloud.ui.fileactions.FileActionsBottomSheet.Companion.newInstance
import com.nextcloud.utils.extensions.clickWithDebounce
import com.nextcloud.utils.extensions.getParcelableArgument
+import com.nextcloud.utils.extensions.typedActivity
import com.owncloud.android.MainApp
import com.owncloud.android.R
import com.owncloud.android.databinding.PreviewImageFragmentBinding
@@ -59,6 +62,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 +73,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
@@ -361,17 +368,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
}
@@ -383,6 +380,57 @@ 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) {
+ typedActivity()?.showLoadingDialog(getString(R.string.wait_a_moment))
+ val context = context ?: return
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ val operation = FetchRemoteFileOperation(
+ context,
+ accountManager.user,
+ ocFile,
+ removeFileFromDb = true,
+ storageManager = containerActivity.storageManager
+ )
+ val result = operation.execute(context)
+
+ withContext(Dispatchers.Main) {
+ typedActivity()?.dismissLoadingDialog()
+
+ if (result?.isSuccess == true && result.resultData != null) {
+ file = result.resultData as OCFile
+ onOverflowClick(isManualClick = true)
+ } else {
+ Log_OC.d(TAG, result?.logMessage)
+ DisplayUtils.showSnackMessage(binding.root, result.getLogMessage(context))
+ }
+ }
+ }
+ }
+
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 abc808e6f1eb..e3a9ce9a0a7d 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
@@ -84,6 +84,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
@@ -530,17 +531,54 @@ 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 operation = FetchRemoteFileOperation(
+ this@PreviewMediaActivity,
+ accountManager.user,
+ ocFile,
+ removeFileFromDb = true,
+ storageManager = storageManager
+ )
+ val result = operation.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)
+ 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 5a19c90b77c3..68423473d79b 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
@@ -68,11 +68,13 @@ 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
@@ -346,13 +348,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
}
@@ -365,6 +361,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 a711c974492f..c4b31b70e8f7 100644
--- a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java
+++ b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java
@@ -778,7 +778,8 @@ public static void setThumbnail(OCFile file,
LoaderImageView shimmerThumbnail,
AppPreferences preferences,
ViewThemeUtils viewThemeUtils,
- OverlayManager overlayManager) {
+ OverlayManager overlayManager,
+ boolean hideVideoOverlay) {
if (file == null || thumbnailView == null || context == null) {
return;
}
@@ -794,16 +795,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;
}
@@ -834,10 +835,10 @@ private static void setThumbnailForOfflineOperation(OCFile file, ImageView thumb
}
}
- public static void setThumbnailFromCache(OCFile file, ImageView thumbnailView, FileDataStorageManager storageManager, List asyncTasks, boolean gridView, LoaderImageView shimmerThumbnail, User user, AppPreferences preferences, Context context, ViewThemeUtils viewThemeUtils) {
+ public 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;
}
@@ -845,8 +846,12 @@ public static void setThumbnailFromCache(OCFile file, ImageView thumbnailView, F
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);
}
@@ -870,7 +875,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;
}
@@ -901,7 +907,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 a2b028d0ecb7..7927e0df76c0 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;
@@ -149,6 +152,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;
@@ -507,4 +514,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/drawable/ic_album.xml b/app/src/main/res/drawable/ic_album.xml
new file mode 100644
index 000000000000..2897c2926ff4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_album.xml
@@ -0,0 +1,15 @@
+
+
+
+
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..448b20ab0773
--- /dev/null
+++ b/app/src/main/res/layout/albums_fragment.xml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..b89959e9ddb6
--- /dev/null
+++ b/app/src/main/res/layout/albums_grid_item.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/layout/bottom_sheet_actions.xml b/app/src/main/res/layout/bottom_sheet_actions.xml
new file mode 100644
index 000000000000..921abf178f45
--- /dev/null
+++ b/app/src/main/res/layout/bottom_sheet_actions.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_action_list.xml b/app/src/main/res/layout/item_action_list.xml
new file mode 100644
index 000000000000..a777c7933282
--- /dev/null
+++ b/app/src/main/res/layout/item_action_list.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/app/src/main/res/menu/bottom_navigation_menu.xml b/app/src/main/res/menu/bottom_navigation_menu.xml
index 74abc6dbc257..f68e66c84954 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 @@