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 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/fragment_create_album.xml b/app/src/main/res/menu/fragment_create_album.xml new file mode 100644 index 000000000000..c1397b8d652c --- /dev/null +++ b/app/src/main/res/menu/fragment_create_album.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/partial_drawer_entries.xml b/app/src/main/res/menu/partial_drawer_entries.xml index 8cb3bb93b88f..a2d73c181abc 100644 --- a/app/src/main/res/menu/partial_drawer_entries.xml +++ b/app/src/main/res/menu/partial_drawer_entries.xml @@ -3,6 +3,7 @@ ~ Nextcloud - Android Client ~ ~ SPDX-FileCopyrightText: 2016-2025 Andy Scherzinger + ~ SPDX-FileCopyrightText: 2025 TSI-mc ~ SPDX-FileCopyrightText: 2018-2024 Tobias Kaminsky ~ SPDX-FileCopyrightText: 2024 Alper Ozturk ~ SPDX-FileCopyrightText: 2024 Bhavesh Kumawat @@ -62,6 +63,11 @@ android:icon="@drawable/selector_media" android:orderInCategory="1" android:title="@string/drawer_item_gallery" /> + ~ SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors ~ SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only --> @@ -1348,4 +1349,23 @@ Sync duplication Could not load content The device is likely not connected to the internet + Album + Create album + New album + Rename album + Enter your new Album name + Album name cannot be empty + Album name cannot start with invalid char + Add more + Rename Album + Delete Album + Failed to delete few of the files. + %d Items — %s + Album already exists + Pick Album + Pick Media Files + Create Albums for your Photos + You can organize all your photos in as many albums as you like. You haven\'t created an album yet. + Add to Album + File added successfully diff --git a/build.gradle b/build.gradle index 3ef523a43c9b..7f79488e66be 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ */ buildscript { ext { - androidLibraryVersion ="1d61d163cde17e3364a441531dfd0d3075964419" + androidLibraryVersion ="f99e79c38f0a7ba5fa94d731e3feb6ae4fadb41b" androidCommonLibraryVersion = "0.27.0" androidPluginVersion = "8.11.0" androidxMediaVersion = "1.5.1"