diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 73493014a302..3bac6166cf28 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,7 +2,7 @@
@@ -592,6 +592,9 @@
android:launchMode="singleTop"
android:theme="@style/Theme.ownCloud.Dialog.NoTitle"
android:windowSoftInputMode="adjustResize" />
+
+ * SPDX-FileCopyrightText: 2024-2025 TSI-mc
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
@@ -31,6 +31,7 @@
import com.nextcloud.ui.ChooseStorageLocationDialogFragment;
import com.nextcloud.ui.ImageDetailFragment;
import com.nextcloud.ui.SetStatusDialogFragment;
+import com.nextcloud.ui.albumItemActions.AlbumItemActionsBottomSheet;
import com.nextcloud.ui.composeActivity.ComposeActivity;
import com.nextcloud.ui.fileactions.FileActionsBottomSheet;
import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsBottomSheet;
@@ -81,6 +82,7 @@
import com.owncloud.android.ui.dialog.ChooseTemplateDialogFragment;
import com.owncloud.android.ui.dialog.ConfirmationDialogFragment;
import com.owncloud.android.ui.dialog.ConflictsResolveDialog;
+import com.owncloud.android.ui.dialog.CreateAlbumDialogFragment;
import com.owncloud.android.ui.dialog.CreateFolderDialogFragment;
import com.owncloud.android.ui.dialog.ExpirationDatePickerDialogFragment;
import com.owncloud.android.ui.dialog.IndeterminateProgressDialog;
@@ -114,6 +116,9 @@
import com.owncloud.android.ui.fragment.OCFileListFragment;
import com.owncloud.android.ui.fragment.SharedListFragment;
import com.owncloud.android.ui.fragment.UnifiedSearchFragment;
+import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment;
+import com.owncloud.android.ui.fragment.albums.AlbumsFragment;
+import com.owncloud.android.ui.activity.AlbumsPickerActivity;
import com.owncloud.android.ui.fragment.contactsbackup.BackupFragment;
import com.owncloud.android.ui.fragment.contactsbackup.BackupListFragment;
import com.owncloud.android.ui.preview.FileDownloadFragment;
@@ -505,4 +510,19 @@ abstract class ComponentsModule {
@ContributesAndroidInjector
abstract TermsOfServiceDialog termsOfServiceDialog();
+
+ @ContributesAndroidInjector
+ abstract AlbumsPickerActivity albumsPickerActivity();
+
+ @ContributesAndroidInjector
+ abstract CreateAlbumDialogFragment createAlbumDialogFragment();
+
+ @ContributesAndroidInjector
+ abstract AlbumsFragment albumsFragment();
+
+ @ContributesAndroidInjector
+ abstract AlbumItemsFragment albumItemsFragment();
+
+ @ContributesAndroidInjector
+ abstract AlbumItemActionsBottomSheet albumItemActionsBottomSheet();
}
diff --git a/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt
new file mode 100644
index 000000000000..9d31afca3def
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt
@@ -0,0 +1,29 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nextcloud.ui.albumItemActions
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.IdRes
+import androidx.annotation.StringRes
+import com.owncloud.android.R
+
+enum class AlbumItemAction(@IdRes val id: Int, @StringRes val title: Int, @DrawableRes val icon: Int? = null) {
+ RENAME_ALBUM(R.id.action_rename_file, R.string.album_rename, R.drawable.ic_edit),
+ DELETE_ALBUM(R.id.action_delete, R.string.album_delete, R.drawable.ic_delete);
+
+ companion object {
+ /**
+ * All file actions, in the order they should be displayed
+ */
+ @JvmField
+ val SORTED_VALUES = listOf(
+ RENAME_ALBUM,
+ DELETE_ALBUM
+ )
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemActionsBottomSheet.kt
new file mode 100644
index 000000000000..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..e1fa9db746ff 100644
--- a/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java
+++ b/app/src/main/java/com/owncloud/android/datamodel/VirtualFolderType.java
@@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
+ * SPDX-FileCopyrightText: 2025 TSI-mc
* SPDX-FileCopyrightText: 2017 Tobias Kaminsky
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
@@ -12,5 +13,5 @@
* Type for virtual folders
*/
public enum VirtualFolderType {
- FAVORITE, GALLERY, NONE
+ FAVORITE, GALLERY, ALBUM, NONE
}
diff --git a/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumOperation.kt b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumOperation.kt
new file mode 100644
index 000000000000..1752a9b63db7
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/operations/albums/CopyFileToAlbumOperation.kt
@@ -0,0 +1,77 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package com.owncloud.android.operations.albums
+
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
+import com.owncloud.android.lib.resources.albums.CopyFileToAlbumRemoteOperation
+import com.owncloud.android.operations.UploadFileOperation
+import com.owncloud.android.operations.common.SyncOperation
+
+/**
+ * Constructor
+ *
+ * @param srcPath Remote path of the [OCFile] to move.
+ * @param targetParentPath Path to the folder where the file will be copied into.
+ */
+class CopyFileToAlbumOperation(
+ private val srcPath: String,
+ private var targetParentPath: String,
+ storageManager: FileDataStorageManager
+) :
+ SyncOperation(storageManager) {
+ init {
+ if (!targetParentPath.endsWith(OCFile.PATH_SEPARATOR)) {
+ this.targetParentPath += OCFile.PATH_SEPARATOR
+ }
+ }
+
+ /**
+ * Performs the operation.
+ *
+ * @param client Client object to communicate with the remote ownCloud server.
+ */
+ @Deprecated("Deprecated in Java")
+ @Suppress("NestedBlockDepth")
+ override fun run(client: OwnCloudClient): RemoteOperationResult {
+ /** 1. check copy validity */
+ val result: RemoteOperationResult
+
+ if (targetParentPath.startsWith(srcPath)) {
+ result = RemoteOperationResult(ResultCode.INVALID_COPY_INTO_DESCENDANT)
+ } else {
+ val file = storageManager.getFileByPath(srcPath)
+ if (file == null) {
+ result = RemoteOperationResult(ResultCode.FILE_NOT_FOUND)
+ } else {
+ /** 2. remote copy */
+ var targetPath = "$targetParentPath${file.fileName}"
+ if (file.isFolder) {
+ targetPath += OCFile.PATH_SEPARATOR
+ }
+
+ // auto rename, to allow copy
+ if (targetPath == srcPath) {
+ if (file.isFolder) {
+ targetPath = "$targetParentPath${file.fileName}"
+ }
+ targetPath = UploadFileOperation.getNewAvailableRemotePath(client, targetPath, null, false)
+
+ if (file.isFolder) {
+ targetPath += OCFile.PATH_SEPARATOR
+ }
+ }
+
+ result = CopyFileToAlbumRemoteOperation(srcPath, targetPath).execute(client)
+ }
+ }
+ return result
+ }
+}
diff --git a/app/src/main/java/com/owncloud/android/services/OperationsService.java b/app/src/main/java/com/owncloud/android/services/OperationsService.java
index d2b8a9c75dae..7386cbc84512 100644
--- a/app/src/main/java/com/owncloud/android/services/OperationsService.java
+++ b/app/src/main/java/com/owncloud/android/services/OperationsService.java
@@ -2,7 +2,7 @@
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2018-2023 Tobias Kaminsky
- * SPDX-FileCopyrightText: 2021 TSI-mc
+ * SPDX-FileCopyrightText: 2021-2025 TSI-mc
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz
* SPDX-FileCopyrightText: 2017-2018 Andy Scherzinger
* SPDX-FileCopyrightText: 2015 ownCloud Inc.
@@ -42,6 +42,9 @@
import com.owncloud.android.lib.common.operations.RemoteOperation;
import com.owncloud.android.lib.common.operations.RemoteOperationResult;
import com.owncloud.android.lib.common.utils.Log_OC;
+import com.owncloud.android.lib.resources.albums.CreateNewAlbumRemoteOperation;
+import com.owncloud.android.lib.resources.albums.RemoveAlbumRemoteOperation;
+import com.owncloud.android.lib.resources.albums.RenameAlbumRemoteOperation;
import com.owncloud.android.lib.resources.files.RestoreFileVersionRemoteOperation;
import com.owncloud.android.lib.resources.files.model.FileVersion;
import com.owncloud.android.lib.resources.shares.OCShare;
@@ -64,6 +67,7 @@
import com.owncloud.android.operations.UpdateShareInfoOperation;
import com.owncloud.android.operations.UpdateSharePermissionsOperation;
import com.owncloud.android.operations.UpdateShareViaLinkOperation;
+import com.owncloud.android.operations.albums.CopyFileToAlbumOperation;
import java.io.IOException;
import java.util.Optional;
@@ -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..e124c9639f81
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/activity/AlbumsPickerActivity.kt
@@ -0,0 +1,218 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.activity
+
+import android.content.Intent
+import android.content.res.Resources
+import android.os.Bundle
+import android.view.MenuItem
+import android.view.View
+import androidx.fragment.app.FragmentActivity
+import com.nextcloud.client.di.Injectable
+import com.owncloud.android.R
+import com.owncloud.android.databinding.FilesFolderPickerBinding
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.lib.common.operations.RemoteOperation
+import com.owncloud.android.lib.common.operations.RemoteOperationResult
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.lib.resources.albums.CreateNewAlbumRemoteOperation
+import com.owncloud.android.lib.resources.files.SearchRemoteOperation
+import com.owncloud.android.ui.activity.FolderPickerActivity.Companion.TAG_LIST_OF_FOLDERS
+import com.owncloud.android.ui.events.SearchEvent
+import com.owncloud.android.ui.fragment.FileFragment
+import com.owncloud.android.ui.fragment.GalleryFragment
+import com.owncloud.android.ui.fragment.OCFileListFragment
+import com.owncloud.android.ui.fragment.albums.AlbumsFragment
+import com.owncloud.android.utils.DisplayUtils
+import com.owncloud.android.utils.ErrorMessageAdapter
+
+class AlbumsPickerActivity : FileActivity(), FileFragment.ContainerActivity, OnEnforceableRefreshListener, Injectable {
+
+ private var captionText: String? = null
+
+ private var action: String? = null
+
+ private lateinit var folderPickerBinding: FilesFolderPickerBinding
+
+ private fun initBinding() {
+ folderPickerBinding = FilesFolderPickerBinding.inflate(layoutInflater)
+ setContentView(folderPickerBinding.root)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ Log_OC.d(TAG, "onCreate() start")
+
+ super.onCreate(savedInstanceState)
+
+ initBinding()
+ setupToolbar()
+ 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 7eec7b7984e3..5b4037c9e723 100644
--- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java
+++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java
@@ -1,7 +1,7 @@
/*
* Nextcloud - Android Client
*
- * SPDX-FileCopyrightText: 2021-2024 TSI-mc
+ * SPDX-FileCopyrightText: 2021-2025 TSI-mc
* SPDX-FileCopyrightText: 2020 Infomaniak Network SA
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz
* SPDX-FileCopyrightText: 2017 Tobias Kaminsky
@@ -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;
@@ -265,17 +268,15 @@ private void handleBottomNavigationViewClicks() {
exitSelectionMode();
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) {
handleSearchEvents(new SearchEvent("", SearchRemoteOperation.SearchType.FAVORITE_SEARCH), menuItemId);
} else if (menuItemId == R.id.nav_assistant && !(this instanceof ComposeActivity)) {
startComposeActivity(ComposeDestination.AssistantScreen, R.string.assistant_screen_top_bar_title);
} else if (menuItemId == R.id.nav_gallery) {
startPhotoSearch(menuItem.getItemId());
+ } else if (menuItemId == R.id.nav_album) {
+ replaceAlbumFragment();
}
// Remove extra icon from the action bar
@@ -550,7 +551,8 @@ private void onNavigationItemClicked(final MenuItem menuItem) {
!(((FileDisplayActivity) this).getLeftFragment() instanceof GalleryFragment) &&
!(((FileDisplayActivity) this).getLeftFragment() instanceof SharedListFragment) &&
!(((FileDisplayActivity) this).getLeftFragment() instanceof GroupfolderListFragment) &&
- !(((FileDisplayActivity) this).getLeftFragment() instanceof PreviewTextStringFragment)) {
+ !(((FileDisplayActivity) this).getLeftFragment() instanceof PreviewTextStringFragment) &&
+ !isAlbumsFragment() && !isAlbumItemsFragment()) {
showFiles(false, itemId == R.id.nav_personal_files);
((FileDisplayActivity) this).browseToRoot();
EventBus.getDefault().post(new ChangeMenuEvent());
@@ -574,6 +576,8 @@ private void onNavigationItemClicked(final MenuItem menuItem) {
menuItem.getItemId());
} else if (itemId == R.id.nav_gallery) {
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);
@@ -618,6 +622,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);
@@ -679,7 +703,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 54a3da2a46e5..c32e0469af32 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
@@ -1,7 +1,7 @@
/*
* Nextcloud - Android Client
*
- * SPDX-FileCopyrightText: 2023-2024 TSI-mc
+ * SPDX-FileCopyrightText: 2023-2025 TSI-mc
* SPDX-FileCopyrightText: 2023 Archontis E. Kostis
* SPDX-FileCopyrightText: 2019 Chris Narkiewicz
* SPDX-FileCopyrightText: 2018-2022 Tobias Kaminsky
@@ -81,6 +81,9 @@
import com.owncloud.android.lib.common.operations.RemoteOperationResult;
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode;
import com.owncloud.android.lib.common.utils.Log_OC;
+import com.owncloud.android.lib.resources.albums.CreateNewAlbumRemoteOperation;
+import com.owncloud.android.lib.resources.albums.RemoveAlbumRemoteOperation;
+import com.owncloud.android.lib.resources.albums.RenameAlbumRemoteOperation;
import com.owncloud.android.lib.resources.files.RestoreFileVersionRemoteOperation;
import com.owncloud.android.lib.resources.files.SearchRemoteOperation;
import com.owncloud.android.lib.resources.notifications.GetNotificationsRemoteOperation;
@@ -94,6 +97,7 @@
import com.owncloud.android.operations.RenameFileOperation;
import com.owncloud.android.operations.SynchronizeFileOperation;
import com.owncloud.android.operations.UploadFileOperation;
+import com.owncloud.android.operations.albums.CopyFileToAlbumOperation;
import com.owncloud.android.syncadapter.FileSyncAdapter;
import com.owncloud.android.ui.CompletionCallback;
import com.owncloud.android.ui.activity.fileDisplayActivity.OfflineFolderConflictManager;
@@ -116,6 +120,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.TransactionInterface;
@@ -444,19 +450,19 @@ private void checkOutdatedServer() {
DisplayUtils.showServerOutdatedSnackbar(this, Snackbar.LENGTH_LONG);
}
}
-
+
private void checkNotifications() {
new Thread(() -> {
try {
RemoteOperationResult> result = new GetNotificationsRemoteOperation()
.execute(clientFactory.createNextcloudClient(accountManager.getUser()));
-
+
if (result.isSuccess() && !result.getResultData().isEmpty()) {
runOnUiThread(() -> mNotificationButton.setVisibility(View.VISIBLE));
} else {
runOnUiThread(() -> mNotificationButton.setVisibility(View.GONE));
}
-
+
} catch (ClientFactory.CreationException e) {
Log_OC.e(TAG, "Could not fetch notifications!");
}
@@ -869,7 +875,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();
@@ -1066,6 +1073,12 @@ public void onBackPressed() {
return;
}
+ // pop back if current fragment is AlbumItemsFragment
+ if (isAlbumItemsFragment()) {
+ popBack();
+ return;
+ }
+
if (getLeftFragment() instanceof OCFileListFragment listOfFiles) {
if (isRoot(getCurrentDir())) {
finish();
@@ -1851,6 +1864,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);
}
}
@@ -2089,6 +2110,80 @@ 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..6ae9c7d0bbae 100644
--- a/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/GalleryAdapter.kt
@@ -6,7 +6,7 @@
* @author TSI-mc
* Copyright (C) 2022 Tobias Kaminsky
* Copyright (C) 2022 Nextcloud GmbH
- * Copyright (C) 2023 TSI-mc
+ * Copyright (C) 2023-2025 TSI-mc
*
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
@@ -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..0062840a6566 100644
--- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListDelegate.kt
@@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
+ * SPDX-FileCopyrightText: 2025 TSI-mc
* SPDX-FileCopyrightText: 2022 Tobias Kaminsky
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
@@ -35,6 +36,8 @@ import com.owncloud.android.ui.activity.ComponentsGetter
import com.owncloud.android.ui.activity.FolderPickerActivity
import com.owncloud.android.ui.fragment.GalleryFragment
import com.owncloud.android.ui.fragment.SearchType
+import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment
+import com.owncloud.android.ui.activity.AlbumsPickerActivity
import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface
import com.owncloud.android.utils.BitmapUtils
import com.owncloud.android.utils.DisplayUtils
@@ -115,8 +118,16 @@ class OCFileListDelegate(
)
imageView.setOnClickListener {
- ocFileListFragmentInterface.onItemClicked(file)
- GalleryFragment.setLastMediaItemPosition(galleryRowHolder.absoluteAdapterPosition)
+ // while picking media directly perform long click
+ if (context is AlbumsPickerActivity) {
+ ocFileListFragmentInterface.onLongItemClicked(
+ file
+ )
+ } else {
+ ocFileListFragmentInterface.onItemClicked(file)
+ GalleryFragment.setLastMediaItemPosition(galleryRowHolder.absoluteAdapterPosition)
+ AlbumItemsFragment.lastMediaItemPosition = galleryRowHolder.absoluteAdapterPosition
+ }
}
if (!hideItemOptions) {
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumFragmentInterface.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumFragmentInterface.kt
new file mode 100644
index 000000000000..652295388c68
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumFragmentInterface.kt
@@ -0,0 +1,14 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import com.owncloud.android.lib.resources.albums.PhotoAlbumEntry
+
+interface AlbumFragmentInterface {
+ fun onItemClick(album: PhotoAlbumEntry)
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumGridItemViewHolder.kt
new file mode 100644
index 000000000000..51eef93cf22d
--- /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 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.elyeproj.loaderviewlibrary.LoaderImageView
+import com.owncloud.android.databinding.AlbumsGridItemBinding
+
+internal class AlbumGridItemViewHolder(private var binding: AlbumsGridItemBinding) :
+ RecyclerView.ViewHolder(binding.root), AlbumItemViewHolder {
+ override val thumbnail: ImageView
+ get() = binding.thumbnail
+ override val shimmerThumbnail: LoaderImageView
+ get() = binding.thumbnailShimmer
+ override val albumName: TextView
+ get() = binding.Filename
+ override val albumInfo: TextView
+ get() = binding.fileInfo
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt
new file mode 100644
index 000000000000..a531404e8e99
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumItemViewHolder.kt
@@ -0,0 +1,19 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import android.widget.ImageView
+import android.widget.TextView
+import com.elyeproj.loaderviewlibrary.LoaderImageView
+
+interface AlbumItemViewHolder {
+ val thumbnail: ImageView
+ val shimmerThumbnail: LoaderImageView
+ val albumName: TextView
+ val albumInfo: TextView
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumListItemViewHolder.kt
new file mode 100644
index 000000000000..12892f6a424c
--- /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 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.adapter.albums
+
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.elyeproj.loaderviewlibrary.LoaderImageView
+import com.owncloud.android.databinding.AlbumsListItemBinding
+
+internal class AlbumListItemViewHolder(private var binding: AlbumsListItemBinding) :
+ RecyclerView.ViewHolder(binding.root), AlbumItemViewHolder {
+ override val thumbnail: ImageView
+ get() = binding.thumbnail
+ override val shimmerThumbnail: LoaderImageView
+ get() = binding.thumbnailShimmer
+ override val albumName: TextView
+ get() = binding.Filename
+ override val albumInfo: TextView
+ get() = binding.fileInfo
+}
diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt
new file mode 100644
index 000000000000..21f7322a659d
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/adapter/albums/AlbumsAdapter.kt
@@ -0,0 +1,111 @@
+/*
+ * 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.lib.resources.albums.PhotoAlbumEntry
+import com.owncloud.android.utils.DisplayUtils
+import com.owncloud.android.utils.theme.ViewThemeUtils
+
+@Suppress("LongParameterList")
+class AlbumsAdapter(
+ val context: Context,
+ private val storageManager: FileDataStorageManager?,
+ private val user: User,
+ private val albumFragmentInterface: AlbumFragmentInterface,
+ private val syncedFolderProvider: SyncedFolderProvider,
+ private val preferences: AppPreferences,
+ private val viewThemeUtils: ViewThemeUtils,
+ private val gridView: Boolean = true
+) :
+ RecyclerView.Adapter() {
+ private var albumList: MutableList = mutableListOf()
+ private val asyncTasks: MutableList = ArrayList()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ 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)
+ DisplayUtils.setThumbnail(
+ ocLocal,
+ gridViewHolder.thumbnail,
+ user,
+ storageManager,
+ asyncTasks,
+ gridView,
+ context,
+ gridViewHolder.shimmerThumbnail,
+ preferences,
+ viewThemeUtils,
+ syncedFolderProvider
+ )
+ } else {
+ gridViewHolder.thumbnail.setImageResource(R.drawable.file_image)
+ gridViewHolder.thumbnail.visibility = View.VISIBLE
+ gridViewHolder.shimmerThumbnail.visibility = View.GONE
+ }
+
+ holder.itemView.setOnClickListener { albumFragmentInterface.onItemClick(file) }
+ }
+
+ fun cancelAllPendingTasks() {
+ for (task in asyncTasks) {
+ task.cancel(true)
+ if (task.getMethod != null) {
+ Log_OC.d("AlbumsAdapter", "cancel: abort get method directly")
+ task.getMethod.abort()
+ }
+ }
+ asyncTasks.clear()
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ fun setAlbumItems(albumItems: List?) {
+ albumList.clear()
+ albumItems?.let {
+ 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..e394a5d3e918
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/dialog/CreateAlbumDialogFragment.kt
@@ -0,0 +1,202 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2025 TSI-mc
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.owncloud.android.ui.dialog
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.View
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import androidx.core.os.bundleOf
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.nextcloud.client.account.CurrentAccountProvider
+import com.nextcloud.client.di.Injectable
+import com.nextcloud.client.network.ConnectivityService
+import com.nextcloud.utils.extensions.typedActivity
+import com.owncloud.android.R
+import com.owncloud.android.databinding.EditBoxDialogBinding
+import com.owncloud.android.datamodel.FileDataStorageManager
+import com.owncloud.android.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..8644e34fb1c4 100644
--- a/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt
+++ b/app/src/main/java/com/owncloud/android/ui/fragment/ExtendedListFragment.kt
@@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
+ * SPDX-FileCopyrightText: 2025 TSI-mc
* SPDX-FileCopyrightText: 2025 Alper Ozturk
* SPDX-FileCopyrightText: 2022 Álvaro Brey
* SPDX-FileCopyrightText: 2018-2021 Tobias Kaminsky
@@ -165,7 +166,9 @@ open class ExtendedListFragment :
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- val item = menu.findItem(R.id.action_search)
+ // while picking Media files from Gallery Fragment through AlbumPickerActivity
+ // there will be no search option so it we have to return it
+ val item = menu.findItem(R.id.action_search) ?: return
searchView = 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..c8d36b950d4b 100644
--- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java
+++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.java
@@ -1,13 +1,14 @@
/*
* Nextcloud - Android Client
*
- * SPDX-FileCopyrightText: 2023 TSI-mc
+ * SPDX-FileCopyrightText: 2023-2025 TSI-mc
* SPDX-FileCopyrightText: 2019 Tobias Kaminsky
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH
* SPDX-License-Identifier: GPL-3.0-or-later AND AGPL-3.0-or-later
*/
package com.owncloud.android.ui.fragment;
+import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
@@ -15,6 +16,7 @@
import android.content.res.Configuration;
import android.os.AsyncTask;
import android.os.Bundle;
+import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -22,6 +24,7 @@
import android.view.View;
import android.view.ViewGroup;
+import com.nextcloud.client.network.ConnectivityService;
import com.nextcloud.utils.extensions.IntentExtensionsKt;
import com.owncloud.android.BuildConfig;
import com.owncloud.android.R;
@@ -37,9 +40,17 @@
import com.owncloud.android.ui.adapter.GalleryAdapter;
import com.owncloud.android.ui.asynctasks.GallerySearchTask;
import com.owncloud.android.ui.events.ChangeMenuEvent;
+import com.owncloud.android.ui.fragment.albums.AlbumsFragment;
+import com.owncloud.android.ui.activity.AlbumsPickerActivity;
+import com.owncloud.android.utils.DisplayUtils;
+
+import java.util.ArrayList;
+import java.util.Set;
import javax.inject.Inject;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
@@ -66,10 +77,15 @@ public class GalleryFragment extends OCFileListFragment implements GalleryFragme
private GalleryFragmentBottomSheetDialog galleryFragmentBottomSheetDialog;
@Inject FileDataStorageManager fileDataStorageManager;
+ @Inject ConnectivityService connectivityService;
private final static int maxColumnSizeLandscape = 5;
private final static int maxColumnSizePortrait = 2;
private int columnSize;
+ // required for Albums
+ private Set checkedFiles;
+ private boolean isFromAlbum; // when opened from Albums to add items
+
protected void setPhotoSearchQueryRunning(boolean value) {
this.photoSearchQueryRunning = value;
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);
+ }
+
+ // 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) {
+ // 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 b03f9c1496be..d2488a901a07 100644
--- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java
+++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java
@@ -1,7 +1,7 @@
/*
* Nextcloud - Android Client
*
- * SPDX-FileCopyrightText: 2023 TSI-mc
+ * SPDX-FileCopyrightText: 2023-2025 TSI-mc
* SPDX-FileCopyrightText: 2018-2023 Tobias Kaminsky
* SPDX-FileCopyrightText: 2022 Álvaro Brey
* SPDX-FileCopyrightText: 2020 Joris Bodin
@@ -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;
@@ -847,6 +848,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;
}
@@ -883,6 +895,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;
}
@@ -2241,6 +2257,14 @@ public void setFabVisible(final boolean visible) {
return;
}
+ // to hide the fab if user is on Albums Fragment
+ if (requireActivity() instanceof FileDisplayActivity fda
+ && (fda.isAlbumsFragment()
+ || fda.isAlbumItemsFragment())) {
+ mFabMain.hide();
+ return;
+ }
+
if (getActivity() != null) {
getActivity().runOnUiThread(() -> {
if (visible) {
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..ef18694346b1
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt
@@ -0,0 +1,1013 @@
+/*
+ * 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 androidx.activity.result.ActivityResult
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.DrawableRes
+import androidx.annotation.IdRes
+import androidx.annotation.StringRes
+import androidx.annotation.VisibleForTesting
+import androidx.core.content.ContextCompat
+import androidx.core.view.MenuHost
+import androidx.core.view.MenuProvider
+import androidx.drawerlayout.widget.DrawerLayout
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.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.getTypedActivity
+import com.nextcloud.utils.extensions.isDialogFragmentReady
+import com.owncloud.android.R
+import com.owncloud.android.databinding.ListFragmentBinding
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.datamodel.SyncedFolderProvider
+import com.owncloud.android.datamodel.ThumbnailsCacheManager
+import com.owncloud.android.datamodel.VirtualFolderType
+import com.owncloud.android.db.ProviderMeta
+import com.owncloud.android.lib.common.OwnCloudClient
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.lib.resources.albums.ReadAlbumItemsRemoteOperation
+import com.owncloud.android.lib.resources.albums.RemoveAlbumFileRemoteOperation
+import com.owncloud.android.lib.resources.albums.ToggleAlbumFavoriteRemoteOperation
+import com.owncloud.android.ui.activity.AlbumsPickerActivity
+import com.owncloud.android.ui.activity.AlbumsPickerActivity.Companion.intentForPickingMediaFiles
+import com.owncloud.android.ui.activity.FileActivity
+import com.owncloud.android.ui.activity.FileDisplayActivity
+import com.owncloud.android.ui.adapter.GalleryAdapter
+import com.owncloud.android.ui.dialog.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: ListFragmentBinding
+
+ @Inject
+ lateinit var viewThemeUtils: ViewThemeUtils
+
+ @Inject
+ lateinit var accountManager: UserAccountManager
+
+ @Inject
+ lateinit var clientFactory: ClientFactory
+
+ @Inject
+ lateinit var preferences: AppPreferences
+
+ @Inject
+ lateinit var syncedFolderProvider: SyncedFolderProvider
+
+ @Inject
+ lateinit var throttler: Throttler
+
+ private var mContainerActivity: FileFragment.ContainerActivity? = null
+
+ private var columnSize = 0
+
+ private lateinit var albumName: String
+ private var isNewAlbum: Boolean = false
+
+ private var mMultiChoiceModeListener: MultiChoiceModeListener? = null
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ try {
+ mContainerActivity = context as FileFragment.ContainerActivity
+ } catch (e: ClassCastException) {
+ throw IllegalArgumentException(
+ context.toString() + " must implement " +
+ FileFragment.ContainerActivity::class.java.simpleName,
+ e
+ )
+ }
+ arguments?.let {
+ albumName = it.getString(ARG_ALBUM_NAME) ?: ""
+ isNewAlbum = it.getBoolean(ARG_IS_NEW_ALBUM)
+ }
+ }
+
+ override fun onDetach() {
+ mContainerActivity = null
+ super.onDetach()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ columnSize = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ MAX_COLUMN_SIZE_LANDSCAPE
+ } else {
+ MAX_COLUMN_SIZE_PORTRAIT
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ binding = ListFragmentBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ 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()
+ }
+ }
+
+ 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() {
+ binding.listRoot.setEmptyView(binding.emptyList.emptyListView)
+ 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() {
+ mMultiChoiceModeListener?.exitSelectionMode()
+ initializeAdapter()
+ setEmptyListLoadingMessage()
+ 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()) {
+ setMessageForEmptyList(
+ R.string.file_list_empty_headline_server_search,
+ resources.getString(R.string.file_list_empty_gallery),
+ R.drawable.file_image,
+ false
+ )
+ }
+ populateList(ocFileList)
+ } else {
+ Log_OC.d(TAG, result?.logMessage)
+ // show error
+ setMessageForEmptyList(
+ R.string.file_list_empty_headline_server_search,
+ resources.getString(R.string.file_list_empty_gallery),
+ R.drawable.file_image,
+ false
+ )
+ }
+ hideRefreshLayoutLoader()
+ }
+ }
+ }
+
+ private fun hideRefreshLayoutLoader() {
+ binding.swipeContainingList.isRefreshing = false
+ }
+
+ private fun setEmptyListLoadingMessage() {
+ val fileActivity = this.getTypedActivity(FileActivity::class.java)
+ fileActivity?.connectivityService?.isNetworkAndServerAvailable { result: Boolean? ->
+ if (!result!!) return@isNetworkAndServerAvailable
+ binding.emptyList.emptyListViewHeadline.setText(R.string.file_list_loading)
+ binding.emptyList.emptyListViewText.text = ""
+ binding.emptyList.emptyListIcon.visibility = View.GONE
+ }
+ }
+
+ private fun initializeClient() {
+ if (client == null && optionalUser?.isPresent == true) {
+ try {
+ val user = optionalUser?.get()
+ client = clientFactory.create(user)
+ } catch (e: CreationException) {
+ Log_OC.e(TAG, "Error initializing client", e)
+ }
+ }
+ }
+
+ private fun initializeAdapter() {
+ initializeClient()
+ if (adapter == null) {
+ adapter = GalleryAdapter(
+ requireContext(),
+ accountManager.user,
+ this,
+ preferences,
+ mContainerActivity!!,
+ viewThemeUtils,
+ columnSize,
+ ThumbnailsCacheManager.getThumbnailDimension()
+ )
+ adapter?.setHasStableIds(true)
+ setUpActionMode()
+ }
+ binding.listRoot.adapter = adapter
+
+ lastMediaItemPosition?.let {
+ binding.listRoot.layoutManager?.scrollToPosition(it)
+ }
+ }
+
+ private fun setMessageForEmptyList(
+ @StringRes headline: Int,
+ message: String,
+ @DrawableRes icon: Int,
+ tintIcon: Boolean
+ ) {
+ binding.emptyList.emptyListViewHeadline.setText(headline)
+ binding.emptyList.emptyListViewText.text = message
+
+ if (tintIcon) {
+ if (context != null) {
+ binding.emptyList.emptyListIcon.setImageDrawable(
+ viewThemeUtils.platform.tintPrimaryDrawable(requireContext(), icon)
+ )
+ }
+ } else {
+ binding.emptyList.emptyListIcon.setImageResource(icon)
+ }
+
+ binding.emptyList.emptyListIcon.visibility = View.VISIBLE
+ binding.emptyList.emptyListViewText.visibility = View.VISIBLE
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (requireActivity() is FileDisplayActivity) {
+ (requireActivity() as FileDisplayActivity).setupToolbar()
+ (requireActivity() as FileDisplayActivity).supportActionBar?.let { actionBar ->
+ viewThemeUtils.files.themeActionBar(requireContext(), actionBar, albumName)
+ }
+ (requireActivity() as FileDisplayActivity).showSortListGroup(false)
+ (requireActivity() as FileDisplayActivity).setMainFabVisible(false)
+
+ // clear the subtitle while navigating to any other screen from Media screen
+ (requireActivity() as FileDisplayActivity).clearToolbarSubtitle()
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ adapter?.cancelAllPendingTasks()
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+
+ if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ columnSize = MAX_COLUMN_SIZE_LANDSCAPE
+ } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) {
+ columnSize = MAX_COLUMN_SIZE_PORTRAIT
+ }
+ adapter?.changeColumn(columnSize)
+ adapter?.notifyDataSetChanged()
+ }
+
+ override fun onDestroyView() {
+ 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..931df1ad605d
--- /dev/null
+++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt
@@ -0,0 +1,358 @@
+/*
+ * 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.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.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.lib.resources.albums.PhotoAlbumEntry
+import com.owncloud.android.lib.resources.albums.ReadAlbumsRemoteOperation
+import com.owncloud.android.ui.activity.FileDisplayActivity
+import com.owncloud.android.ui.adapter.albums.AlbumFragmentInterface
+import com.owncloud.android.ui.adapter.albums.AlbumsAdapter
+import com.owncloud.android.ui.dialog.CreateAlbumDialogFragment
+import com.owncloud.android.ui.fragment.FileFragment
+import com.owncloud.android.utils.theme.ViewThemeUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.Optional
+import javax.inject.Inject
+
+class AlbumsFragment : Fragment(), AlbumFragmentInterface, Injectable {
+
+ private var adapter: AlbumsAdapter? = null
+ private var client: OwnCloudClient? = null
+ private var optionalUser: Optional? = null
+
+ private lateinit var binding: AlbumsFragmentBinding
+
+ @Inject
+ lateinit var viewThemeUtils: ViewThemeUtils
+
+ @Inject
+ lateinit var accountManager: UserAccountManager
+
+ @Inject
+ lateinit var clientFactory: ClientFactory
+
+ @Inject
+ lateinit var preferences: AppPreferences
+
+ @Inject
+ lateinit var syncedFolderProvider: SyncedFolderProvider
+
+ 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()
+ binding.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.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..221ef85cf7a6 100755
--- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java
+++ b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java
@@ -1,7 +1,7 @@
/*
* Nextcloud - Android Client
*
- * SPDX-FileCopyrightText: 2023 TSI-mc
+ * SPDX-FileCopyrightText: 2023-2025 TSI-mc
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz
* SPDX-FileCopyrightText: 2018-2020 Tobias Kaminsky
* SPDX-FileCopyrightText: 2018-2020 Andy Scherzinger
@@ -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..052cc7a2fed3 100644
--- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt
+++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImagePagerAdapter.kt
@@ -1,6 +1,7 @@
/*
* Nextcloud - Android Client
*
+ * SPDX-FileCopyrightText: 2025 TSI-mc
* SPDX-FileCopyrightText: 2023 Alper Ozturk
* SPDX-FileCopyrightText: 2018 Tobias Kaminsky
* SPDX-FileCopyrightText: 2020 Chris Narkiewicz
@@ -93,6 +94,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/layout/albums_fragment.xml b/app/src/main/res/layout/albums_fragment.xml
new file mode 100644
index 000000000000..14469cf62188
--- /dev/null
+++ b/app/src/main/res/layout/albums_fragment.xml
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/albums_grid_item.xml b/app/src/main/res/layout/albums_grid_item.xml
new file mode 100644
index 000000000000..446422e56c3b
--- /dev/null
+++ b/app/src/main/res/layout/albums_grid_item.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/albums_list_item.xml b/app/src/main/res/layout/albums_list_item.xml
new file mode 100644
index 000000000000..d5e11e2994a8
--- /dev/null
+++ b/app/src/main/res/layout/albums_list_item.xml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/bottom_navigation_menu.xml b/app/src/main/res/menu/bottom_navigation_menu.xml
index 74abc6dbc257..3943106dfed5 100644
--- a/app/src/main/res/menu/bottom_navigation_menu.xml
+++ b/app/src/main/res/menu/bottom_navigation_menu.xml
@@ -1,6 +1,7 @@
@@ -30,4 +31,10 @@
android:icon="@drawable/selector_media"
android:title="@string/bottom_navigation_menu_media_label"/>
+
+
diff --git a/app/src/main/res/menu/custom_menu_placeholder.xml b/app/src/main/res/menu/custom_menu_placeholder.xml
index f84383a573de..0e85e2230553 100644
--- a/app/src/main/res/menu/custom_menu_placeholder.xml
+++ b/app/src/main/res/menu/custom_menu_placeholder.xml
@@ -2,12 +2,18 @@