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