diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e58ae55fb6b4..f1c478adc9bd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -589,6 +589,9 @@
android:launchMode="singleTop"
android:theme="@style/Theme.ownCloud.Dialog.NoTitle"
android:windowSoftInputMode="adjustResize" />
+
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.ui.albumItemActions
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.IdRes
+import androidx.annotation.StringRes
+import com.owncloud.android.R
+
+enum class AlbumItemAction(@IdRes val id: Int, @StringRes val title: Int, @DrawableRes val icon: Int? = null) {
+ RENAME_ALBUM(R.id.action_rename_file, R.string.album_rename, R.drawable.ic_edit),
+ DELETE_ALBUM(R.id.action_delete, R.string.album_delete, R.drawable.ic_delete);
+
+ companion object {
+ /**
+ * All file actions, in the order they should be displayed
+ */
+ @JvmField
+ val SORTED_VALUES = listOf(
+ RENAME_ALBUM,
+ DELETE_ALBUM
+ )
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt
new file mode 100644
index 000000000000..0d697077b931
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt
@@ -0,0 +1,127 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.ui.albumItemActions
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.IdRes
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.os.bundleOf
+import androidx.core.view.isEmpty
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.setFragmentResult
+import androidx.lifecycle.LifecycleOwner
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.nextcloud.android.common.ui.theme.utils.ColorRole
+import com.nextcloud.client.di.Injectable
+import com.owncloud.android.databinding.FileActionsBottomSheetBinding
+import com.owncloud.android.databinding.FileActionsBottomSheetItemBinding
+import com.owncloud.android.utils.theme.ViewThemeUtils
+import javax.inject.Inject
+
+class AlbumItemActionsBottomSheet : BottomSheetDialogFragment(), Injectable {
+
+ @Inject
+ lateinit var viewThemeUtils: ViewThemeUtils
+
+ private var _binding: FileActionsBottomSheetBinding? = null
+ val binding
+ get() = _binding!!
+
+ fun interface ResultListener {
+ fun onResult(@IdRes actionId: Int)
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ _binding = FileActionsBottomSheetBinding.inflate(inflater, container, false)
+
+ val bottomSheetDialog = dialog as BottomSheetDialog
+ bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ bottomSheetDialog.behavior.skipCollapsed = true
+
+ viewThemeUtils.platform.colorViewBackground(binding.bottomSheet, ColorRole.SURFACE)
+
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.bottomSheetHeader.visibility = View.GONE
+ binding.bottomSheetLoading.visibility = View.GONE
+ displayActions()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ fun setResultListener(
+ fragmentManager: FragmentManager,
+ lifecycleOwner: LifecycleOwner,
+ listener: ResultListener
+ ): AlbumItemActionsBottomSheet {
+ fragmentManager.setFragmentResultListener(REQUEST_KEY, lifecycleOwner) { _, result ->
+ @IdRes val actionId = result.getInt(RESULT_KEY_ACTION_ID, -1)
+ if (actionId != -1) {
+ listener.onResult(actionId)
+ }
+ }
+ return this
+ }
+
+ private fun displayActions() {
+ if (binding.fileActionsList.isEmpty()) {
+ AlbumItemAction.SORTED_VALUES.forEach { action ->
+ val view = inflateActionView(action)
+ binding.fileActionsList.addView(view)
+ }
+ }
+ }
+
+ private fun inflateActionView(action: AlbumItemAction): View {
+ val itemBinding = FileActionsBottomSheetItemBinding.inflate(layoutInflater, binding.fileActionsList, false)
+ .apply {
+ root.setOnClickListener {
+ dispatchActionClick(action.id)
+ }
+ text.setText(action.title)
+ if (action.icon != null) {
+ val drawable =
+ viewThemeUtils.platform.tintDrawable(
+ requireContext(),
+ AppCompatResources.getDrawable(requireContext(), action.icon)!!
+ )
+ icon.setImageDrawable(drawable)
+ }
+ }
+ return itemBinding.root
+ }
+
+ private fun dispatchActionClick(id: Int?) {
+ if (id != null) {
+ setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY_ACTION_ID to id))
+ parentFragmentManager.clearFragmentResultListener(REQUEST_KEY)
+ dismiss()
+ }
+ }
+
+ companion object {
+ private const val REQUEST_KEY = "REQUEST_KEY_ACTION"
+ private const val RESULT_KEY_ACTION_ID = "RESULT_KEY_ACTION_ID"
+
+ @JvmStatic
+ fun newInstance(): AlbumItemActionsBottomSheet {
+ return AlbumItemActionsBottomSheet()
+ }
+ }
+}
diff --git a/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java b/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java
index 1259b706a2b1..ffc68007e616 100644
--- a/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java
+++ b/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java
@@ -12,5 +12,5 @@
* Type for virtual folders
*/
public enum VirtualFolderType {
- FAVORITE, GALLERY, NONE
+ FAVORITE, GALLERY, ALBUM, NONE
}
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumOperation.kt
new file mode 100644
index 000000000000..5c1d5b4f8fe7
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumOperation.kt
@@ -0,0 +1,76 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package com.owncloud.android.operations.albums
+
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
+import com.owncloud.android.operations.UploadFileOperation
+import com.owncloud.android.operations.common.SyncOperation
+
+/**
+ * Constructor
+ *
+ * @param srcPath Remote path of the [OCFile] to move.
+ * @param targetParentPath Path to the folder where the file will be copied into.
+ */
+class CopyFileToAlbumOperation(
+ private val srcPath: String,
+ private var targetParentPath: String,
+ storageManager: FileDataStorageManager
+) :
+ SyncOperation(storageManager) {
+ init {
+ if (!targetParentPath.endsWith(OCFile.PATH_SEPARATOR)) {
+ this.targetParentPath += OCFile.PATH_SEPARATOR
+ }
+ }
+
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("NestedBlockDepth")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ /** 1. check copy validity */
+ val result: RemoteOperationResult
+
+ if (targetParentPath.startsWith(srcPath)) {
+ result = RemoteOperationResult(ResultCode.INVALID_COPY_INTO_DESCENDANT)
+ } else {
+ val file = storageManager.getFileByPath(srcPath)
+ if (file == null) {
+ result = RemoteOperationResult(ResultCode.FILE_NOT_FOUND)
+ } else {
+ /** 2. remote copy */
+ var targetPath = "$targetParentPath${file.fileName}"
+ if (file.isFolder) {
+ targetPath += OCFile.PATH_SEPARATOR
+ }
+
+ // auto rename, to allow copy
+ if (targetPath == srcPath) {
+ if (file.isFolder) {
+ targetPath = "$targetParentPath${file.fileName}"
+ }
+ targetPath = UploadFileOperation.getNewAvailableRemotePath(client, targetPath, null, false)
+
+ if (file.isFolder) {
+ targetPath += OCFile.PATH_SEPARATOR
+ }
+ }
+
+ result = CopyFileToAlbumRemoteOperation(srcPath, targetPath).execute(client)
+ }
+ }
+ return result
+ }
+}
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumRemoteOperation.kt
new file mode 100644
index 000000000000..f2fd99d6945b
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumRemoteOperation.kt
@@ -0,0 +1,157 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2021 Tobias Kaminsky
+ * SPDX-FileCopyrightText: 2019 Andy Scherzinger
+ * SPDX-FileCopyrightText: 2012-2014 ownCloud Inc.
+ * SPDX-FileCopyrightText: 2014 Jorge Antonio Diaz-Benito Soriano
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+package com.owncloud.android.operations.albums
+
+import android.util.Log
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.DavException
+import org.apache.jackrabbit.webdav.Status
+import org.apache.jackrabbit.webdav.client.methods.CopyMethod
+import java.io.IOException
+
+/**
+ * Remote operation moving a remote file or folder in the ownCloud server to a different folder
+ * in the same account.
+ *
+ *
+ * Allows renaming the moving file/folder at the same time.
+ */
+class CopyFileToAlbumRemoteOperation @JvmOverloads constructor(
+ private val mSrcRemotePath: String,
+ private val mTargetRemotePath: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+) :
+ RemoteOperation() {
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ /** check parameters */
+
+ var result: RemoteOperationResult
+ if (mTargetRemotePath == mSrcRemotePath) {
+ // nothing to do!
+ result = RemoteOperationResult(ResultCode.OK)
+ } else if (mTargetRemotePath.startsWith(mSrcRemotePath)) {
+ result = RemoteOperationResult(ResultCode.INVALID_COPY_INTO_DESCENDANT)
+ } else {
+ /** perform remote operation */
+ var copyMethod: CopyMethod? = null
+ try {
+ copyMethod = CopyMethod(
+ client.getFilesDavUri(this.mSrcRemotePath),
+ "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums${
+ WebdavUtils.encodePath(
+ mTargetRemotePath
+ )
+ }",
+ false
+ )
+ val status = client.executeMethod(
+ copyMethod,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+
+ /** process response */
+ result = when (status) {
+ HttpStatus.SC_MULTI_STATUS -> processPartialError(copyMethod)
+ HttpStatus.SC_PRECONDITION_FAILED -> {
+ client.exhaustResponse(copyMethod.responseBodyAsStream)
+ RemoteOperationResult(ResultCode.INVALID_OVERWRITE)
+ }
+
+ else -> {
+ client.exhaustResponse(copyMethod.responseBodyAsStream)
+ RemoteOperationResult(isSuccess(status), copyMethod)
+ }
+ }
+
+ Log.i(
+ TAG,
+ "Copy $mSrcRemotePath to $mTargetRemotePath : ${result.logMessage}"
+ )
+ } catch (e: Exception) {
+ result = RemoteOperationResult(e)
+ Log.e(
+ TAG,
+ "Copy $mSrcRemotePath to $mTargetRemotePath : ${result.logMessage}", e
+ )
+ } finally {
+ copyMethod?.releaseConnection()
+ }
+ }
+
+ return result
+ }
+
+ /**
+ * Analyzes a multistatus response from the OC server to generate an appropriate result.
+ *
+ *
+ * In WebDAV, a COPY request on collections (folders) can be PARTIALLY successful: some
+ * children are copied, some other aren't.
+ *
+ *
+ * According to the WebDAV specification, a multistatus response SHOULD NOT include partial
+ * successes (201, 204) nor for descendants of already failed children (424) in the response
+ * entity. But SHOULD NOT != MUST NOT, so take carefully.
+ *
+ * @param copyMethod Copy operation just finished with a multistatus response
+ * @return A result for the [CopyFileToAlbumRemoteOperation] caller
+ * @throws java.io.IOException If the response body could not be parsed
+ * @throws org.apache.jackrabbit.webdav.DavException If the status code is other than MultiStatus or if obtaining
+ * the response XML document fails
+ */
+ @Throws(IOException::class, DavException::class)
+ private fun processPartialError(copyMethod: CopyMethod): RemoteOperationResult {
+ // Adding a list of failed descendants to the result could be interesting; or maybe not.
+ // For the moment, let's take the easy way.
+ /** check that some error really occurred */
+
+ val responses = copyMethod.responseBodyAsMultiStatus.responses
+ var status: Array?
+ var failFound = false
+ var i = 0
+ while (i < responses.size && !failFound) {
+ status = responses[i].status
+ failFound = (!status.isNullOrEmpty() && status[0].statusCode > FAILED_STATUS_CODE
+ )
+ i++
+ }
+ val result: RemoteOperationResult = if (failFound) {
+ RemoteOperationResult(ResultCode.PARTIAL_COPY_DONE)
+ } else {
+ RemoteOperationResult(true, copyMethod)
+ }
+
+ return result
+ }
+
+ private fun isSuccess(status: Int): Boolean {
+ return status == HttpStatus.SC_CREATED || status == HttpStatus.SC_NO_CONTENT
+ }
+
+ companion object {
+ private val TAG: String = CopyFileToAlbumRemoteOperation::class.java.simpleName
+ private const val FAILED_STATUS_CODE = 299
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/CreateNewAlbumRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/CreateNewAlbumRemoteOperation.kt
new file mode 100644
index 000000000000..34c4742652d8
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/CreateNewAlbumRemoteOperation.kt
@@ -0,0 +1,76 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2021 Tobias Kaminsky
+ * SPDX-FileCopyrightText: 2019 Andy Scherzinger
+ * SPDX-FileCopyrightText: 2015 ownCloud Inc.
+ * SPDX-FileCopyrightText: 2015 María Asensio Valverde
+ * SPDX-FileCopyrightText: 2014 David A. Velasco
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+package com.owncloud.android.operations.albums
+
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.client.methods.MkColMethod
+
+class CreateNewAlbumRemoteOperation
+ @JvmOverloads
+ constructor(
+ val newAlbumName: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+ ) : RemoteOperation() {
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ var mkCol: MkColMethod? = null
+ var result: RemoteOperationResult
+ try {
+ mkCol =
+ MkColMethod(
+ "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums${
+ WebdavUtils.encodePath(
+ newAlbumName
+ )
+ }"
+ )
+ client.executeMethod(
+ mkCol,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+ if (HttpStatus.SC_METHOD_NOT_ALLOWED == mkCol.statusCode) {
+ result =
+ RemoteOperationResult(RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS)
+ } else {
+ result = RemoteOperationResult(mkCol.succeeded(), mkCol)
+ result.resultData = null
+ }
+
+ Log_OC.d(TAG, "Create album $newAlbumName : ${result.logMessage}")
+ client.exhaustResponse(mkCol.responseBodyAsStream)
+ } catch (e: Exception) {
+ result = RemoteOperationResult(e)
+ Log_OC.e(TAG, "Create album $newAlbumName : ${result.logMessage}", e)
+ } finally {
+ mkCol?.releaseConnection()
+ }
+
+ return result
+ }
+
+ companion object {
+ private val TAG: String = CreateNewAlbumRemoteOperation::class.java.simpleName
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/PhotoAlbumEntry.kt b/app/src/main/java/com/owncloud/android/operations/albums/PhotoAlbumEntry.kt
new file mode 100644
index 000000000000..3f0b6fe31c9b
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/PhotoAlbumEntry.kt
@@ -0,0 +1,101 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.operations.albums
+
+import com.owncloud.android.lib.common.network.WebdavEntry
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.MultiStatusResponse
+import org.apache.jackrabbit.webdav.property.DavPropertyName
+import org.apache.jackrabbit.webdav.property.DavPropertySet
+import org.apache.jackrabbit.webdav.xml.Namespace
+import org.json.JSONException
+import org.json.JSONObject
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class PhotoAlbumEntry(
+ response: MultiStatusResponse
+) {
+ val href: String
+ val lastPhoto: Long
+ val nbItems: Int
+ val location: String?
+ private val dateRange: String?
+
+ companion object {
+ private val dateFormat = SimpleDateFormat("MMM yyyy", Locale.US)
+ private const val MILLIS = 1000L
+ private const val PROPERTY_LAST_PHOTO = "last-photo"
+ private const val PROPERTY_NB_ITEMS = "nbItems"
+ private const val PROPERTY_LOCATION = "location"
+ private const val PROPERTY_DATE_RANGE = "dateRange"
+ private const val PROPERTY_COLLABORATORS = "collaborators"
+ }
+
+ init {
+
+ href = response.href
+
+ val properties = response.getProperties(HttpStatus.SC_OK)
+
+ this.lastPhoto = parseLong(parseString(properties, PROPERTY_LAST_PHOTO))
+ this.nbItems = parseInt(parseString(properties, PROPERTY_NB_ITEMS))
+ this.location = parseString(properties, PROPERTY_LOCATION)
+ this.dateRange = parseString(properties, PROPERTY_DATE_RANGE)
+ }
+
+ private fun parseString(
+ props: DavPropertySet,
+ name: String
+ ): String? {
+ val propName = DavPropertyName.create(name, Namespace.getNamespace("nc", WebdavEntry.NAMESPACE_NC))
+ val prop = props[propName]
+ return if (prop != null && prop.value != null) prop.value.toString() else null
+ }
+
+ private fun parseInt(value: String?): Int =
+ try {
+ value?.toInt() ?: 0
+ } catch (_: NumberFormatException) {
+ 0
+ }
+
+ private fun parseLong(value: String?): Long =
+ try {
+ value?.toLong() ?: 0L
+ } catch (_: NumberFormatException) {
+ 0L
+ }
+
+ val albumName: String
+ get() {
+ return href
+ .removeSuffix("/")
+ .substringAfterLast("/")
+ .takeIf { it.isNotEmpty() } ?: ""
+ }
+
+ val createdDate: String
+ get() {
+ val currentDate = Date(System.currentTimeMillis())
+
+ return try {
+ val obj = JSONObject(dateRange ?: return dateFormat.format(currentDate))
+ val startTimestamp = obj.optLong("start", 0)
+ if (startTimestamp > 0) {
+ dateFormat.format(Date(startTimestamp * MILLIS))
+ } else {
+ dateFormat.format(currentDate)
+ }
+ } catch (e: JSONException) {
+ e.printStackTrace()
+ dateFormat.format(currentDate)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/ReadAlbumItemsRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/ReadAlbumItemsRemoteOperation.kt
new file mode 100644
index 000000000000..b715524d008a
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/ReadAlbumItemsRemoteOperation.kt
@@ -0,0 +1,118 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2021 Tobias Kaminsky
+ * SPDX-FileCopyrightText: 2019 Andy Scherzinger
+ * SPDX-FileCopyrightText: 2015 ownCloud Inc.
+ * SPDX-FileCopyrightText: 2015 María Asensio Valverde
+ * SPDX-FileCopyrightText: 2014 David A. Velasco
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+package com.owncloud.android.operations.albums
+
+import androidx.core.net.toUri
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavEntry
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.lib.resources.files.model.RemoteFile
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.DavConstants
+import org.apache.jackrabbit.webdav.MultiStatus
+import org.apache.jackrabbit.webdav.client.methods.PropFindMethod
+
+class ReadAlbumItemsRemoteOperation
+@JvmOverloads
+constructor(
+ private val mRemotePath: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+) : RemoteOperation>() {
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult> {
+ var result: RemoteOperationResult>? = null
+ var query: PropFindMethod? = null
+ val url = "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums${
+ WebdavUtils.encodePath(
+ mRemotePath
+ )
+ }"
+ try {
+ // remote request
+ query =
+ PropFindMethod(
+ url,
+ WebdavUtils.getAllPropSet(), // PropFind Properties
+ DavConstants.DEPTH_1
+ )
+ val status =
+ client.executeMethod(
+ query,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+
+ // check and process response
+ val isSuccess = (status == HttpStatus.SC_MULTI_STATUS || status == HttpStatus.SC_OK)
+
+ result =
+ if (isSuccess) {
+ // get data from remote folder
+ val dataInServer = query.responseBodyAsMultiStatus
+ val mFolderAndFiles = readAlbumData(dataInServer, client)
+
+ // Result of the operation
+ RemoteOperationResult>(true, query).apply {
+ // Add data to the result
+ resultData = mFolderAndFiles
+ }
+ } else {
+ // synchronization failed
+ client.exhaustResponse(query.responseBodyAsStream)
+ RemoteOperationResult(false, query)
+ }
+ } catch (e: Exception) {
+ result = RemoteOperationResult(e)
+ } finally {
+ query?.releaseConnection()
+
+ result = result ?: RemoteOperationResult>(Exception("unknown error")).also {
+ Log_OC.e(TAG, "Synchronized $mRemotePath: failed")
+ }
+ if (result.isSuccess) {
+ Log_OC.i(TAG, "Synchronized $mRemotePath : ${result.logMessage}")
+ } else if (result.isException) {
+ Log_OC.e(TAG, "Synchronized $mRemotePath : ${result.logMessage}", result.exception)
+ } else {
+ Log_OC.e(TAG, "Synchronized $mRemotePath : ${result.logMessage}")
+ }
+ }
+
+ return result
+ }
+
+ companion object {
+ private val TAG: String = ReadAlbumItemsRemoteOperation::class.java.simpleName
+
+ private fun readAlbumData(remoteData: MultiStatus, client: OwnCloudClient): List {
+ val baseUrl = "${client.baseUri}/remote.php/dav/photos/${client.userId}"
+ val encodedPath = baseUrl.toUri().encodedPath ?: return emptyList()
+
+ val files = mutableListOf()
+ val responses = remoteData.responses
+
+ // reading from 1 as 0th item will be just the root album path
+ for (i in 1..
+ * SPDX-FileCopyrightText: 2019 Andy Scherzinger
+ * SPDX-FileCopyrightText: 2015 ownCloud Inc.
+ * SPDX-FileCopyrightText: 2015 María Asensio Valverde
+ * SPDX-FileCopyrightText: 2014 David A. Velasco
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+package com.owncloud.android.operations.albums
+
+import android.text.TextUtils
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavEntry
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.DavConstants
+import org.apache.jackrabbit.webdav.client.methods.PropFindMethod
+import org.apache.jackrabbit.webdav.property.DavPropertyName
+import org.apache.jackrabbit.webdav.property.DavPropertyNameSet
+import org.apache.jackrabbit.webdav.xml.Namespace
+
+class ReadAlbumsRemoteOperation
+@JvmOverloads
+constructor(
+ private val mAlbumRemotePath: String? = null,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+) : RemoteOperation>() {
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult> {
+ var propfind: PropFindMethod? = null
+ var result: RemoteOperationResult>
+ var url = "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums"
+ if (!TextUtils.isEmpty(mAlbumRemotePath)) {
+ url += WebdavUtils.encodePath(mAlbumRemotePath)
+ }
+ try {
+ propfind = PropFindMethod(url, getAlbumPropSet(), DavConstants.DEPTH_1)
+ val status =
+ client.executeMethod(
+ propfind,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+ val isSuccess = status == HttpStatus.SC_MULTI_STATUS || status == HttpStatus.SC_OK
+ if (isSuccess) {
+ val albumsList =
+ propfind.responseBodyAsMultiStatus.responses
+ .filter { it.status[0].statusCode == HttpStatus.SC_OK }
+ .map { res -> PhotoAlbumEntry(res) }
+ result = RemoteOperationResult>(true, propfind)
+ result.resultData = albumsList
+ } else {
+ result = RemoteOperationResult>(false, propfind)
+ client.exhaustResponse(propfind.responseBodyAsStream)
+ }
+ } catch (e: Exception) {
+ result = RemoteOperationResult>(e)
+ Log_OC.e(TAG, "Read album failed: ${result.logMessage}", result.exception)
+ } finally {
+ propfind?.releaseConnection()
+ }
+
+ return result
+ }
+
+ companion object {
+ private val TAG: String = ReadAlbumsRemoteOperation::class.java.simpleName
+ private const val MILLIS = 1000L
+ private const val PROPERTY_LAST_PHOTO = "last-photo"
+ private const val PROPERTY_NB_ITEMS = "nbItems"
+ private const val PROPERTY_LOCATION = "location"
+ private const val PROPERTY_DATE_RANGE = "dateRange"
+ private const val PROPERTY_COLLABORATORS = "collaborators"
+
+ private fun getAlbumPropSet(): DavPropertyNameSet {
+ val propertySet = DavPropertyNameSet()
+ val ncNamespace: Namespace = Namespace.getNamespace("nc", WebdavEntry.NAMESPACE_NC)
+
+ propertySet.add(DavPropertyName.create(PROPERTY_LAST_PHOTO, ncNamespace))
+ propertySet.add(DavPropertyName.create(PROPERTY_NB_ITEMS, ncNamespace))
+ propertySet.add(DavPropertyName.create(PROPERTY_LOCATION, ncNamespace))
+ propertySet.add(DavPropertyName.create(PROPERTY_DATE_RANGE, ncNamespace))
+ propertySet.add(DavPropertyName.create(PROPERTY_COLLABORATORS, ncNamespace))
+
+ return propertySet
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumFileRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumFileRemoteOperation.kt
new file mode 100644
index 000000000000..6cda4b02ece5
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumFileRemoteOperation.kt
@@ -0,0 +1,63 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package com.owncloud.android.operations.albums
+
+import android.net.Uri
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+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.RemoveFileRemoteOperation
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.client.methods.DeleteMethod
+
+class RemoveAlbumFileRemoteOperation
+ @JvmOverloads
+ constructor(
+ private val mRemotePath: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+ ) : RemoteOperation() {
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ var result: RemoteOperationResult
+ var delete: DeleteMethod? = null
+ val webDavUrl = "${client.davUri}/photos/"
+ val encodedPath = ("${client.userId}${Uri.encode(this.mRemotePath)}").replace("%2F", "/")
+ val fullFilePath = "$webDavUrl$encodedPath"
+
+ try {
+ delete = DeleteMethod(fullFilePath)
+ val status =
+ client.executeMethod(
+ delete,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+ delete.responseBodyAsString
+ result =
+ RemoteOperationResult(
+ delete.succeeded() || status == HttpStatus.SC_NOT_FOUND,
+ delete
+ )
+ Log_OC.i(TAG, "Remove ${this.mRemotePath} : ${result.logMessage}")
+ } catch (e: Exception) {
+ result = RemoteOperationResult(e)
+ Log_OC.e(TAG, "Remove ${this.mRemotePath} : ${result.logMessage}", e)
+ } finally {
+ delete?.releaseConnection()
+ }
+
+ return result
+ }
+
+ companion object {
+ private val TAG: String = RemoveFileRemoteOperation::class.java.simpleName
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumRemoteOperation.kt
new file mode 100644
index 000000000000..17f29035ab3b
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/RemoveAlbumRemoteOperation.kt
@@ -0,0 +1,75 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2021 Tobias Kaminsky
+ * SPDX-FileCopyrightText: 2019 Andy Scherzinger
+ * SPDX-FileCopyrightText: 2015 ownCloud Inc.
+ * SPDX-FileCopyrightText: 2015 María Asensio Valverde
+ * SPDX-FileCopyrightText: 2014 David A. Velasco
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+package com.owncloud.android.operations.albums
+
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.client.methods.DeleteMethod
+
+class RemoveAlbumRemoteOperation
+ @JvmOverloads
+ constructor(
+ private val albumName: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+ ) : RemoteOperation() {
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ var result: RemoteOperationResult
+ var delete: DeleteMethod? = null
+
+ try {
+ delete =
+ DeleteMethod(
+ "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums${
+ WebdavUtils.encodePath(
+ albumName
+ )
+ }"
+ )
+ val status =
+ client.executeMethod(
+ delete,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+ delete.responseBodyAsString
+ result =
+ RemoteOperationResult(
+ delete.succeeded() || status == HttpStatus.SC_NOT_FOUND,
+ delete
+ )
+ Log_OC.i(TAG, "Remove ${this.albumName} : ${result.logMessage}")
+ } catch (e: Exception) {
+ result = RemoteOperationResult(e)
+ Log_OC.e(TAG, "Remove ${this.albumName} : ${result.logMessage}", e)
+ } finally {
+ delete?.releaseConnection()
+ }
+
+ return result
+ }
+
+ companion object {
+ private val TAG: String = RemoveAlbumRemoteOperation::class.java.simpleName
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/RenameAlbumRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/RenameAlbumRemoteOperation.kt
new file mode 100644
index 000000000000..0eaede8e66fc
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/RenameAlbumRemoteOperation.kt
@@ -0,0 +1,81 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2021 Tobias Kaminsky
+ * SPDX-FileCopyrightText: 2019 Andy Scherzinger
+ * SPDX-FileCopyrightText: 2015 ownCloud Inc.
+ * SPDX-FileCopyrightText: 2015 María Asensio Valverde
+ * SPDX-FileCopyrightText: 2014 David A. Velasco
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+package com.owncloud.android.operations.albums
+
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavUtils
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import org.apache.jackrabbit.webdav.client.methods.MoveMethod
+
+class RenameAlbumRemoteOperation
+ @JvmOverloads
+ constructor(
+ private val mOldRemotePath: String,
+ val newAlbumName: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+ ) : RemoteOperation() {
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("TooGenericExceptionCaught")
+ override fun run(client: OwnCloudClient): RemoteOperationResult? {
+ var result: RemoteOperationResult? = null
+ var move: MoveMethod? = null
+ val url = "${client.baseUri}/remote.php/dav/photos/${client.userId}/albums"
+ try {
+ if (this.newAlbumName != this.mOldRemotePath) {
+ move =
+ MoveMethod(
+ "$url${WebdavUtils.encodePath(mOldRemotePath)}",
+ "$url${
+ WebdavUtils.encodePath(
+ newAlbumName
+ )
+ }",
+ true
+ )
+ client.executeMethod(
+ move,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+ result = RemoteOperationResult(move.succeeded(), move)
+ Log_OC.i(
+ TAG,
+ "Rename ${this.mOldRemotePath} to ${this.newAlbumName} : ${result.logMessage}"
+ )
+ client.exhaustResponse(move.responseBodyAsStream)
+ }
+ } catch (e: Exception) {
+ result = RemoteOperationResult(e)
+ Log_OC.e(
+ TAG,
+ "Rename ${this.mOldRemotePath} to ${this.newAlbumName} : ${result.logMessage}",
+ e
+ )
+ } finally {
+ move?.releaseConnection()
+ }
+
+ return result
+ }
+
+ companion object {
+ private val TAG: String = RenameAlbumRemoteOperation::class.java.simpleName
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/ToggleAlbumFavoriteRemoteOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/ToggleAlbumFavoriteRemoteOperation.kt
new file mode 100644
index 000000000000..f0ac424662e5
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/ToggleAlbumFavoriteRemoteOperation.kt
@@ -0,0 +1,76 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package com.owncloud.android.operations.albums
+
+import android.net.Uri
+import com.nextcloud.common.SessionTimeOut
+import com.nextcloud.common.defaultSessionTimeOut
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.network.WebdavEntry
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import org.apache.commons.httpclient.HttpStatus
+import org.apache.jackrabbit.webdav.client.methods.PropPatchMethod
+import org.apache.jackrabbit.webdav.property.DavPropertyNameSet
+import org.apache.jackrabbit.webdav.property.DavPropertySet
+import org.apache.jackrabbit.webdav.property.DefaultDavProperty
+import org.apache.jackrabbit.webdav.xml.Namespace
+import java.io.IOException
+
+class ToggleAlbumFavoriteRemoteOperation
+ @JvmOverloads
+ constructor(
+ private val makeItFavorited: Boolean,
+ private val filePath: String,
+ private val sessionTimeOut: SessionTimeOut = defaultSessionTimeOut
+ ) : RemoteOperation() {
+ @Deprecated("Deprecated in Java")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ var result: RemoteOperationResult
+ var propPatchMethod: PropPatchMethod? = null
+ val newProps = DavPropertySet()
+ val removeProperties = DavPropertyNameSet()
+ if (this.makeItFavorited) {
+ val favoriteProperty =
+ DefaultDavProperty(
+ "oc:favorite",
+ "1",
+ Namespace.getNamespace(WebdavEntry.NAMESPACE_OC)
+ )
+ newProps.add(favoriteProperty)
+ } else {
+ removeProperties.add("oc:favorite", Namespace.getNamespace(WebdavEntry.NAMESPACE_OC))
+ }
+
+ val webDavUrl = "${client.davUri}/photos/"
+ val encodedPath = ("${client.userId}${Uri.encode(this.filePath)}").replace("%2F", "/")
+ val fullFilePath = "$webDavUrl$encodedPath"
+
+ try {
+ propPatchMethod = PropPatchMethod(fullFilePath, newProps, removeProperties)
+ val status =
+ client.executeMethod(
+ propPatchMethod,
+ sessionTimeOut.readTimeOut,
+ sessionTimeOut.connectionTimeOut
+ )
+ val isSuccess = (status == HttpStatus.SC_MULTI_STATUS || status == HttpStatus.SC_OK)
+ if (isSuccess) {
+ result = RemoteOperationResult(true, status, propPatchMethod.responseHeaders)
+ } else {
+ client.exhaustResponse(propPatchMethod.responseBodyAsStream)
+ result = RemoteOperationResult(false, status, propPatchMethod.responseHeaders)
+ }
+ } catch (e: IOException) {
+ result = RemoteOperationResult(e)
+ } finally {
+ propPatchMethod?.releaseConnection()
+ }
+
+ return result
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/owncloud/android/services/OperationsService.java b/app/src/main/java/com/owncloud/android/services/OperationsService.java
index d2b8a9c75dae..40988f07dae5 100644
--- a/app/src/main/java/com/owncloud/android/services/OperationsService.java
+++ b/app/src/main/java/com/owncloud/android/services/OperationsService.java
@@ -64,6 +64,10 @@
import com.owncloud.android.operations.UpdateShareInfoOperation;
import com.owncloud.android.operations.UpdateSharePermissionsOperation;
import com.owncloud.android.operations.UpdateShareViaLinkOperation;
+import com.owncloud.android.operations.albums.CopyFileToAlbumOperation;
+import com.owncloud.android.operations.albums.CreateNewAlbumRemoteOperation;
+import com.owncloud.android.operations.albums.RemoveAlbumRemoteOperation;
+import com.owncloud.android.operations.albums.RenameAlbumRemoteOperation;
import java.io.IOException;
import java.util.Optional;
@@ -123,6 +127,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;
@@ -758,6 +767,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..fd2e31b1a542
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/activity/AlbumsPickerActivity.kt
@@ -0,0 +1,218 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.activity
+
+import android.content.Intent
+import android.content.res.Resources
+import android.os.Bundle
+import android.view.MenuItem
+import android.view.View
+import androidx.fragment.app.FragmentActivity
+import com.nextcloud.client.di.Injectable
+import com.owncloud.android.R
+import com.owncloud.android.databinding.FilesFolderPickerBinding
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.lib.resources.files.SearchRemoteOperation
+import com.owncloud.android.operations.albums.CreateNewAlbumRemoteOperation
+import com.owncloud.android.ui.activity.FolderPickerActivity.Companion.TAG_LIST_OF_FOLDERS
+import com.owncloud.android.ui.events.SearchEvent
+import com.owncloud.android.ui.fragment.FileFragment
+import com.owncloud.android.ui.fragment.GalleryFragment
+import com.owncloud.android.ui.fragment.OCFileListFragment
+import com.owncloud.android.ui.fragment.albums.AlbumsFragment
+import com.owncloud.android.utils.DisplayUtils
+import com.owncloud.android.utils.ErrorMessageAdapter
+
+class AlbumsPickerActivity : FileActivity(), FileFragment.ContainerActivity, OnEnforceableRefreshListener, Injectable {
+
+ private var captionText: String? = null
+
+ private var action: String? = null
+
+ private lateinit var folderPickerBinding: FilesFolderPickerBinding
+
+ private fun initBinding() {
+ folderPickerBinding = FilesFolderPickerBinding.inflate(layoutInflater)
+ setContentView(folderPickerBinding.root)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ Log_OC.d(TAG, "onCreate() start")
+
+ super.onCreate(savedInstanceState)
+
+ initBinding()
+ setupToolbar()
+ setupActionBar()
+ setupAction()
+
+ 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?.setDisplayHomeAsUpEnabled(true)
+ }
+
+ private fun setupAction() {
+ action = intent.getStringExtra(EXTRA_ACTION)
+ setupUIForChooseButton()
+ }
+
+ private fun setupUIForChooseButton() {
+ if (action == CHOOSE_ALBUM) {
+ captionText = resources.getText(R.string.album_picker_toolbar_title).toString()
+ } else if (action == CHOOSE_MEDIA_FILES) {
+ captionText = resources.getText(R.string.media_picker_toolbar_title).toString()
+ }
+
+ folderPickerBinding.folderPickerBtnCopy.visibility = View.GONE
+ folderPickerBinding.folderPickerBtnMove.visibility = View.GONE
+ folderPickerBinding.folderPickerBtnChoose.visibility = View.GONE
+ folderPickerBinding.folderPickerBtnCancel.visibility = View.GONE
+ folderPickerBinding.chooseButtonSpacer.visibility = View.GONE
+ folderPickerBinding.moveOrCopyButtonSpacer.visibility = View.GONE
+ }
+
+ private fun createFragments() {
+ if (action == CHOOSE_ALBUM) {
+ val transaction = supportFragmentManager.beginTransaction()
+ transaction.add(
+ R.id.fragment_container,
+ AlbumsFragment.newInstance(isSelectionMode = true),
+ AlbumsFragment.TAG
+ )
+ transaction.commit()
+ } else if (action == CHOOSE_MEDIA_FILES) {
+ createGalleryFragment()
+ }
+ }
+
+ private fun createGalleryFragment() {
+ val photoFragment = GalleryFragment()
+ val bundle = Bundle()
+ bundle.putParcelable(
+ OCFileListFragment.SEARCH_EVENT,
+ SearchEvent("image/%", SearchRemoteOperation.SearchType.PHOTO_SEARCH)
+ )
+ bundle.putBoolean(EXTRA_FROM_ALBUM, true)
+ photoFragment.arguments = bundle
+
+ val transaction = supportFragmentManager.beginTransaction()
+ transaction.add(R.id.fragment_container, photoFragment, TAG_LIST_OF_FOLDERS)
+ transaction.commit()
+ }
+
+ private val listOfFilesFragment: AlbumsFragment?
+ get() {
+ val listOfFiles = supportFragmentManager.findFragmentByTag(AlbumsFragment.TAG)
+
+ return if (listOfFiles != null) {
+ return listOfFiles as AlbumsFragment?
+ } else {
+ Log_OC.e(TAG, "Access to non existing list of albums fragment!!")
+ null
+ }
+ }
+
+ override fun onRemoteOperationFinish(operation: RemoteOperation<*>?, result: RemoteOperationResult<*>) {
+ super.onRemoteOperationFinish(operation, result)
+ if (operation is CreateNewAlbumRemoteOperation) {
+ onCreateAlbumOperationFinish(operation, result)
+ }
+ }
+
+ /**
+ * Updates the view associated to the activity after the finish of an operation trying to create a new folder.
+ *
+ * @param operation Creation operation performed.
+ * @param result Result of the creation.
+ */
+ @Suppress("MaxLineLength")
+ private fun onCreateAlbumOperationFinish(
+ operation: CreateNewAlbumRemoteOperation,
+ result: RemoteOperationResult<*>
+ ) {
+ if (result.isSuccess) {
+ val fileListFragment = listOfFilesFragment
+ fileListFragment?.refreshAlbums()
+ } else {
+ try {
+ DisplayUtils.showSnackMessage(
+ this,
+ ErrorMessageAdapter.getErrorCauseMessage(result, operation, resources)
+ )
+ } catch (e: Resources.NotFoundException) {
+ Log_OC.e(TAG, "Error while trying to show fail message ", e)
+ }
+ }
+ }
+
+ override fun showDetails(file: OCFile?) {
+ // not used at the moment
+ }
+
+ override fun showDetails(file: OCFile?, activeTab: Int) {
+ // not used at the moment
+ }
+
+ override fun onBrowsedDownTo(folder: OCFile?) {
+ // not used at the moment
+ }
+
+ override fun onTransferStateChanged(file: OCFile?, downloading: Boolean, uploading: Boolean) {
+ // not used at the moment
+ }
+
+ companion object {
+ private val EXTRA_ACTION = AlbumsPickerActivity::class.java.canonicalName?.plus(".EXTRA_ACTION")
+ private val CHOOSE_ALBUM = AlbumsPickerActivity::class.java.canonicalName?.plus(".CHOOSE_ALBUM")
+ private val CHOOSE_MEDIA_FILES = AlbumsPickerActivity::class.java.canonicalName?.plus(".CHOOSE_MEDIA_FILES")
+ val EXTRA_FROM_ALBUM = AlbumsPickerActivity::class.java.canonicalName?.plus(".EXTRA_FROM_ALBUM")
+ val EXTRA_MEDIA_FILES_PATH = AlbumsPickerActivity::class.java.canonicalName?.plus(".EXTRA_MEDIA_FILES_PATH")
+
+ private val TAG = AlbumsPickerActivity::class.java.simpleName
+
+ fun intentForPickingAlbum(context: FragmentActivity): Intent {
+ return Intent(context, AlbumsPickerActivity::class.java).apply {
+ putExtra(EXTRA_ACTION, CHOOSE_ALBUM)
+ }
+ }
+
+ fun intentForPickingMediaFiles(context: FragmentActivity): Intent {
+ return Intent(context, AlbumsPickerActivity::class.java).apply {
+ putExtra(EXTRA_ACTION, CHOOSE_MEDIA_FILES)
+ }
+ }
+ }
+
+ override fun onRefresh(enforced: Boolean) {
+ // do nothing
+ }
+
+ override fun onRefresh() {
+ // do nothing
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ android.R.id.home -> super.onBackPressed()
+ }
+ return super.onOptionsItemSelected(item)
+ }
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java
index 03af5e607461..dd8792e82d33 100644
--- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java
+++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java
@@ -94,6 +94,8 @@
import com.owncloud.android.ui.fragment.GroupfolderListFragment;
import com.owncloud.android.ui.fragment.OCFileListFragment;
import com.owncloud.android.ui.fragment.SharedListFragment;
+import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment;
+import com.owncloud.android.ui.fragment.albums.AlbumsFragment;
import com.owncloud.android.ui.preview.PreviewTextStringFragment;
import com.owncloud.android.ui.trashbin.TrashbinActivity;
import com.owncloud.android.utils.BitmapUtils;
@@ -129,6 +131,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;
@@ -268,11 +271,7 @@ private void handleBottomNavigationViewClicks() {
resetOnlyPersonalAndOnDevice();
if (menuItemId == R.id.nav_all_files) {
- showFiles(false,false);
- if (this instanceof FileDisplayActivity fda) {
- fda.browseToRoot();
- }
- EventBus.getDefault().post(new ChangeMenuEvent());
+ onNavigationItemClicked(menuItem);
} else if (menuItemId == R.id.nav_favorites) {
setupToolbar();
handleSearchEvents(new SearchEvent("", SearchRemoteOperation.SearchType.FAVORITE_SEARCH), menuItemId);
@@ -281,6 +280,8 @@ private void handleBottomNavigationViewClicks() {
} else if (menuItemId == R.id.nav_gallery) {
setupToolbar();
startPhotoSearch(menuItem.getItemId());
+ } else if (menuItemId == R.id.nav_album) {
+ replaceAlbumFragment();
}
// Remove extra icon from the action bar
@@ -557,7 +558,8 @@ private void onNavigationItemClicked(final MenuItem menuItem) {
!(fda.getLeftFragment() instanceof GalleryFragment) &&
!(fda.getLeftFragment() instanceof SharedListFragment) &&
!(fda.getLeftFragment() instanceof GroupfolderListFragment) &&
- !(fda.getLeftFragment() instanceof PreviewTextStringFragment)) {
+ !(fda.getLeftFragment() instanceof PreviewTextStringFragment) &&
+ !isAlbumsFragment() && !isAlbumItemsFragment()) {
showFiles(false, itemId == R.id.nav_personal_files);
fda.browseToRoot();
EventBus.getDefault().post(new ChangeMenuEvent());
@@ -579,6 +581,8 @@ private void onNavigationItemClicked(final MenuItem menuItem) {
resetOnlyPersonalAndOnDevice();
setupToolbar();
startPhotoSearch(menuItem.getItemId());
+ } else if (itemId == R.id.nav_album) {
+ replaceAlbumFragment();
} else if (itemId == R.id.nav_on_device) {
EventBus.getDefault().post(new ChangeMenuEvent());
showFiles(true, false);
@@ -601,7 +605,7 @@ private void onNavigationItemClicked(final MenuItem menuItem) {
resetOnlyPersonalAndOnDevice();
menuItemId = Menu.NONE;
MenuItem isNewMenuItemChecked = menuItem.setChecked(false);
- Log_OC.d(TAG,"onNavigationItemClicked nav_logout setChecked " + isNewMenuItemChecked);
+ Log_OC.d(TAG, "onNavigationItemClicked nav_logout setChecked " + isNewMenuItemChecked);
final Optional optionalUser = getUser();
if (optionalUser.isPresent()) {
UserInfoActivity.openAccountRemovalDialog(optionalUser.get(), getSupportFragmentManager());
@@ -632,6 +636,26 @@ private void onNavigationItemClicked(final MenuItem menuItem) {
}
}
+ private void replaceAlbumFragment() {
+ if (isAlbumsFragment()) {
+ return;
+ }
+ FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
+ transaction.addToBackStack(null);
+ transaction.replace(R.id.left_fragment_container, AlbumsFragment.Companion.newInstance(false), AlbumsFragment.Companion.getTAG());
+ transaction.commit();
+ }
+
+ public boolean isAlbumsFragment() {
+ Fragment albumsFragment = getSupportFragmentManager().findFragmentByTag(AlbumsFragment.Companion.getTAG());
+ return albumsFragment instanceof AlbumsFragment && albumsFragment.isVisible();
+ }
+
+ public boolean isAlbumItemsFragment() {
+ Fragment albumItemsFragment = getSupportFragmentManager().findFragmentByTag(AlbumItemsFragment.Companion.getTAG());
+ return albumItemsFragment instanceof AlbumItemsFragment && albumItemsFragment.isVisible();
+ }
+
private void startComposeActivity(ComposeDestination destination, int titleId) {
Intent composeActivity = new Intent(getApplicationContext(), ComposeActivity.class);
composeActivity.putExtra(ComposeActivity.DESTINATION, destination);
@@ -693,7 +717,8 @@ public void startPhotoSearch(int id) {
private void handleSearchEvents(SearchEvent searchEvent, int menuItemId) {
if (this instanceof FileDisplayActivity) {
final Fragment leftFragment = ((FileDisplayActivity) this).getLeftFragment();
- if (leftFragment instanceof GalleryFragment || leftFragment instanceof SharedListFragment) {
+ if (leftFragment instanceof GalleryFragment || leftFragment instanceof SharedListFragment
+ || isAlbumsFragment() || isAlbumItemsFragment()) {
launchActivityForSearch(searchEvent, menuItemId);
} else {
EventBus.getDefault().post(searchEvent);
diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java
index d4b066f9b81d..36107b72fad5 100644
--- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java
+++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java
@@ -95,6 +95,10 @@
import com.owncloud.android.operations.RenameFileOperation;
import com.owncloud.android.operations.SynchronizeFileOperation;
import com.owncloud.android.operations.UploadFileOperation;
+import com.owncloud.android.operations.albums.CopyFileToAlbumOperation;
+import com.owncloud.android.operations.albums.CreateNewAlbumRemoteOperation;
+import com.owncloud.android.operations.albums.RemoveAlbumRemoteOperation;
+import com.owncloud.android.operations.albums.RenameAlbumRemoteOperation;
import com.owncloud.android.syncadapter.FileSyncAdapter;
import com.owncloud.android.ui.asynctasks.CheckAvailableSpaceTask;
import com.owncloud.android.ui.asynctasks.FetchRemoteFileTask;
@@ -115,6 +119,8 @@
import com.owncloud.android.ui.fragment.SharedListFragment;
import com.owncloud.android.ui.fragment.TaskRetainerFragment;
import com.owncloud.android.ui.fragment.UnifiedSearchFragment;
+import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment;
+import com.owncloud.android.ui.fragment.albums.AlbumsFragment;
import com.owncloud.android.ui.helpers.FileOperationsHelper;
import com.owncloud.android.ui.helpers.UriUploader;
import com.owncloud.android.ui.interfaces.CompletionCallback;
@@ -854,7 +860,8 @@ public boolean onOptionsItemSelected(MenuItem item) {
int itemId = item.getItemId();
if (itemId == android.R.id.home) {
- if (!isDrawerOpen() && !isSearchOpen() && isRoot(getCurrentDir()) && getLeftFragment() instanceof OCFileListFragment) {
+ if (!isDrawerOpen() && !isSearchOpen() && isRoot(getCurrentDir()) && getLeftFragment() instanceof OCFileListFragment
+ && !isAlbumItemsFragment()) {
openDrawer();
} else {
onBackPressed();
@@ -1051,6 +1058,12 @@ public void onBackPressed() {
return;
}
+ // NMC Customization: pop back if current fragment is AlbumItemsFragment
+ if (isAlbumItemsFragment()) {
+ popBack();
+ return;
+ }
+
if (getLeftFragment() instanceof OCFileListFragment listOfFiles) {
if (isRoot(getCurrentDir())) {
finish();
@@ -1802,6 +1815,14 @@ public void onRemoteOperationFinish(RemoteOperation operation, RemoteOperationRe
onCopyFileOperationFinish(copyFileOperation, result);
} else if (operation instanceof RestoreFileVersionRemoteOperation) {
onRestoreFileVersionOperationFinish(result);
+ } else if (operation instanceof CreateNewAlbumRemoteOperation createNewAlbumRemoteOperation) {
+ onCreateAlbumOperationFinish(createNewAlbumRemoteOperation, result);
+ } else if (operation instanceof CopyFileToAlbumOperation copyFileOperation) {
+ onCopyAlbumFileOperationFinish(copyFileOperation, result);
+ } else if (operation instanceof RenameAlbumRemoteOperation renameAlbumRemoteOperation) {
+ onRenameAlbumOperationFinish(renameAlbumRemoteOperation, result);
+ } else if (operation instanceof RemoveAlbumRemoteOperation removeAlbumRemoteOperation) {
+ onRemoveAlbumOperationFinish(removeAlbumRemoteOperation, result);
}
}
@@ -2040,6 +2061,81 @@ private void onCreateFolderOperationFinish(CreateFolderOperation operation, Remo
}
}
+ private void onRemoveAlbumOperationFinish(RemoveAlbumRemoteOperation operation, RemoteOperationResult result) {
+
+ if (result.isSuccess()) {
+
+ Fragment fragment = getSupportFragmentManager().findFragmentByTag(AlbumItemsFragment.Companion.getTAG());
+ if (fragment instanceof AlbumItemsFragment albumItemsFragment) {
+ albumItemsFragment.onAlbumDeleted();
+ }
+ } else {
+ DisplayUtils.showSnackMessage(this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()));
+
+ if (result.isSslRecoverableException()) {
+ mLastSslUntrustedServerResult = result;
+ showUntrustedCertDialog(mLastSslUntrustedServerResult);
+ }
+ }
+ }
+
+ private void onCopyAlbumFileOperationFinish(CopyFileToAlbumOperation operation, RemoteOperationResult result) {
+ if (result.isSuccess()) {
+ // when item added from inside of Album
+ Fragment fragment = getSupportFragmentManager().findFragmentByTag(AlbumItemsFragment.Companion.getTAG());
+ if (fragment instanceof AlbumItemsFragment albumItemsFragment) {
+ albumItemsFragment.refreshData();
+ } else {
+ // files added directly from Media tab
+ DisplayUtils.showSnackMessage(this, getResources().getString(R.string.album_file_added_message));
+ }
+ Log_OC.e(TAG, "Files copied successfully");
+ } else {
+ try {
+ DisplayUtils.showSnackMessage(this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()));
+ } catch (NotFoundException e) {
+ Log_OC.e(TAG, "Error while trying to show fail message ", e);
+ }
+ }
+ }
+
+ private void onRenameAlbumOperationFinish(RenameAlbumRemoteOperation operation, RemoteOperationResult result) {
+ if (result.isSuccess()) {
+
+ Fragment fragment = getSupportFragmentManager().findFragmentByTag(AlbumItemsFragment.Companion.getTAG());
+ if (fragment instanceof AlbumItemsFragment albumItemsFragment) {
+ albumItemsFragment.onAlbumRenamed(operation.getNewAlbumName());
+ }
+
+ } else {
+ DisplayUtils.showSnackMessage(this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()));
+
+ if (result.isSslRecoverableException()) {
+ mLastSslUntrustedServerResult = result;
+ showUntrustedCertDialog(mLastSslUntrustedServerResult);
+ }
+ }
+ }
+
+ private void onCreateAlbumOperationFinish(CreateNewAlbumRemoteOperation operation, RemoteOperationResult result) {
+ if (result.isSuccess()) {
+ Fragment fragment = getSupportFragmentManager().findFragmentByTag(AlbumsFragment.Companion.getTAG());
+ if (fragment instanceof AlbumsFragment albumsFragment) {
+ albumsFragment.navigateToAlbumItemsFragment(operation.getNewAlbumName(), true);
+ }
+ } else {
+ try {
+ if (ResultCode.FOLDER_ALREADY_EXISTS == result.getCode()) {
+ DisplayUtils.showSnackMessage(this, R.string.album_already_exists);
+ } else {
+ DisplayUtils.showSnackMessage(this, ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()));
+ }
+ } catch (NotFoundException e) {
+ Log_OC.e(TAG, "Error while trying to show fail message ", e);
+ }
+ }
+ }
+
/**
* {@inheritDoc}
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 10fbaaa8053b..c12227362ef8 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
@@ -205,6 +205,12 @@ class GalleryAdapter(
Handler(Looper.getMainLooper()).post { notifyDataSetChanged() }
}
+ @SuppressLint("NotifyDataSetChanged")
+ fun showAlbumItems(albumItems: List) {
+ files = albumItems.toGalleryItems()
+ Handler(Looper.getMainLooper()).post { notifyDataSetChanged() }
+ }
+
private fun transformToRows(list: List): List {
return list
.sortedBy { it.modificationTimestamp }
@@ -304,6 +310,10 @@ class GalleryAdapter(
notifyItemChanged(getItemPosition(file))
}
+ fun setCheckedItem(files: Set?) {
+ ocFileListDelegate.setCheckedItem(files)
+ }
+
override fun getFilesCount(): Int {
return files.fold(0) { acc, item -> acc + item.rows.size }
}
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 e292006ec5f0..b966ef206b84 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
@@ -35,6 +35,8 @@ import com.owncloud.android.ui.activity.ComponentsGetter
import com.owncloud.android.ui.activity.FolderPickerActivity
import com.owncloud.android.ui.fragment.GalleryFragment
import com.owncloud.android.ui.fragment.SearchType
+import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment
+import com.owncloud.android.ui.activity.AlbumsPickerActivity
import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface
import com.owncloud.android.utils.BitmapUtils
import com.owncloud.android.utils.DisplayUtils
@@ -115,8 +117,16 @@ class OCFileListDelegate(
)
imageView.setOnClickListener {
- ocFileListFragmentInterface.onItemClicked(file)
- GalleryFragment.setLastMediaItemPosition(galleryRowHolder.absoluteAdapterPosition)
+ // NMC Customization: while picking media directly perform long click
+ if (context is AlbumsPickerActivity) {
+ ocFileListFragmentInterface.onLongItemClicked(
+ file
+ )
+ } else {
+ ocFileListFragmentInterface.onItemClicked(file)
+ GalleryFragment.setLastMediaItemPosition(galleryRowHolder.absoluteAdapterPosition)
+ AlbumItemsFragment.lastMediaItemPosition = galleryRowHolder.absoluteAdapterPosition
+ }
}
if (!hideItemOptions) {
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumFragmentInterface.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumFragmentInterface.kt
new file mode 100644
index 000000000000..e30ef5b41cd9
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumFragmentInterface.kt
@@ -0,0 +1,14 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import com.owncloud.android.operations.albums.PhotoAlbumEntry
+
+interface AlbumFragmentInterface {
+ fun onItemClick(album: PhotoAlbumEntry)
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt
new file mode 100644
index 000000000000..4d7e5c6ca5d0
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt
@@ -0,0 +1,26 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.elyeproj.loaderviewlibrary.LoaderImageView
+import com.owncloud.android.databinding.AlbumsGridItemBinding
+
+internal class AlbumGridItemViewHolder(private var binding: AlbumsGridItemBinding) :
+ RecyclerView.ViewHolder(binding.root), AlbumItemViewHolder {
+ override val thumbnail: ImageView
+ get() = binding.thumbnail
+ override val shimmerThumbnail: LoaderImageView
+ get() = binding.thumbnailShimmer
+ override val albumName: TextView
+ get() = binding.Filename
+ override val albumInfo: TextView
+ get() = binding.fileInfo
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt
new file mode 100644
index 000000000000..a531404e8e99
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt
@@ -0,0 +1,19 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import android.widget.ImageView
+import android.widget.TextView
+import com.elyeproj.loaderviewlibrary.LoaderImageView
+
+interface AlbumItemViewHolder {
+ val thumbnail: ImageView
+ val shimmerThumbnail: LoaderImageView
+ val albumName: TextView
+ val albumInfo: TextView
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt
new file mode 100644
index 000000000000..99ff63eb9a01
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt
@@ -0,0 +1,26 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.elyeproj.loaderviewlibrary.LoaderImageView
+import com.owncloud.android.databinding.AlbumsListItemBinding
+
+internal class AlbumListItemViewHolder(private var binding: AlbumsListItemBinding) :
+ RecyclerView.ViewHolder(binding.root), AlbumItemViewHolder {
+ override val thumbnail: ImageView
+ get() = binding.thumbnail
+ override val shimmerThumbnail: LoaderImageView
+ get() = binding.thumbnailShimmer
+ override val albumName: TextView
+ get() = binding.Filename
+ override val albumInfo: TextView
+ get() = binding.fileInfo
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt
new file mode 100644
index 000000000000..fa6ab4433a12
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt
@@ -0,0 +1,117 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.nextcloud.client.account.User
+import com.nextcloud.client.preferences.AppPreferences
+import com.owncloud.android.R
+import com.owncloud.android.databinding.AlbumsGridItemBinding
+import com.owncloud.android.databinding.AlbumsListItemBinding
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.SyncedFolderProvider
+import com.owncloud.android.datamodel.ThumbnailsCacheManager
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.operations.albums.PhotoAlbumEntry
+import com.owncloud.android.utils.DisplayUtils
+import com.owncloud.android.utils.theme.ViewThemeUtils
+
+@Suppress("LongParameterList")
+class AlbumsAdapter(
+ val context: Context,
+ private val storageManager: FileDataStorageManager?,
+ private val user: User,
+ private val albumFragmentInterface: AlbumFragmentInterface,
+ private val syncedFolderProvider: SyncedFolderProvider,
+ private val preferences: AppPreferences,
+ private val viewThemeUtils: ViewThemeUtils,
+ private val gridView: Boolean = true
+) :
+ RecyclerView.Adapter() {
+ private var albumList: MutableList = mutableListOf()
+ private val asyncTasks: MutableList = ArrayList()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ return if (gridView) {
+ AlbumGridItemViewHolder(AlbumsGridItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+ } else {
+ AlbumListItemViewHolder(AlbumsListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+ }
+ }
+
+ override fun getItemCount(): Int {
+ return albumList.size
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ val gridViewHolder = holder as AlbumItemViewHolder
+ val file: PhotoAlbumEntry = albumList[position]
+
+ gridViewHolder.albumName.text = file.albumName
+ gridViewHolder.thumbnail.tag = file.lastPhoto
+ gridViewHolder.albumInfo.text = String.format(
+ context.resources.getString(R.string.album_items_text),
+ file.nbItems,
+ file.createdDate
+ )
+
+ if (file.lastPhoto > 0) {
+ val ocLocal = storageManager?.getFileByLocalId(file.lastPhoto)
+ if (ocLocal == null) {
+ gridViewHolder.thumbnail.setImageResource(R.drawable.album_no_photo_placeholder)
+ gridViewHolder.thumbnail.visibility = View.VISIBLE
+ gridViewHolder.shimmerThumbnail.visibility = View.GONE
+ } else {
+ DisplayUtils.setThumbnail(
+ ocLocal,
+ gridViewHolder.thumbnail,
+ user,
+ storageManager,
+ asyncTasks,
+ gridView,
+ context,
+ gridViewHolder.shimmerThumbnail,
+ preferences,
+ viewThemeUtils,
+ syncedFolderProvider
+ )
+ }
+ } else {
+ gridViewHolder.thumbnail.setImageResource(R.drawable.album_no_photo_placeholder)
+ gridViewHolder.thumbnail.visibility = View.VISIBLE
+ gridViewHolder.shimmerThumbnail.visibility = View.GONE
+ }
+
+ holder.itemView.setOnClickListener { albumFragmentInterface.onItemClick(file) }
+ }
+
+ fun cancelAllPendingTasks() {
+ for (task in asyncTasks) {
+ task.cancel(true)
+ if (task.getMethod != null) {
+ Log_OC.d("AlbumsAdapter", "cancel: abort get method directly")
+ task.getMethod.abort()
+ }
+ }
+ asyncTasks.clear()
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ fun setAlbumItems(albumItems: List?) {
+ albumList.clear()
+ albumItems?.let {
+ albumList.addAll(it)
+ }
+ notifyDataSetChanged()
+ }
+}
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..2ba533471c4e
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/dialog/CreateAlbumDialogFragment.kt
@@ -0,0 +1,206 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2023 Alper Ozturk
+ * SPDX-FileCopyrightText: 2022 Álvaro Brey
+ * SPDX-FileCopyrightText: 2018 Tobias Kaminsky
+ * SPDX-FileCopyrightText: 2015 David A. Velasco
+ * SPDX-FileCopyrightText: 2015 ownCloud Inc.
+ * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only)
+ */
+
+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.lib.resources.status.OCCapability
+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 getOCCapability(): OCCapability = fileDataStorageManager.getCapability(accountProvider.user.accountName)
+
+ private fun checkFileNameAfterEachType() {
+ val newAlbumName = binding.userInput.text?.toString() ?: ""
+
+ val errorMessage = when {
+ newAlbumName.isEmpty() -> 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 {
+ return MaterialAlertDialogBuilder(requireActivity())
+ .setView(view)
+ .setPositiveButton(
+ if (albumName == null) R.string.folder_confirm_create else R.string.rename_dialog_title,
+ 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()
+
+ val errorMessage = when {
+ newAlbumName.isEmpty() -> 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 {
+ return CreateAlbumDialogFragment().apply {
+ val argsBundle = bundleOf(
+ ARG_ALBUM_NAME to albumName
+ )
+ arguments = argsBundle
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt
index c083cbe8f45c..6209b2e85371 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
@@ -165,7 +165,9 @@ open class ExtendedListFragment :
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- val item = menu.findItem(R.id.action_search)
+ // NMC Customization: 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 = MenuItemCompat.getActionView(item) 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 d6cd370a5de4..5d034d75d05e 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
@@ -8,6 +8,7 @@
*/
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;
+ // NMC: required for Albums
+ private Set checkedFiles;
+ private boolean isFromAlbum; // when opened from Albums to add items
+
protected void setPhotoSearchQueryRunning(boolean value) {
this.photoSearchQueryRunning = value;
this.setLoading(value); // link the photoSearchQueryRunning variable with UI progress loading
@@ -84,7 +100,12 @@ public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
searchFragment = true;
- setHasOptionsMenu(true);
+ if (getArguments() != null) {
+ isFromAlbum = getArguments().getBoolean(AlbumsPickerActivity.Companion.getEXTRA_FROM_ALBUM(), false);
+ }
+
+ // NMC Customization: only show menu when not opened from media picker
+ setHasOptionsMenu(!isFromAlbum);
if (galleryFragmentBottomSheetDialog == null) {
galleryFragmentBottomSheetDialog = new GalleryFragmentBottomSheetDialog(this);
@@ -409,6 +430,11 @@ public void showAllGalleryItems() {
}
private void updateSubtitle(GalleryFragmentBottomSheetDialog.MediaState mediaState) {
+ // NMC Customization: while picking media don't show subtitle
+ if (isFromAlbum) {
+ return;
+ }
+
requireActivity().runOnUiThread(() -> {
if (!isAdded()) {
return;
@@ -435,4 +461,47 @@ 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;
+ 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 2c1ce53158a1..30b9d9044567 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
@@ -80,6 +80,7 @@
import com.owncloud.android.lib.resources.files.ToggleFavoriteRemoteOperation;
import com.owncloud.android.lib.resources.status.E2EVersion;
import com.owncloud.android.lib.resources.status.OCCapability;
+import com.owncloud.android.ui.activity.AlbumsPickerActivity;
import com.owncloud.android.ui.activity.DrawerActivity;
import com.owncloud.android.ui.activity.FileActivity;
import com.owncloud.android.ui.activity.FileDisplayActivity;
@@ -846,6 +847,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;
}
@@ -882,6 +894,10 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
final Set checkedFiles = getCommonAdapter().getCheckedItems();
if (item.getItemId() == R.id.custom_menu_placeholder_item) {
openActionsMenu(getCommonAdapter().getFilesCount(), checkedFiles, false);
+ } else if (item.getItemId() == R.id.add_to_album){
+ if (OCFileListFragment.this instanceof GalleryFragment galleryFragment) {
+ galleryFragment.addImagesToAlbum(checkedFiles);
+ }
}
return true;
}
@@ -2259,6 +2275,14 @@ public void setFabVisible(final boolean visible) {
return;
}
+ // NMC Customizations: to hide the fab if user is on Albums Fragment
+ if (requireActivity() instanceof FileDisplayActivity fda
+ && (fda.isAlbumsFragment()
+ || fda.isAlbumItemsFragment())) {
+ mFabMain.hide();
+ return;
+ }
+
final var activity = getActivity();
if (activity == null) {
return;
diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt
new file mode 100644
index 000000000000..42685d13f860
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt
@@ -0,0 +1,988 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.fragment.albums
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.ContentValues
+import android.content.Context
+import android.content.Intent
+import android.content.res.Configuration
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+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.ImageView
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.IdRes
+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.recyclerview.widget.GridLayoutManager
+import com.nextcloud.client.account.User
+import com.nextcloud.client.account.UserAccountManager
+import com.nextcloud.client.di.Injectable
+import com.nextcloud.client.network.ClientFactory
+import com.nextcloud.client.network.ClientFactory.CreationException
+import com.nextcloud.client.preferences.AppPreferences
+import com.nextcloud.client.utils.Throttler
+import com.nextcloud.ui.albumItemActions.AlbumItemActionsBottomSheet
+import com.nextcloud.ui.fileactions.FileActionsBottomSheet.Companion.newInstance
+import com.nextcloud.utils.extensions.isDialogFragmentReady
+import com.owncloud.android.R
+import com.owncloud.android.databinding.AlbumsFragmentBinding
+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.operations.albums.ReadAlbumItemsRemoteOperation
+import com.owncloud.android.operations.albums.RemoveAlbumFileRemoteOperation
+import com.owncloud.android.operations.albums.ToggleAlbumFavoriteRemoteOperation
+import com.owncloud.android.ui.activity.AlbumsPickerActivity
+import com.owncloud.android.ui.activity.AlbumsPickerActivity.Companion.intentForPickingMediaFiles
+import com.owncloud.android.ui.activity.FileDisplayActivity
+import com.owncloud.android.ui.adapter.GalleryAdapter
+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.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.FileStorageUtils
+import com.owncloud.android.utils.theme.ViewThemeUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+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")
+class AlbumItemsFragment : Fragment(), OCFileListFragmentInterface, Injectable {
+
+ private var adapter: GalleryAdapter? = null
+ private var client: OwnCloudClient? = null
+ private var optionalUser: Optional? = null
+
+ private lateinit var binding: AlbumsFragmentBinding
+
+ @Inject
+ lateinit var viewThemeUtils: ViewThemeUtils
+
+ @Inject
+ lateinit var accountManager: UserAccountManager
+
+ @Inject
+ lateinit var clientFactory: ClientFactory
+
+ @Inject
+ lateinit var preferences: AppPreferences
+
+ @Inject
+ lateinit var syncedFolderProvider: SyncedFolderProvider
+
+ @Inject
+ lateinit var throttler: Throttler
+
+ private var mContainerActivity: FileFragment.ContainerActivity? = null
+
+ private var columnSize = 0
+
+ private lateinit var albumName: String
+ private var isNewAlbum: Boolean = false
+
+ private var mMultiChoiceModeListener: MultiChoiceModeListener? = null
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ try {
+ mContainerActivity = context as FileFragment.ContainerActivity
+ } catch (e: ClassCastException) {
+ throw IllegalArgumentException(
+ context.toString() + " must implement " +
+ FileFragment.ContainerActivity::class.java.simpleName,
+ e
+ )
+ }
+ arguments?.let {
+ 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 = AlbumsFragmentBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ optionalUser = Optional.of(accountManager.user)
+ createMenu()
+ setupContainingList()
+ setupContent()
+
+ // if fragment is opened when new albums is created
+ // then open gallery to choose media to add
+ if (isNewAlbum) {
+ openGalleryToAddMedia()
+ }
+
+ setUpEmptyView()
+ }
+
+ private fun setUpEmptyView() {
+ binding.albumEmptyView.albumsBgImage.setImageResource(R.drawable.empty_album_detailed_view)
+ binding.albumEmptyView.albumsBgImage.scaleType = ImageView.ScaleType.FIT_CENTER
+ binding.albumEmptyView.emptyAlbumLabel.text = resources.getString(R.string.empty_album_detailed_view_title)
+ binding.albumEmptyView.emptyAlbumMessageLabel.text =
+ resources.getString(R.string.empty_album_detailed_view_message)
+ binding.albumEmptyView.createAlbum.text = resources.getString(R.string.add_photos)
+
+ binding.albumEmptyView.createAlbum.setOnClickListener {
+ // open Gallery fragment as selection then add items to current album
+ openGalleryToAddMedia()
+ }
+ }
+
+ private fun setUpActionMode() {
+ if (mMultiChoiceModeListener != null) return
+
+ mMultiChoiceModeListener = MultiChoiceModeListener(
+ requireActivity(),
+ adapter,
+ viewThemeUtils
+ ) { filesCount, checkedFiles -> openActionsMenu(filesCount, checkedFiles) }
+ (requireActivity() as FileDisplayActivity).addDrawerListener(mMultiChoiceModeListener)
+ }
+
+ private fun createMenu() {
+ val menuHost: MenuHost = requireActivity()
+ menuHost.addMenuProvider(
+ object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menu.clear() // important: clears any existing activity menu
+ menuInflater.inflate(R.menu.fragment_album_items, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.action_three_dot_icon -> {
+ openAlbumActionsMenu()
+ true
+ }
+
+ R.id.action_add_more_photos -> {
+ // open Gallery fragment as selection then add items to current album
+ openGalleryToAddMedia()
+ true
+ }
+
+ else -> false
+ }
+ }
+
+ override fun onPrepareMenu(menu: Menu) {
+ super.onPrepareMenu(menu)
+ val moreMenu = menu.findItem(R.id.action_three_dot_icon)
+ moreMenu.icon?.let {
+ moreMenu.setIcon(
+ viewThemeUtils.platform.colorDrawable(
+ it,
+ ContextCompat.getColor(requireActivity(), R.color.black)
+ )
+ )
+ }
+ val add = menu.findItem(R.id.action_add_more_photos)
+ add.icon?.let {
+ add.setIcon(
+ viewThemeUtils.platform.colorDrawable(
+ it,
+ ContextCompat.getColor(requireActivity(), R.color.black)
+ )
+ )
+ }
+ }
+ },
+ 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 {
+ return when (itemId) {
+ // action to rename album
+ R.id.action_rename_file -> {
+ CreateAlbumDialogFragment.newInstance(albumName)
+ .show(
+ requireActivity().supportFragmentManager,
+ CreateAlbumDialogFragment.TAG
+ )
+ true
+ }
+
+ // action to delete album
+ R.id.action_delete -> {
+ mContainerActivity?.getFileOperationsHelper()?.removeAlbum(albumName)
+ true
+ }
+
+ else -> false
+ }
+ }
+
+ private fun setupContent() {
+ val layoutManager = GridLayoutManager(requireContext(), 1)
+ binding.listRoot.layoutManager = layoutManager
+ fetchAndSetData()
+ }
+
+ private fun setupContainingList() {
+ viewThemeUtils.androidx.themeSwipeRefreshLayout(binding.swipeContainingList)
+ binding.swipeContainingList.setOnRefreshListener {
+ binding.swipeContainingList.isRefreshing = true
+ fetchAndSetData()
+ }
+ }
+
+ @VisibleForTesting
+ fun populateList(albums: List) {
+ // exit action mode on data refresh
+ mMultiChoiceModeListener?.exitSelectionMode()
+
+ if (requireActivity() is FileDisplayActivity) {
+ (requireActivity() as FileDisplayActivity).setMainFabVisible(false)
+ }
+ initializeAdapter()
+ adapter?.showAlbumItems(albums)
+ }
+
+ private fun fetchAndSetData() {
+ binding.swipeContainingList.isRefreshing = true
+ mMultiChoiceModeListener?.exitSelectionMode()
+ initializeAdapter()
+ updateEmptyView(false)
+ lifecycleScope.launch(Dispatchers.IO) {
+ val getRemoteNotificationOperation = ReadAlbumItemsRemoteOperation(albumName)
+ val result = client?.let { getRemoteNotificationOperation.execute(it) }
+ val ocFileList = mutableListOf()
+
+ if (result?.isSuccess == true && result.resultData != null) {
+ mContainerActivity?.storageManager?.deleteVirtuals(VirtualFolderType.ALBUM)
+ val contentValues = mutableListOf()
+
+ for (remoteFile in result.resultData) {
+ var ocFile = mContainerActivity?.storageManager?.getFileByLocalId(remoteFile.localId)
+ if (ocFile == null) {
+ ocFile = FileStorageUtils.fillOCFile(remoteFile)
+ } else {
+ // required: as OCFile will only contains file_name.png not with /albums/album_name/file_name
+ // to fix this we have to get the remote path from remote file and assign to OCFile
+ ocFile.remotePath = remoteFile.remotePath
+ ocFile.decryptedRemotePath = remoteFile.remotePath
+ }
+ ocFileList.add(ocFile!!)
+
+ val cv = ContentValues()
+ cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_TYPE, VirtualFolderType.ALBUM.toString())
+ cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_OCFILE_ID, ocFile.fileId)
+
+ contentValues.add(cv)
+ }
+
+ mContainerActivity?.storageManager?.saveVirtuals(contentValues)
+ }
+ withContext(Dispatchers.Main) {
+ if (result?.isSuccess == true && result.resultData != null) {
+ if (result.resultData.isEmpty()) {
+ updateEmptyView(true)
+ }
+ populateList(ocFileList)
+ } else {
+ Log_OC.d(TAG, result?.logMessage)
+ // show error
+ updateEmptyView(true)
+ }
+ hideRefreshLayoutLoader()
+ }
+ }
+ }
+
+ private fun hideRefreshLayoutLoader() {
+ binding.swipeContainingList.isRefreshing = false
+ }
+
+ private fun initializeClient() {
+ if (client == null && optionalUser?.isPresent == true) {
+ try {
+ val user = optionalUser?.get()
+ client = clientFactory.create(user)
+ } catch (e: CreationException) {
+ Log_OC.e(TAG, "Error initializing client", e)
+ }
+ }
+ }
+
+ private fun initializeAdapter() {
+ initializeClient()
+ if (adapter == null) {
+ adapter = 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 updateEmptyView(isEmpty: Boolean) {
+ binding.albumEmptyView.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 (requireActivity() is FileDisplayActivity) {
+ (requireActivity() as FileDisplayActivity).setupToolbar()
+ (requireActivity() as FileDisplayActivity).supportActionBar?.let { actionBar ->
+ viewThemeUtils.files.themeActionBar(requireContext(), actionBar, albumName)
+ }
+ (requireActivity() as FileDisplayActivity).showSortListGroup(false)
+ (requireActivity() as FileDisplayActivity).setMainFabVisible(false)
+
+ // clear the subtitle while navigating to any other screen from Media screen
+ (requireActivity() as FileDisplayActivity).clearToolbarSubtitle()
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ adapter?.cancelAllPendingTasks()
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+
+ if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ columnSize = MAX_COLUMN_SIZE_LANDSCAPE
+ } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) {
+ columnSize = MAX_COLUMN_SIZE_PORTRAIT
+ }
+ adapter?.changeColumn(columnSize)
+ adapter?.notifyDataSetChanged()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ lastMediaItemPosition = 0
+ }
+
+ override fun getColumnsCount(): Int {
+ return columnSize
+ }
+
+ override fun onShareIconClick(file: OCFile?) {
+ // nothing to do here
+ }
+
+ override fun showShareDetailView(file: OCFile?) {
+ // nothing to do here
+ }
+
+ override fun showActivityDetailView(file: OCFile?) {
+ // nothing to do here
+ }
+
+ override fun onOverflowIconClicked(file: OCFile?, view: View?) {
+ // nothing to do here
+ }
+
+ override fun onItemClicked(file: OCFile) {
+ if (adapter?.isMultiSelect() == true) {
+ toggleItemToCheckedList(file)
+ } else {
+ if (PreviewImageFragment.canBePreviewed(file)) {
+ (mContainerActivity as FileDisplayActivity).startImagePreview(
+ file,
+ VirtualFolderType.ALBUM,
+ !file.isDown
+ )
+ } else if (file.isDown) {
+ if (canBePreviewed(file)) {
+ (mContainerActivity as FileDisplayActivity).startMediaPreview(file, 0, true, true, false, true)
+ } else {
+ mContainerActivity?.getFileOperationsHelper()?.openFile(file)
+ }
+ } else {
+ if (canBePreviewed(file) && !file.isEncrypted) {
+ (mContainerActivity as FileDisplayActivity).startMediaPreview(file, 0, true, true, true, true)
+ } else {
+ Log_OC.d(TAG, "Couldn't handle item click")
+ }
+ }
+ }
+ }
+
+ override fun onLongItemClicked(file: OCFile): Boolean {
+ // Create only once instance of action mode
+ if (mMultiChoiceModeListener?.mActiveActionMode != null) {
+ toggleItemToCheckedList(file)
+ } else {
+ requireActivity().startActionMode(mMultiChoiceModeListener)
+ adapter?.addCheckedFile(file)
+ }
+ mMultiChoiceModeListener?.updateActionModeFile(file)
+ return true
+ }
+
+ /**
+ * Will toggle a file selection status from the action mode
+ *
+ * @param file The concerned OCFile by the selection/deselection
+ */
+ private fun toggleItemToCheckedList(file: OCFile) {
+ adapter?.run {
+ if (isCheckedFile(file)) {
+ removeCheckedFile(file)
+ } else {
+ addCheckedFile(file)
+ }
+ }
+ mMultiChoiceModeListener?.updateActionModeFile(file)
+ }
+
+ override fun isLoading(): Boolean {
+ return false
+ }
+
+ override fun onHeaderClicked() {
+ // nothing to do here
+ }
+
+ fun onAlbumRenamed(newAlbumName: String) {
+ albumName = newAlbumName
+ if (requireActivity() is FileDisplayActivity) {
+ (requireActivity() as FileDisplayActivity).updateActionBarTitleAndHomeButtonByString(albumName)
+ }
+ }
+
+ fun onAlbumDeleted() {
+ requireActivity().supportFragmentManager.popBackStack()
+ }
+
+ 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 actionBottomSheet = newInstance(filesCount, checkedFiles, true, toHide)
+ .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 -> {
+ onRemoveFileOperation(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_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(
+ files.first().remotePath
+ )
+ 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(
+ file.remotePath
+ )
+ 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
+ fetchAndSetData()
+ }
+ }
+ }
+
+ private fun showDialog(isShow: Boolean) {
+ if (requireActivity() is FileDisplayActivity) {
+ if (isShow) {
+ (requireActivity() as FileDisplayActivity).showLoadingDialog(
+ requireContext().resources.getString(
+ R.string.wait_a_moment
+ )
+ )
+ } else {
+ (requireActivity() as FileDisplayActivity).dismissLoadingDialog()
+ }
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ EventBus.getDefault().register(this)
+ }
+
+ override fun onStop() {
+ EventBus.getDefault().unregister(this)
+ super.onStop()
+ }
+
+ /**
+ * Handler for multiple selection mode.
+ *
+ *
+ * Manages input from the user when one or more files or folders are selected in the list.
+ *
+ *
+ * Also listens to changes in navigation drawer to hide and recover multiple selection when it's opened and closed.
+ */
+ internal class MultiChoiceModeListener(
+ val activity: FragmentActivity,
+ val adapter: GalleryAdapter?,
+ val viewThemeUtils: ViewThemeUtils,
+ val openActionsMenu: (Int, Set) -> Unit
+ ) : AbsListView.MultiChoiceModeListener, DrawerLayout.DrawerListener {
+
+ var mActiveActionMode: ActionMode? = null
+ private var mIsActionModeNew = false
+
+ /**
+ * True when action mode is finished because the drawer was opened
+ */
+ private var mActionModeClosedByDrawer = false
+
+ /**
+ * Selected items in list when action mode is closed by drawer
+ */
+ private val mSelectionWhenActionModeClosedByDrawer: MutableSet = HashSet()
+
+ override fun onDrawerSlide(drawerView: View, slideOffset: Float) {
+ // nothing to do
+ }
+
+ override fun onDrawerOpened(drawerView: View) {
+ // nothing to do
+ }
+
+ /**
+ * When the navigation drawer is closed, action mode is recovered in the same state as was when the drawer was
+ * (started to be) opened.
+ *
+ * @param drawerView Navigation drawer just closed.
+ */
+ override fun onDrawerClosed(drawerView: View) {
+ if (mActionModeClosedByDrawer && mSelectionWhenActionModeClosedByDrawer.size > 0) {
+ activity.startActionMode(this)
+
+ adapter?.setCheckedItem(mSelectionWhenActionModeClosedByDrawer)
+
+ mActiveActionMode?.invalidate()
+
+ mSelectionWhenActionModeClosedByDrawer.clear()
+ }
+ }
+
+ /**
+ * If the action mode is active when the navigation drawer starts to move, the action mode is closed and the
+ * selection stored to be recovered when the drawer is closed.
+ *
+ * @param newState One of STATE_IDLE, STATE_DRAGGING or STATE_SETTLING.
+ */
+ override fun onDrawerStateChanged(newState: Int) {
+ if (DrawerLayout.STATE_DRAGGING == newState && mActiveActionMode != null) {
+ adapter?.let {
+ mSelectionWhenActionModeClosedByDrawer.addAll(
+ it.getCheckedItems()
+ )
+ }
+
+ mActiveActionMode?.finish()
+ mActionModeClosedByDrawer = true
+ }
+ }
+
+ /**
+ * Update action mode bar when an item is selected / unselected in the list
+ */
+ override fun onItemCheckedStateChanged(mode: ActionMode, position: Int, id: Long, checked: Boolean) {
+ // nothing to do here
+ }
+
+ /**
+ * Load menu and customize UI when action mode is started.
+ */
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ mActiveActionMode = mode
+ // Determine if actionMode is "new" or not (already affected by item-selection)
+ mIsActionModeNew = true
+
+ // fake menu to be able to use bottom sheet instead
+ val inflater: MenuInflater = activity.menuInflater
+ inflater.inflate(R.menu.custom_menu_placeholder, menu)
+ val item = menu.findItem(R.id.custom_menu_placeholder_item)
+ item.icon?.let {
+ item.setIcon(
+ viewThemeUtils.platform.colorDrawable(
+ it,
+ ContextCompat.getColor(activity, R.color.white)
+ )
+ )
+ }
+
+ mode.invalidate()
+
+ // set actionMode color
+ viewThemeUtils.platform.colorStatusBar(
+ activity,
+ ContextCompat.getColor(activity, R.color.action_mode_background)
+ )
+
+ adapter?.setMultiSelect(true)
+ return true
+ }
+
+ /**
+ * Updates available action in menu depending on current selection.
+ */
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ val checkedFiles: Set = adapter?.getCheckedItems() ?: emptySet()
+ val checkedCount = checkedFiles.size
+ val title: String =
+ activity.resources.getQuantityString(R.plurals.items_selected_count, checkedCount, checkedCount)
+ mode.title = title
+
+ // Determine if we need to finish the action mode because there are no items selected
+ if (checkedCount == 0 && !mIsActionModeNew) {
+ exitSelectionMode()
+ }
+
+ return true
+ }
+
+ /**
+ * Exits the multi file selection mode.
+ */
+ fun exitSelectionMode() {
+ mActiveActionMode?.run {
+ finish()
+ }
+ }
+
+ /**
+ * Will update (invalidate) the action mode adapter/mode to refresh an item selection change
+ *
+ * @param file The concerned OCFile to refresh in adapter
+ */
+ fun updateActionModeFile(file: OCFile) {
+ mIsActionModeNew = false
+ mActiveActionMode?.let {
+ it.invalidate()
+ adapter?.notifyItemChanged(file)
+ }
+ }
+
+ fun invalidateActionMode() {
+ mActiveActionMode?.invalidate()
+ }
+
+ /**
+ * Starts the corresponding action when a menu item is tapped by the user.
+ */
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ adapter?.let {
+ val checkedFiles: Set = it.getCheckedItems()
+ if (item.itemId == R.id.custom_menu_placeholder_item) {
+ openActionsMenu(it.getFilesCount(), checkedFiles)
+ }
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Restores UI.
+ */
+ override fun onDestroyActionMode(mode: ActionMode) {
+ mActiveActionMode = null
+
+ viewThemeUtils.platform.resetStatusBar(activity)
+
+ adapter?.setMultiSelect(false)
+ adapter?.clearCheckedItems()
+ }
+ }
+
+ private val activityResult: ActivityResultLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { intentResult: ActivityResult ->
+ if (Activity.RESULT_OK == intentResult.resultCode) {
+ intentResult.data?.let {
+ val paths = it.getStringArrayListExtra(AlbumsPickerActivity.EXTRA_MEDIA_FILES_PATH)
+ paths?.let { p ->
+ addMediaToAlbum(p.toMutableList())
+ }
+ }
+ }
+ }
+
+ private fun openGalleryToAddMedia() {
+ activityResult.launch(intentForPickingMediaFiles(requireActivity()))
+ }
+
+ private fun addMediaToAlbum(filePaths: MutableList) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ // short delay to let other transactions finish
+ // else showLoadingDialog will throw exception
+ delay(SLEEP_DELAY)
+ mContainerActivity?.fileOperationsHelper?.albumCopyFiles(filePaths, albumName)
+ }
+ }
+
+ fun refreshData() {
+ fetchAndSetData()
+ }
+
+ companion object {
+ val TAG: String = AlbumItemsFragment::class.java.simpleName
+ 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
+
+ fun newInstance(albumName: String, isNewAlbum: Boolean = false): AlbumItemsFragment {
+ val args = Bundle()
+
+ val fragment = AlbumItemsFragment()
+ fragment.arguments = args
+ args.putString(ARG_ALBUM_NAME, albumName)
+ args.putBoolean(ARG_IS_NEW_ALBUM, isNewAlbum)
+ return fragment
+ }
+ }
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt
new file mode 100644
index 000000000000..ad7ddc9a7b52
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt
@@ -0,0 +1,366 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 Your Name
+ * 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.text.Spannable
+import android.text.SpannableString
+import android.text.style.ForegroundColorSpan
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.VisibleForTesting
+import androidx.core.content.ContextCompat
+import androidx.core.content.res.ResourcesCompat
+import androidx.core.view.MenuHost
+import androidx.core.view.MenuProvider
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.bumptech.glide.Glide
+import com.nextcloud.client.account.User
+import com.nextcloud.client.account.UserAccountManager
+import com.nextcloud.client.di.Injectable
+import com.nextcloud.client.network.ClientFactory
+import com.nextcloud.client.network.ClientFactory.CreationException
+import com.nextcloud.client.preferences.AppPreferences
+import com.owncloud.android.R
+import com.owncloud.android.databinding.AlbumsFragmentBinding
+import com.owncloud.android.datamodel.SyncedFolderProvider
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.operations.albums.PhotoAlbumEntry
+import com.owncloud.android.operations.albums.ReadAlbumsRemoteOperation
+import com.owncloud.android.ui.activity.FileDisplayActivity
+import com.owncloud.android.ui.adapter.albums.AlbumFragmentInterface
+import com.owncloud.android.ui.adapter.albums.AlbumsAdapter
+import com.owncloud.android.ui.dialog.CreateAlbumDialogFragment
+import com.owncloud.android.ui.fragment.FileFragment
+import com.owncloud.android.utils.theme.ViewThemeUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.Optional
+import javax.inject.Inject
+
+class AlbumsFragment : Fragment(), AlbumFragmentInterface, Injectable {
+
+ private var adapter: AlbumsAdapter? = null
+ private var client: OwnCloudClient? = null
+ private var optionalUser: Optional? = null
+
+ private lateinit var binding: AlbumsFragmentBinding
+
+ @Inject
+ lateinit var viewThemeUtils: ViewThemeUtils
+
+ @Inject
+ lateinit var accountManager: UserAccountManager
+
+ @Inject
+ lateinit var clientFactory: ClientFactory
+
+ @Inject
+ lateinit var preferences: AppPreferences
+
+ @Inject
+ lateinit var syncedFolderProvider: SyncedFolderProvider
+
+ private var mContainerActivity: FileFragment.ContainerActivity? = null
+
+ private var isGridView = true
+ private var maxColumnSize = 2
+ private var isSelectionMode = false
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ try {
+ mContainerActivity = context as FileFragment.ContainerActivity
+ } catch (e: ClassCastException) {
+ throw IllegalArgumentException(
+ context.toString() + " must implement " +
+ FileFragment.ContainerActivity::class.java.simpleName,
+ e
+ )
+ }
+ arguments?.let {
+ isSelectionMode = it.getBoolean(ARG_IS_SELECTION_MODE, false)
+ if (isSelectionMode) {
+ isGridView = false
+ }
+ }
+ }
+
+ override fun onDetach() {
+ mContainerActivity = null
+ super.onDetach()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ maxColumnSize = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ MAX_COLUMN_SIZE_LANDSCAPE
+ } else {
+ MAX_COLUMN_SIZE_PORTRAIT
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ binding = AlbumsFragmentBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ optionalUser = Optional.of(accountManager.user)
+ createMenu()
+ setupContainingList()
+ setupContent()
+ setUpEmptyView()
+ }
+
+ private fun setUpEmptyView() {
+ Glide.with(requireContext()).load(R.drawable.bg_image_albums)
+ .into(binding.albumEmptyView.albumsBgImage)
+
+ binding.albumEmptyView.createAlbum.setOnClickListener {
+ showCreateAlbumDialog()
+ }
+ }
+
+ private fun createMenu() {
+ val menuHost: MenuHost = requireActivity()
+ menuHost.addMenuProvider(
+ object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menu.clear() // important: clears any existing activity menu
+ menuInflater.inflate(R.menu.fragment_create_album, menu)
+
+ val addItem = menu.findItem(R.id.action_create_new_album)
+ val coloredTitle = SpannableString(addItem.title).apply {
+ setSpan(
+ ForegroundColorSpan(ContextCompat.getColor(requireContext(), R.color.primary)),
+ 0,
+ length,
+ Spannable.SPAN_INCLUSIVE_INCLUSIVE
+ )
+ }
+ addItem.title = coloredTitle
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.action_create_new_album -> {
+ showCreateAlbumDialog()
+ true
+ }
+
+ else -> false
+ }
+ }
+ },
+ viewLifecycleOwner,
+ Lifecycle.State.RESUMED
+ )
+ }
+
+ private fun showCreateAlbumDialog() {
+ CreateAlbumDialogFragment.newInstance()
+ .show(
+ requireActivity().supportFragmentManager,
+ CreateAlbumDialogFragment.TAG
+ )
+ }
+
+ private fun setupContent() {
+ binding.listRoot.setHasFixedSize(true)
+ if (isGridView) {
+ val layoutManager = GridLayoutManager(requireContext(), maxColumnSize)
+ binding.listRoot.layoutManager = layoutManager
+ } else {
+ val layoutManager = LinearLayoutManager(requireContext())
+ binding.listRoot.layoutManager = layoutManager
+ }
+ fetchAndSetData()
+ }
+
+ private fun setupContainingList() {
+ viewThemeUtils.androidx.themeSwipeRefreshLayout(binding.swipeContainingList)
+ binding.swipeContainingList.setOnRefreshListener {
+ fetchAndSetData()
+ }
+ }
+
+ @VisibleForTesting
+ fun populateList(albums: List?) {
+ if (requireActivity() is FileDisplayActivity) {
+ (requireActivity() as FileDisplayActivity).setMainFabVisible(false)
+ }
+ initializeAdapter()
+ adapter?.setAlbumItems(albums)
+ }
+
+ private fun fetchAndSetData() {
+ binding.swipeContainingList.isRefreshing = true
+ initializeAdapter()
+ updateEmptyView(false)
+ lifecycleScope.launch(Dispatchers.IO) {
+ val getRemoteNotificationOperation = ReadAlbumsRemoteOperation()
+ val result = client?.let { getRemoteNotificationOperation.execute(it) }
+ withContext(Dispatchers.Main) {
+ if (result?.isSuccess == true && result.resultData != null) {
+ if (result.resultData.isEmpty()) {
+ updateEmptyView(true)
+ }
+ populateList(result.resultData)
+ } else {
+ Log_OC.d(TAG, result?.logMessage)
+ // show error
+ updateEmptyView(true)
+ }
+ hideRefreshLayoutLoader()
+ }
+ }
+ }
+
+ private fun hideRefreshLayoutLoader() {
+ binding.swipeContainingList.isRefreshing = false
+ }
+
+ private fun initializeClient() {
+ if (client == null && optionalUser?.isPresent == true) {
+ try {
+ val user = optionalUser?.get()
+ client = clientFactory.create(user)
+ } catch (e: CreationException) {
+ Log_OC.e(TAG, "Error initializing client", e)
+ }
+ }
+ }
+
+ private fun initializeAdapter() {
+ initializeClient()
+ if (adapter == null) {
+ adapter = AlbumsAdapter(
+ requireContext(),
+ mContainerActivity?.storageManager,
+ accountManager.user,
+ this,
+ syncedFolderProvider,
+ preferences,
+ viewThemeUtils,
+ isGridView
+ )
+ }
+ binding.listRoot.adapter = adapter
+ }
+
+ private fun updateEmptyView(isEmpty: Boolean) {
+ binding.albumEmptyView.emptyViewLayout.visibility = if (isEmpty) View.VISIBLE else View.GONE
+ binding.listRoot.visibility = if (isEmpty) View.GONE else View.VISIBLE
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (isSelectionMode) {
+ binding.root.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.bg_default, null))
+ }
+ if (requireActivity() is FileDisplayActivity) {
+ (requireActivity() as FileDisplayActivity).setupToolbar()
+ (requireActivity() as FileDisplayActivity).supportActionBar?.let { actionBar ->
+ viewThemeUtils.files.themeActionBar(
+ requireContext(),
+ actionBar,
+ R.string.drawer_item_album,
+ isMenu = true
+ )
+ }
+ (requireActivity() as FileDisplayActivity).showSortListGroup(false)
+ (requireActivity() as FileDisplayActivity).setMainFabVisible(false)
+
+ // clear the subtitle while navigating to any other screen from Media screen
+ (requireActivity() as FileDisplayActivity).clearToolbarSubtitle()
+ }
+ }
+
+ fun navigateToAlbumItemsFragment(albumName: String, isNewAlbum: Boolean = false) {
+ requireActivity().supportFragmentManager.beginTransaction().apply {
+ addToBackStack(null)
+ replace(
+ R.id.left_fragment_container,
+ AlbumItemsFragment.newInstance(albumName, isNewAlbum = isNewAlbum),
+ AlbumItemsFragment.TAG
+ )
+ commit()
+ }
+ }
+
+ fun refreshAlbums() {
+ fetchAndSetData()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ adapter?.cancelAllPendingTasks()
+ }
+
+ 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 5acc339d4949..61f9af25af73 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
@@ -1013,6 +1013,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/preview/PreviewImagePagerAdapter.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt
index f217fd4da955..b5efb522e80d 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
@@ -93,6 +93,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/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/album_no_photo_placeholder.xml b/app/src/main/res/drawable/album_no_photo_placeholder.xml
new file mode 100644
index 000000000000..4b31ce242b9a
--- /dev/null
+++ b/app/src/main/res/drawable/album_no_photo_placeholder.xml
@@ -0,0 +1,564 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/empty_album_detailed_view.xml b/app/src/main/res/drawable/empty_album_detailed_view.xml
new file mode 100644
index 000000000000..1ebcd208a454
--- /dev/null
+++ b/app/src/main/res/drawable/empty_album_detailed_view.xml
@@ -0,0 +1,568 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/albums_empty_view.xml b/app/src/main/res/layout/albums_empty_view.xml
new file mode 100644
index 000000000000..75d8c0112bcd
--- /dev/null
+++ b/app/src/main/res/layout/albums_empty_view.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
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..d0bd8639df2a
--- /dev/null
+++ b/app/src/main/res/layout/albums_fragment.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
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..f232eb7483b4
--- /dev/null
+++ b/app/src/main/res/layout/albums_grid_item.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..bc84d95c9af5
--- /dev/null
+++ b/app/src/main/res/layout/albums_list_item.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/bottom_navigation_menu.xml b/app/src/main/res/menu/bottom_navigation_menu.xml
index 46c128bf92bc..2fcfdd42ed32 100644
--- a/app/src/main/res/menu/bottom_navigation_menu.xml
+++ b/app/src/main/res/menu/bottom_navigation_menu.xml
@@ -30,4 +30,10 @@
android:icon="@drawable/nav_photos"
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..ae1973a3e875 100644
--- a/app/src/main/res/menu/custom_menu_placeholder.xml
+++ b/app/src/main/res/menu/custom_menu_placeholder.xml
@@ -8,6 +8,11 @@
-->