diff --git a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java index 79fd7fa7ddfe..fc790b736b68 100644 --- a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2024 TSI-mc + * SPDX-FileCopyrightText: 2024-2026 TSI-mc * SPDX-FileCopyrightText: 2020 Chris Narkiewicz * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ @@ -118,6 +118,7 @@ import com.owncloud.android.ui.fragment.SharedListFragment; import com.owncloud.android.ui.fragment.UnifiedSearchFragment; import com.owncloud.android.ui.fragment.albums.AlbumItemsFragment; +import com.owncloud.android.ui.fragment.albums.AlbumSharingBottomSheet; import com.owncloud.android.ui.fragment.albums.AlbumsFragment; import com.owncloud.android.ui.fragment.community.CommunityFragment; import com.owncloud.android.ui.fragment.contactsbackup.BackupFragment; @@ -536,4 +537,7 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract AlbumItemActionsBottomSheet albumItemActionsBottomSheet(); + + @ContributesAndroidInjector + abstract AlbumSharingBottomSheet albumSharingBottomSheet(); } diff --git a/app/src/main/java/com/nextcloud/client/utils/IntentUtil.kt b/app/src/main/java/com/nextcloud/client/utils/IntentUtil.kt index 1f3dfa2f21fd..afde9397f8bf 100644 --- a/app/src/main/java/com/nextcloud/client/utils/IntentUtil.kt +++ b/app/src/main/java/com/nextcloud/client/utils/IntentUtil.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 TSI-mc * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2022 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only @@ -10,7 +11,11 @@ package com.nextcloud.client.utils import android.content.Context import android.content.Intent import android.net.Uri +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentActivity import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.dialog.ShareLinkToDialog.Companion.newInstance object IntentUtil { @@ -39,4 +44,16 @@ object IntentUtil { private fun getExposedFileUris(context: Context, files: Array): ArrayList = ArrayList(files.map { it.getExposedFileUri(context) }) + + @JvmStatic + fun showShareLinkDialog(activity: FragmentActivity, link: String?) { + // Create dialog to allow the user choose an app to send the link + val intentToShareLink = Intent(Intent.ACTION_SEND) + + intentToShareLink.putExtra(Intent.EXTRA_TEXT, link) + intentToShareLink.setType("text/plain") + + val chooserDialog: DialogFragment = newInstance(intentToShareLink, activity.packageName) + chooserDialog.show(activity.supportFragmentManager, FileDisplayActivity.FTAG_CHOOSER_DIALOG) + } } diff --git a/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt index b5b313f895ac..50f1a30f2c02 100644 --- a/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt +++ b/app/src/main/java/com/nextcloud/ui/albumItemActions/AlbumItemAction.kt @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2025 TSI-mc + * SPDX-FileCopyrightText: 2025-2026 TSI-mc * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -21,6 +21,7 @@ enum class AlbumItemAction(val id: Int, val titleId: Int, val iconId: Int) { R.drawable.file_image ), RENAME_ALBUM(R.id.action_rename_file, R.string.album_rename, R.drawable.ic_edit), + SHARE_ALBUM(R.id.action_share_album, R.string.album_share, R.drawable.ic_share), DELETE_ALBUM(R.id.action_delete, R.string.album_delete, R.drawable.ic_delete); companion object { @@ -29,6 +30,7 @@ enum class AlbumItemAction(val id: Int, val titleId: Int, val iconId: Int) { UPLOAD_FROM_CAMERA_ROLL, SELECT_IMAGES_FROM_ACCOUNT, RENAME_ALBUM, + SHARE_ALBUM, DELETE_ALBUM ) } diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt b/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt index e8881c8751b7..843254be8e86 100644 --- a/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt +++ b/app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 TSI-mc * SPDX-FileCopyrightText: 2025 Alper Ozturk * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2022 Nextcloud GmbH @@ -13,6 +14,7 @@ import androidx.annotation.IdRes import androidx.annotation.StringRes import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.MimeTypeUtil enum class FileAction( @param:IdRes val id: Int, @@ -62,7 +64,10 @@ enum class FileAction( PIN_TO_HOMESCREEN(R.id.action_pin_to_homescreen, R.string.pin_home, R.drawable.add_to_home_screen), // Retry for offline operation - RETRY(R.id.action_retry, R.string.retry, R.drawable.ic_retry); + RETRY(R.id.action_retry, R.string.retry, R.drawable.ic_retry), + + // Add to Album operation for image and video files + ADD_TO_ALBUM(R.id.action_add_to_album, R.string.add_to_album, R.drawable.ic_album); constructor(id: Int, title: Int) : this(id, title, null) @@ -79,6 +84,7 @@ enum class FileAction( SEE_DETAILS, LOCK_FILE, RENAME_FILE, + ADD_TO_ALBUM, MOVE_OR_COPY, DOWNLOAD_FILE, EXPORT_FILE, @@ -206,6 +212,10 @@ enum class FileAction( result.add(R.id.action_edit) } + if (files.any { !MimeTypeUtil.isImage(it) && !MimeTypeUtil.isVideo(it) }) { + result.add(R.id.action_add_to_album) + } + if (files.any { it.isRecommendedFile }) { val allowedForRecommended = setOf( R.id.action_see_details, diff --git a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java index 6665598c0a4b..037bd129da9e 100644 --- a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java +++ b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 TSI-mc * SPDX-FileCopyrightText: 2023 Alper Ozturk * SPDX-FileCopyrightText: 2019-2023 Tobias Kaminsky * SPDX-FileCopyrightText: 2022 Álvaro Brey Vilas @@ -169,6 +170,7 @@ private List filter(boolean inSingleFileFragment) { filterUnsetEncrypted(toHide, endToEndEncryptionEnabled); filterSetPictureAs(toHide); filterStream(toHide); + filterAddToAlbum(toHide); filterLock(toHide, fileLockingEnabled); filterUnlock(toHide, fileLockingEnabled); filterPinToHome(toHide); @@ -399,6 +401,17 @@ private void filterStream(List toHide) { } } + private void filterAddToAlbum(List toHide) { + if (files.isEmpty() || containsEncryptedFile()) { + toHide.add(R.id.action_add_to_album); + return; + } + OCFile file = files.iterator().next(); + if(!MimeTypeUtil.isImage(file) && !MimeTypeUtil.isVideo(file)){ + toHide.add(R.id.action_add_to_album); + } + } + private boolean anyFileSynchronizing() { boolean synchronizing = false; if (componentsGetter != null && !files.isEmpty() && user != null) { 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 4353373a0f0c..6b4cbf3de487 100644 --- a/app/src/main/java/com/owncloud/android/services/OperationsService.java +++ b/app/src/main/java/com/owncloud/android/services/OperationsService.java @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2018-2023 Tobias Kaminsky - * SPDX-FileCopyrightText: 2021-2025 TSI-mc + * SPDX-FileCopyrightText: 2021-2026 TSI-mc * SPDX-FileCopyrightText: 2019 Chris Narkiewicz * SPDX-FileCopyrightText: 2017-2018 Andy Scherzinger * SPDX-FileCopyrightText: 2015 ownCloud Inc. @@ -43,6 +43,7 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult; import com.owncloud.android.lib.common.utils.Log_OC; import com.owncloud.android.lib.resources.albums.CreateNewAlbumRemoteOperation; +import com.owncloud.android.lib.resources.albums.PublicShareLinkAlbumRemoteOperation; import com.owncloud.android.lib.resources.albums.RemoveAlbumRemoteOperation; import com.owncloud.android.lib.resources.albums.RenameAlbumRemoteOperation; import com.owncloud.android.lib.resources.files.RestoreFileVersionRemoteOperation; @@ -109,6 +110,7 @@ public class OperationsService extends Service { public static final String EXTRA_IN_BACKGROUND = "IN_BACKGROUND"; public static final String EXTRA_FILES_DOWNLOAD_LIMIT = "FILES_DOWNLOAD_LIMIT"; public static final String EXTRA_SHARE_ATTRIBUTES = "SHARE_ATTRIBUTES"; + public static final String EXTRA_CREATE_ALBUM_SHARE = "CREATE_ALBUM_SHARE"; public static final String ACTION_CREATE_SHARE_VIA_LINK = "CREATE_SHARE_VIA_LINK"; public static final String ACTION_CREATE_SECURE_FILE_DROP = "CREATE_SECURE_FILE_DROP"; @@ -135,6 +137,7 @@ public class OperationsService extends Service { 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"; + public static final String ACTION_PUBLIC_SHARE_LINK_ALBUM = "PUBLIC_SHARE_LINK_ALBUM"; private ServiceHandler mOperationsHandler; private OperationsServiceBinder mOperationsBinder; @@ -809,6 +812,12 @@ private Pair newOperation(Intent operationIntent) { operation = new RemoveAlbumRemoteOperation(albumNameToRemove); break; + case ACTION_PUBLIC_SHARE_LINK_ALBUM: + String albmName = operationIntent.getStringExtra(EXTRA_ALBUM_NAME); + boolean isCreateShare = operationIntent.getBooleanExtra(EXTRA_CREATE_ALBUM_SHARE, false); + operation = new PublicShareLinkAlbumRemoteOperation(albmName, isCreateShare); + 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 index 31ac71189202..f706b8d23c82 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/AlbumsPickerActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/AlbumsPickerActivity.kt @@ -43,6 +43,9 @@ class AlbumsPickerActivity : private lateinit var folderPickerBinding: FilesFolderPickerBinding + private var targetFilePaths: ArrayList? = null + private var albumName: String? = null + private fun initBinding() { folderPickerBinding = FilesFolderPickerBinding.inflate(layoutInflater) setContentView(folderPickerBinding.root) @@ -57,6 +60,7 @@ class AlbumsPickerActivity : setupToolbar() setupAction() setupActionBar() + initExtras() if (savedInstanceState == null) { createFragments() @@ -79,6 +83,11 @@ class AlbumsPickerActivity : } } + private fun initExtras() { + targetFilePaths = intent.getStringArrayListExtra(EXTRA_FILE_PATHS) + albumName = intent.getStringExtra(EXTRA_ALBUM_NAME) + } + private fun setupAction() { action = intent.getStringExtra(EXTRA_ACTION) setupUIForChooseButton() @@ -180,6 +189,19 @@ class AlbumsPickerActivity : } } + fun addFilesToAlbum(albumName: String?) { + targetFilePaths?.let { + fileOperationsHelper.albumCopyFiles(it, albumName) + } + } + + fun addFilesToAlbum(files: Collection) { + val paths: List = files.map { it.remotePath } + albumName?.let { + fileOperationsHelper.albumCopyFiles(paths, it) + } + } + override fun showDetails(file: OCFile?) = Unit override fun showDetails(file: OCFile?, activeTab: Int) = Unit @@ -192,19 +214,22 @@ class AlbumsPickerActivity : 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") + private val EXTRA_FILE_PATHS = FolderPickerActivity::class.java.canonicalName?.plus(".EXTRA_FILE_PATHS") + private val EXTRA_ALBUM_NAME = FolderPickerActivity::class.java.canonicalName?.plus(".EXTRA_ALBUM_NAME") 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 = + fun intentForPickingAlbum(context: FragmentActivity, paths: ArrayList): Intent = Intent(context, AlbumsPickerActivity::class.java).apply { putExtra(EXTRA_ACTION, CHOOSE_ALBUM) + putStringArrayListExtra(EXTRA_FILE_PATHS, paths) } - fun intentForPickingMediaFiles(context: FragmentActivity): Intent = + fun intentForPickingMediaFiles(context: FragmentActivity, albumName: String): Intent = Intent(context, AlbumsPickerActivity::class.java).apply { putExtra(EXTRA_ACTION, CHOOSE_MEDIA_FILES) + putExtra(EXTRA_ALBUM_NAME, albumName) } } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index e5e18320a3a4..0c629a3152b1 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -99,6 +99,7 @@ import com.owncloud.android.lib.common.operations.RemoteOperation import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.albums.CreateNewAlbumRemoteOperation +import com.owncloud.android.lib.resources.albums.PublicShareLinkAlbumRemoteOperation import com.owncloud.android.lib.resources.albums.RemoveAlbumRemoteOperation import com.owncloud.android.lib.resources.albums.RenameAlbumRemoteOperation import com.owncloud.android.lib.resources.files.RestoreFileVersionRemoteOperation @@ -1394,7 +1395,6 @@ class FileDisplayActivity : highlightNavigationViewItem(menuItemId) } - if (SettingsActivity.isBackPressed) { Log_OC.d(TAG, "User returned from settings activity, skipping reset content logic") return @@ -2166,6 +2166,10 @@ class FileDisplayActivity : is RemoveAlbumRemoteOperation -> { albumOperationListener.onRemoveAlbumOperationFinish(operation, result) } + + is PublicShareLinkAlbumRemoteOperation -> { + albumOperationListener.onAlbumPublicLinkOperationFinish(operation, result) + } } } 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 index 9f7ab0e00325..ec6aa8302e37 100644 --- 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 @@ -20,7 +20,6 @@ import com.owncloud.android.databinding.AlbumsGridItemBinding import com.owncloud.android.databinding.AlbumsListItemBinding import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.datamodel.SyncedFolderProvider import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.albums.PhotoAlbumEntry @@ -62,6 +61,13 @@ class AlbumsAdapter( DisplayUtils.getDateByPattern(file.createdDate, "MMM yyyy") ) + gridViewHolder.albumName.setCompoundDrawablesWithIntrinsicBounds( + 0, + 0, + if (file.collaborators.isNotEmpty()) R.drawable.ic_share else 0, + 0 + ) + if (file.lastPhoto > 0) { var ocLocal = storageManager?.getFileByLocalId(file.lastPhoto) if (ocLocal == null) { diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt index 8fb2821b8c59..f8cb405828c5 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/GalleryFragment.kt @@ -8,7 +8,6 @@ */ package com.owncloud.android.ui.fragment -import android.app.Activity import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -21,8 +20,6 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup -import androidx.activity.result.ActivityResult -import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.MenuHost import androidx.core.view.MenuProvider @@ -30,7 +27,6 @@ import androidx.lifecycle.Lifecycle import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.nextcloud.client.network.ConnectivityService import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getTypedActivity import com.owncloud.android.BuildConfig @@ -48,18 +44,12 @@ 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.GalleryFragmentBottomSheetDialog.MediaState -import com.owncloud.android.ui.fragment.albums.AlbumsFragment -import com.owncloud.android.utils.DisplayUtils import kotlinx.coroutines.Job -import javax.inject.Inject -@Suppress("ForbiddenComment", "ReturnCount", "MagicNumber", "MaxLineLength") +@Suppress("ForbiddenComment", "ReturnCount", "MagicNumber", "MaxLineLength", "TooManyFunctions") class GalleryFragment : OCFileListFragment(), GalleryFragmentBottomSheetActions { - - @Inject - lateinit var connectivityService: ConnectivityService var isPhotoSearchQueryRunning: Boolean = false private var photoSearchTask: Job? = null private var endDate: Long = 0 @@ -72,7 +62,6 @@ class GalleryFragment : private set // required for Albums - private var checkedFiles = setOf() private var isFromAlbum = false // when opened from Albums to add items override fun onCreate(savedInstanceState: Bundle?) { @@ -442,61 +431,11 @@ class GalleryFragment : adapter?.markAsFavorite(remotePath, favorite) } - private val activityResult: ActivityResultLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { intentResult: ActivityResult -> - if (Activity.RESULT_OK == intentResult.resultCode) { - if (Activity.RESULT_OK == intentResult.resultCode) { - intentResult.data?.let { - val albumName = it.getStringExtra(AlbumsFragment.ARG_SELECTED_ALBUM_NAME) - Log_OC.e(TAG, "Selected album name: $albumName") - addFilesToAlbum(albumName) - } - } - } - } - fun addImagesToAlbum(checkedFiles: Set) { - this.checkedFiles = checkedFiles if (isFromAlbum) { - addFilesToAlbum(null) - } else { - activityResult.launch(AlbumsPickerActivity.intentForPickingAlbum(requireActivity())) - } - } - - private fun addFilesToAlbum(albumName: String?) { - connectivityService.isNetworkAndServerAvailable { result -> - if (result) { - val files = checkedFiles - if (files.isEmpty()) { - return@isNetworkAndServerAvailable - } - - val paths = files.map { it.remotePath }.toCollection(ArrayList()) - - checkedFiles = emptySet() - exitSelectionMode() - - if (!albumName.isNullOrEmpty()) { - mContainerActivity - .getFileOperationsHelper() - .albumCopyFiles(paths, albumName) - } else { - val resultIntent = Intent().apply { - putStringArrayListExtra( - AlbumsPickerActivity.EXTRA_MEDIA_FILES_PATH, - paths - ) - } - requireActivity().setResult(Activity.RESULT_OK, resultIntent) - requireActivity().finish() - } - } else { - DisplayUtils.showSnackMessage( - requireActivity(), - getString(R.string.offline_mode) - ) - } + getTypedActivity(AlbumsPickerActivity::class.java)?.addFilesToAlbum(checkedFiles) + exitSelectionMode() + requireActivity().finish() } } 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 d2de4463d0bd..e70bb08b1424 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 @@ -2,7 +2,7 @@ * Nextcloud - Android Client * * SPDX-FileCopyrightText: 2026 Philipp Hasper - * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2023-2026 TSI-mc * SPDX-FileCopyrightText: 2018-2023 Tobias Kaminsky * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2020 Joris Bodin @@ -876,13 +876,13 @@ public boolean onCreateActionMode(ActionMode mode, Menu menu) { 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); + + final MenuItem addAlbumItem = menu.findItem(R.id.add_to_album); + // show add to album button when picking files from media to add to album + addAlbumItem.setVisible(true); } } @@ -1460,6 +1460,10 @@ public boolean onFileActionChosen(@IdRes final int itemId, Set checkedFi return true; } else if (itemId == R.id.action_lock_file) { // TODO call lock API + } else if (itemId == R.id.action_add_to_album) { + mContainerActivity.getFileOperationsHelper().addFileToAlbum(checkedFiles); + exitSelectionMode(); + return true; } return false; 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 index 094b8836d769..366edec8ee1b 100644 --- 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 @@ -27,8 +27,6 @@ import android.view.View import android.view.ViewGroup import android.widget.AbsListView import android.widget.RelativeLayout -import androidx.activity.result.ActivityResult -import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes import androidx.annotation.IdRes @@ -53,6 +51,7 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.network.ClientFactory import com.nextcloud.client.network.ClientFactory.CreationException import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.client.utils.IntentUtil import com.nextcloud.client.utils.Throttler import com.nextcloud.ui.albumItemActions.AlbumItemActionsBottomSheet import com.nextcloud.ui.fileactions.FileActionsBottomSheet @@ -67,13 +66,14 @@ import com.owncloud.android.datamodel.VirtualFolderType import com.owncloud.android.db.ProviderMeta import com.owncloud.android.lib.common.OwnCloudClient import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.albums.PhotoAlbumEntry +import com.owncloud.android.lib.resources.albums.ReadAlbumsRemoteOperation import com.owncloud.android.lib.resources.albums.RemoveAlbumFileRemoteOperation import com.owncloud.android.lib.resources.albums.ToggleAlbumFavoriteRemoteOperation import com.owncloud.android.lib.resources.files.model.RemoteFile import com.owncloud.android.lib.resources.status.Type import com.owncloud.android.operations.albums.ReadAlbumItemsOperation import com.owncloud.android.ui.activity.AlbumsPickerActivity -import com.owncloud.android.ui.activity.AlbumsPickerActivity.Companion.intentForPickingMediaFiles import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.activity.FileDisplayActivity import com.owncloud.android.ui.adapter.GalleryAdapter @@ -86,12 +86,12 @@ import com.owncloud.android.ui.helpers.UriUploader import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface import com.owncloud.android.ui.preview.PreviewImageFragment import com.owncloud.android.ui.preview.PreviewMediaActivity.Companion.canBePreviewed +import com.owncloud.android.utils.ClipboardUtil import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.ErrorMessageAdapter import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.onEach @@ -108,6 +108,7 @@ import javax.inject.Inject class AlbumItemsFragment : Fragment(), OCFileListFragmentInterface, + AlbumSharingBottomSheetActions, Injectable { private var adapter: GalleryAdapter? = null @@ -144,9 +145,14 @@ class AlbumItemsFragment : private var mMultiChoiceModeListener: MultiChoiceModeListener? = null private var albumRemoteFileList = listOf() + private var albumsOCFileList = mutableListOf() private val refreshFlow = MutableSharedFlow(extraBufferCapacity = 1) + private var photoAlbumEntry: PhotoAlbumEntry? = null + + private var albumSharingBottomSheet: AlbumSharingBottomSheet? = null + private lateinit var addMediaFab: FloatingActionButton override fun onAttach(context: Context) { @@ -301,6 +307,23 @@ class AlbumItemsFragment : } } + private fun openAlbumSharingBottomSheet() { + throttler.run("albumSharingSheet") { + photoAlbumEntry?.let { + val supportFragmentManager = requireActivity().supportFragmentManager + + albumSharingBottomSheet = + AlbumSharingBottomSheet.newInstance( + it, + albumsOCFileList.take(AlbumSharingBottomSheet.IMAGE_COLLAGE_MAX_LIMIT), + this + ) + + albumSharingBottomSheet?.show(supportFragmentManager, "album_sharing_sheet") + } + } + } + private fun onAlbumActionChosen(@IdRes itemId: Int): Boolean = when (itemId) { R.id.action_upload_from_camera_roll -> { addFromCameraRoll() @@ -321,6 +344,11 @@ class AlbumItemsFragment : true } + R.id.action_share_album -> { + openAlbumSharingBottomSheet() + true + } + R.id.action_delete -> { showConfirmationDialog(true, null) true @@ -370,7 +398,7 @@ class AlbumItemsFragment : for (remoteFile in albumRemoteFileList) { val ocFile = mContainerActivity?.storageManager?.getFileByLocalId(remoteFile.localId) ocFile?.let { - ocFileList.add(it) + albumsOCFileList.add(it) val cv = ContentValues() cv.put(ProviderMeta.ProviderTableMeta.VIRTUAL_TYPE, VirtualFolderType.ALBUM.toString()) @@ -384,7 +412,7 @@ class AlbumItemsFragment : } withContext(Dispatchers.Main) { if (result?.isSuccess == true && result.resultData != null) { - if (result.resultData.isEmpty() || ocFileList.isEmpty()) { + if (result.resultData.isEmpty() || albumsOCFileList.isEmpty()) { setMessageForEmptyList( R.string.file_list_empty_headline_server_search, resources.getString(R.string.file_list_empty_gallery), @@ -392,7 +420,7 @@ class AlbumItemsFragment : false ) } - populateList(ocFileList) + populateList(albumsOCFileList) } else { Log_OC.d(TAG, result?.logMessage) // show error @@ -403,11 +431,53 @@ class AlbumItemsFragment : false ) } + + // refresh album meta data to update share id + refreshAlbumMetaData() + hideRefreshLayoutLoader() } } } + // will also be called from FileDisplayActivity when PublicShareLinkAlbumRemoteOperation completed + // to refresh and update the album sharing info + fun refreshAlbumMetaData() { + lifecycleScope.launch(Dispatchers.IO) { + val albumsRemoteOperation = ReadAlbumsRemoteOperation(albumName) + val result = client?.let { albumsRemoteOperation.execute(it) } + withContext(Dispatchers.Main) { + if (result?.isSuccess == true && result.resultData != null) { + photoAlbumEntry = if (!result.resultData.isEmpty()) { + result.resultData[0] + } else { + // no info + null + } + } else { + Log_OC.d(TAG, result?.logMessage) + } + + sendRefreshedShareIdToAlbumsSharingSheet() + } + } + } + + private fun sendRefreshedShareIdToAlbumsSharingSheet() { + if (!isAdded || isDetached) return + + albumSharingBottomSheet + ?.takeIf { it.isAdded && it.isVisible } + ?.run { + updateShareId( + photoAlbumEntry + ?.collaborators + ?.firstOrNull() + ?.id + ) + } + } + private fun hideRefreshLayoutLoader() { binding.swipeContainingList.isRefreshing = false } @@ -643,7 +713,8 @@ class AlbumItemsFragment : R.id.action_send_share_file, R.id.action_see_details, R.id.action_rename_file, - R.id.action_pin_to_homescreen + R.id.action_pin_to_homescreen, + R.id.action_add_to_album ) ) } @@ -1069,30 +1140,8 @@ class AlbumItemsFragment : } } - private val activityResult: ActivityResultLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { intentResult: ActivityResult -> - if (RESULT_OK == intentResult.resultCode) { - intentResult.data?.let { - val paths = it.getStringArrayListExtra(AlbumsPickerActivity.EXTRA_MEDIA_FILES_PATH) - paths?.let { p -> - addMediaToAlbum(p.toMutableList()) - } - } - } - } - private fun openGalleryToAddMedia() { - activityResult.launch(intentForPickingMediaFiles(requireActivity())) - } - - private fun addMediaToAlbum(filePaths: MutableList) { - viewLifecycleOwner.lifecycleScope.launch { - // short delay to let other transactions finish - // else showLoadingDialog will throw exception - delay(SLEEP_DELAY) - mContainerActivity?.fileOperationsHelper?.albumCopyFiles(filePaths, albumName) - } + requireActivity().startActivity(AlbumsPickerActivity.intentForPickingMediaFiles(requireActivity(), albumName)) } fun refreshData() { @@ -1150,17 +1199,42 @@ class AlbumItemsFragment : urisToUpload = streamsToUpload, uploadPath = remotePath, user = optionalUser.get(), - behaviour = FileUploadWorker.LOCAL_BEHAVIOUR_COPY, + behaviour = FileUploadWorker.LOCAL_BEHAVIOUR_COPY, showWaitingDialog = false, // Not show waiting dialog while file is being copied from private storage copyTmpTaskListener = null, // Not needed copy temp task listener, fileDisplayNameTransformer = null, - albumName = albumName, + albumName = albumName ) uploader.uploadUris() } } + override fun createShare() { + mContainerActivity?.getFileOperationsHelper()?.albumPublicShareLink(albumName, true) + } + + override fun removeShare() { + mContainerActivity?.getFileOperationsHelper()?.albumPublicShareLink(albumName, false) + } + + override fun copyShareLink() { + ClipboardUtil.copyToClipboard(requireActivity(), getShareLink()) + } + + override fun shareAlbumLink() { + IntentUtil.showShareLinkDialog(requireActivity(), getShareLink()) + } + + private fun getShareLink(): String? { + photoAlbumEntry?.let { + if (it.collaborators.isNotEmpty()) { + return it.collaborators[0].shareLink + } + } + return null + } + companion object { val TAG: String = AlbumItemsFragment::class.java.simpleName diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumOperationListener.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumOperationListener.kt index dc77887643cf..7d92272b5c76 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumOperationListener.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumOperationListener.kt @@ -13,6 +13,7 @@ import com.owncloud.android.lib.common.operations.RemoteOperation import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.albums.CreateNewAlbumRemoteOperation +import com.owncloud.android.lib.resources.albums.PublicShareLinkAlbumRemoteOperation import com.owncloud.android.lib.resources.albums.RemoveAlbumRemoteOperation import com.owncloud.android.lib.resources.albums.RenameAlbumRemoteOperation import com.owncloud.android.operations.albums.CopyFileToAlbumOperation @@ -73,10 +74,7 @@ class AlbumOperationListener(private val activity: FileDisplayActivity) { } } - fun onCreateAlbumOperationFinish( - operation: CreateNewAlbumRemoteOperation, - result: RemoteOperationResult<*> - ) { + fun onCreateAlbumOperationFinish(operation: CreateNewAlbumRemoteOperation, result: RemoteOperationResult<*>) { if (result.isSuccess) { val fragment = activity.supportFragmentManager.findFragmentByTag(AlbumsFragment.TAG) if (fragment is AlbumsFragment) { @@ -95,6 +93,21 @@ class AlbumOperationListener(private val activity: FileDisplayActivity) { } } + fun onAlbumPublicLinkOperationFinish( + operation: PublicShareLinkAlbumRemoteOperation, + result: RemoteOperationResult<*> + ) { + if (result.isSuccess) { + val fragment = activity.supportFragmentManager.findFragmentByTag(AlbumItemsFragment.TAG) + if (fragment is AlbumItemsFragment) { + fragment.refreshAlbumMetaData() + } + } else { + showErrorMessage(operation, result) + showUntrustedCertDialog(result) + } + } + private fun showUntrustedCertDialog(result: RemoteOperationResult<*>) { if (result.isSslRecoverableException) { activity.mLastSslUntrustedServerResult = result diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumSharingBottomSheet.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumSharingBottomSheet.kt new file mode 100644 index 000000000000..780f22c31ffa --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumSharingBottomSheet.kt @@ -0,0 +1,397 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment.albums + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintSet +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.account.CurrentAccountProvider +import com.nextcloud.client.di.Injectable +import com.nextcloud.utils.extensions.setVisibleIf +import com.owncloud.android.R +import com.owncloud.android.databinding.AlbumImageThumbnailBinding +import com.owncloud.android.databinding.AlbumSharingBottomSheetBinding +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.SyncedFolderProvider +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.lib.resources.albums.PhotoAlbumEntry +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.overlay.OverlayManager +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +class AlbumSharingBottomSheet( + private val photoAlbumEntry: PhotoAlbumEntry?, + private val fileList: List?, + private val actions: AlbumSharingBottomSheetActions +) : BottomSheetDialogFragment(), + Injectable { + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var currentUserProvider: CurrentAccountProvider + + @Inject + lateinit var storageManager: FileDataStorageManager + + @Inject + lateinit var syncedFolderProvider: SyncedFolderProvider + + @Inject + lateinit var overlayManager: OverlayManager + + private val thumbnailAsyncTasks = mutableListOf() + + private var _binding: AlbumSharingBottomSheetBinding? = null + val binding + get() = _binding!! + + private var shareId: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + photoAlbumEntry?.let { + // read only the 1st item of result and 1st item of collaborators + // as there will be no more data apart from current Album + if (it.collaborators.isNotEmpty()) { + shareId = it.collaborators[0].id + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = AlbumSharingBottomSheetBinding.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.bottomSheetLoading.visibility = View.GONE + setUpContent() + setUpShareComponentsVisibility() + setClickListeners() + } + + private fun setUpContent() { + val album = photoAlbumEntry ?: return + + with(binding) { + bindAlbumText(album) + bindAlbumThumbnail(album) + initializeImageCollage() + } + } + + private fun bindAlbumText(album: PhotoAlbumEntry) = with(binding) { + albumTitle.text = album.albumName + albumElements.text = getString( + R.string.album_elements_text, + album.nbItems + ) + albumDate.text = DisplayUtils.getDateByPattern(album.createdDate, "MMM yyyy") + } + + private fun bindAlbumThumbnail(album: PhotoAlbumEntry) = with(binding.albumImageLayout) { + thumbnail.tag = album.lastPhoto + + if (album.lastPhoto <= 0) { + showPlaceholder() + return@with + } + + val file = getOrCreateFile(album) + DisplayUtils.setThumbnail( + file, + thumbnail, + currentUserProvider.user, + storageManager, + thumbnailAsyncTasks, + false, + context, + thumbnailShimmer, + syncedFolderProvider.preferences, + viewThemeUtils, + overlayManager, + true + ) + } + + private fun showPlaceholder() = with(binding.albumImageLayout) { + thumbnail.setImageResource(R.drawable.file_image) + thumbnail.visibility = View.VISIBLE + thumbnailShimmer.visibility = View.GONE + } + + private fun getOrCreateFile(album: PhotoAlbumEntry): OCFile = storageManager.getFileByLocalId(album.lastPhoto) + ?: OCFile("/${album.albumName}").apply { + localId = album.lastPhoto + remoteId = album.lastPhoto.toString() + } + + private fun initializeImageCollage() { + fileList?.let { + if (it.isNotEmpty()) { + binding.imageCollage.visibility = View.VISIBLE + + val imageViews = listOf( + binding.imgTopLeft, + binding.imgBottomLeft, + binding.imgCenter, + binding.imgTopRight, + binding.imgBottomRight + ) + + imageViews.forEach { image -> image.root.visibility = View.GONE } + + rearrangeImageCollage(imageViews, it.size) + + it.forEachIndexed { index, url -> + imageViews[index].root.visibility = View.VISIBLE + DisplayUtils.setThumbnail( + url, + imageViews[index].thumbnail, + currentUserProvider.user, + storageManager, + thumbnailAsyncTasks, + false, + context, + imageViews[index].thumbnailShimmer, + syncedFolderProvider.preferences, + viewThemeUtils, + overlayManager, + true + ) + } + } + } + } + + /** + * rearrange the collage images based on the number of images to be shown + * for IMAGE_COLLAGE_MAX_LIMIT which is 5 images the default xml layout will be used + */ + private fun rearrangeImageCollage(imageViews: List, count: Int) { + if (count == IMAGE_COLLAGE_MAX_LIMIT) { + return + } + + val set = ConstraintSet() + set.clone(binding.imageCollage) + + imageViews.forEach { + set.clear(it.root.id) + it.root.visibility = View.GONE + } + + when (count) { + IMAGE_COUNT_1 -> layout1Image(set) + IMAGE_COUNT_2 -> layout2Images(set) + IMAGE_COUNT_3 -> layout3Images(set) + IMAGE_COUNT_4 -> layout4Images(set) + } + + set.applyTo(binding.imageCollage) + } + + /** + * show single image with full width and height + */ + private fun layout1Image(set: ConstraintSet) { + set.connect(binding.imgTopLeft.root.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) + set.connect(binding.imgTopLeft.root.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) + set.connect(binding.imgTopLeft.root.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) + set.connect(binding.imgTopLeft.root.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) + } + + /** + * for 2 images place both images equal width and full height + */ + private fun layout2Images(set: ConstraintSet) { + // for horizontal spacing between images we have used 0.48f + listOf(binding.imgTopLeft.root, binding.imgBottomLeft.root) + .forEach { + set.constrainPercentWidth(it.id, TWO_COLUMN_IMAGE_WIDTH_PERCENT) + } + + set.connect(binding.imgTopLeft.root.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) + set.connect(binding.imgTopLeft.root.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) + set.connect(binding.imgTopLeft.root.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) + set.connect(binding.imgTopLeft.root.id, ConstraintSet.END, binding.imgBottomLeft.root.id, ConstraintSet.START) + + set.connect(binding.imgBottomLeft.root.id, ConstraintSet.START, binding.imgTopLeft.root.id, ConstraintSet.END) + set.connect(binding.imgBottomLeft.root.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) + set.connect(binding.imgBottomLeft.root.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) + set.connect(binding.imgBottomLeft.root.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) + } + + /** + * for 3 images place first 2 images in 1 column and 3rd image in 2nd column with full height + */ + private fun layout3Images(set: ConstraintSet) { + // for horizontal spacing between images we have used 0.48f + listOf(binding.imgTopLeft.root, binding.imgBottomLeft.root, binding.imgCenter.root) + .forEach { + set.constrainPercentWidth(it.id, TWO_COLUMN_IMAGE_WIDTH_PERCENT) + } + + // for vertical spacing between images we have used 0.48f + listOf(binding.imgTopLeft.root, binding.imgBottomLeft.root) + .forEach { + set.constrainPercentHeight(it.id, TWO_ROW_IMAGE_HEIGHT_PERCENT) + } + + // 0.98f is used to align the full height image with 1st column image + set.constrainPercentHeight(binding.imgCenter.root.id, FULL_IMAGE_HEIGHT_PERCENT) + + // Left column + set.connect(binding.imgTopLeft.root.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) + set.connect(binding.imgTopLeft.root.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) + set.connect(binding.imgTopLeft.root.id, ConstraintSet.END, binding.imgCenter.root.id, ConstraintSet.START) + + set.connect(binding.imgBottomLeft.root.id, ConstraintSet.TOP, binding.imgTopLeft.root.id, ConstraintSet.BOTTOM) + set.connect(binding.imgBottomLeft.root.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) + set.connect(binding.imgBottomLeft.root.id, ConstraintSet.END, binding.imgCenter.root.id, ConstraintSet.START) + set.connect(binding.imgBottomLeft.root.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) + + // Right column full + set.connect(binding.imgCenter.root.id, ConstraintSet.START, binding.imgTopLeft.root.id, ConstraintSet.END) + set.connect(binding.imgCenter.root.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) + set.connect(binding.imgCenter.root.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) + set.connect(binding.imgCenter.root.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) + } + + /** + * for 4 images place the images in 2*2 grid with different height to look them staggered + */ + private fun layout4Images(set: ConstraintSet) { + // for horizontal spacing between images we have used 0.48f + listOf(binding.imgTopLeft.root, binding.imgBottomLeft.root, binding.imgCenter.root, binding.imgTopRight.root) + .forEach { + set.constrainPercentWidth(it.id, TWO_COLUMN_IMAGE_WIDTH_PERCENT) + } + + // for vertical spacing between images we have used 0.52f + listOf(binding.imgTopLeft.root, binding.imgTopRight.root) + .forEach { + set.constrainPercentHeight(it.id, STAGGERED_IMAGE_HEIGHT_PERCENT_BIG) + } + + // for vertical spacing between images we have used 0.43f + listOf(binding.imgBottomLeft.root, binding.imgCenter.root) + .forEach { + set.constrainPercentHeight(it.id, STAGGERED_IMAGE_HEIGHT_PERCENT_SMALL) + } + + // Top row + set.connect(binding.imgTopLeft.root.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) + set.connect(binding.imgTopLeft.root.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) + set.connect(binding.imgTopLeft.root.id, ConstraintSet.END, binding.imgCenter.root.id, ConstraintSet.START) + + set.connect(binding.imgCenter.root.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) + set.connect(binding.imgCenter.root.id, ConstraintSet.START, binding.imgTopLeft.root.id, ConstraintSet.END) + set.connect(binding.imgCenter.root.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) + + // Bottom row + set.connect(binding.imgBottomLeft.root.id, ConstraintSet.TOP, binding.imgTopLeft.root.id, ConstraintSet.BOTTOM) + set.connect(binding.imgBottomLeft.root.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) + set.connect(binding.imgBottomLeft.root.id, ConstraintSet.END, binding.imgTopRight.root.id, ConstraintSet.START) + set.connect(binding.imgBottomLeft.root.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) + + set.connect(binding.imgTopRight.root.id, ConstraintSet.TOP, binding.imgCenter.root.id, ConstraintSet.BOTTOM) + set.connect(binding.imgTopRight.root.id, ConstraintSet.START, binding.imgBottomLeft.root.id, ConstraintSet.END) + set.connect(binding.imgTopRight.root.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) + set.connect(binding.imgTopRight.root.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) + } + + private fun setUpShareComponentsVisibility() { + binding.createShareGroup.setVisibleIf(shareId.isNullOrEmpty()) + binding.shareGroup.setVisibleIf(!shareId.isNullOrEmpty()) + } + + private fun setClickListeners() { + binding.btnClose.setOnClickListener { + dismiss() + } + binding.btnCreateLink.setOnClickListener { + actions.createShare() + } + binding.lblCreateLink.setOnClickListener { + actions.createShare() + } + + binding.btnStopSharing.setOnClickListener { + actions.removeShare() + } + binding.lblStopSharing.setOnClickListener { + actions.removeShare() + } + + binding.btnCopy.setOnClickListener { + actions.copyShareLink() + dismiss() + } + binding.lblCopy.setOnClickListener { + actions.copyShareLink() + dismiss() + } + + binding.btnShareAlbumLink.setOnClickListener { + actions.shareAlbumLink() + dismiss() + } + binding.lblShareAlbumLink.setOnClickListener { + actions.shareAlbumLink() + dismiss() + } + } + + // has to be called when the new share is created or removed + fun updateShareId(updatedShareId: String?) { + shareId = updatedShareId + setUpShareComponentsVisibility() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val IMAGE_COUNT_1 = 1 + private const val IMAGE_COUNT_2 = 2 + private const val IMAGE_COUNT_3 = 3 + private const val IMAGE_COUNT_4 = 4 + const val IMAGE_COLLAGE_MAX_LIMIT = 5 + private const val TWO_COLUMN_IMAGE_WIDTH_PERCENT = 0.48f + private const val TWO_ROW_IMAGE_HEIGHT_PERCENT = 0.48f + private const val STAGGERED_IMAGE_HEIGHT_PERCENT_BIG = 0.52f + private const val STAGGERED_IMAGE_HEIGHT_PERCENT_SMALL = 0.43f + private const val FULL_IMAGE_HEIGHT_PERCENT = 0.98f + + @JvmStatic + fun newInstance( + photoAlbumEntry: PhotoAlbumEntry?, + fileList: List?, + actions: AlbumSharingBottomSheetActions + ): AlbumSharingBottomSheet = AlbumSharingBottomSheet(photoAlbumEntry, fileList, actions) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumSharingBottomSheetActions.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumSharingBottomSheetActions.kt new file mode 100644 index 000000000000..63be0c89496b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumSharingBottomSheetActions.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 TSI-mc + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.fragment.albums + +interface AlbumSharingBottomSheetActions { + fun createShare() + + fun removeShare() + + fun copyShareLink() + + fun shareAlbumLink() +} 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 index dbdcc26ccd81..86aa1cc003de 100644 --- 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 @@ -7,9 +7,7 @@ package com.owncloud.android.ui.fragment.albums -import android.app.Activity import android.content.Context -import android.content.Intent import android.content.res.Configuration import android.os.Bundle import android.os.Parcelable @@ -32,11 +30,13 @@ import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.di.Injectable import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.client.utils.Throttler +import com.nextcloud.utils.extensions.getTypedActivity import com.owncloud.android.R import com.owncloud.android.databinding.AlbumsFragmentBinding import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.albums.PhotoAlbumEntry import com.owncloud.android.lib.resources.albums.ReadAlbumsRemoteOperation +import com.owncloud.android.ui.activity.AlbumsPickerActivity import com.owncloud.android.ui.activity.BaseActivity import com.owncloud.android.ui.activity.FileDisplayActivity import com.owncloud.android.ui.adapter.albums.AlbumFragmentInterface @@ -323,8 +323,6 @@ class AlbumsFragment : 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 @@ -339,11 +337,10 @@ class AlbumsFragment : override fun onItemClick(album: PhotoAlbumEntry) { if (isSelectionMode) { - val resultIntent = Intent().apply { - putExtra(ARG_SELECTED_ALBUM_NAME, album.albumName) + getTypedActivity(AlbumsPickerActivity::class.java)?.let { + it.addFilesToAlbum(album.albumName) + it.finish() } - 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 34f39743ce2d..9d335b28d1dd 100755 --- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java +++ b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2023-2025 TSI-mc + * SPDX-FileCopyrightText: 2023-2026 TSI-mc * SPDX-FileCopyrightText: 2020 Chris Narkiewicz * SPDX-FileCopyrightText: 2018-2020 Tobias Kaminsky * SPDX-FileCopyrightText: 2018-2020 Andy Scherzinger @@ -55,6 +55,7 @@ import com.owncloud.android.lib.resources.status.OCCapability; import com.owncloud.android.operations.SynchronizeFileOperation; import com.owncloud.android.services.OperationsService; +import com.owncloud.android.ui.activity.AlbumsPickerActivity; import com.owncloud.android.ui.activity.ConflictsResolveActivity; import com.owncloud.android.ui.activity.ExternalSiteWebView; import com.owncloud.android.ui.activity.FileActivity; @@ -1072,20 +1073,36 @@ public void createAlbum(String albumName) { fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)); } + public void addFileToAlbum(Collection files) { + final ArrayList paths = new ArrayList<>(files.size()); + for (OCFile file : files) { + paths.add(file.getRemotePath()); + } + + fileActivity.startActivity(AlbumsPickerActivity.Companion.intentForPickingAlbum(fileActivity, paths)); + } + 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)); + connectivityService.isNetworkAndServerAvailable(result -> { + if (result) { + 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)); + } else { + DisplayUtils.showSnackMessage(fileActivity, fileActivity.getString(R.string.offline_mode)); + + } + }); } public void renameAlbum(String oldAlbumName, String newAlbumName) { @@ -1110,6 +1127,17 @@ public void removeAlbum(String albumName) { fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)); } + public void albumPublicShareLink(String albumName, boolean isCreateShare) { + Intent service = new Intent(fileActivity, OperationsService.class); + service.setAction(OperationsService.ACTION_PUBLIC_SHARE_LINK_ALBUM); + service.putExtra(OperationsService.EXTRA_ACCOUNT, fileActivity.getAccount()); + service.putExtra(OperationsService.EXTRA_ALBUM_NAME, albumName); + service.putExtra(OperationsService.EXTRA_CREATE_ALBUM_SHARE, isCreateShare); + 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/PreviewImageFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt index ce6444411991..1ac5564e87b7 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.kt @@ -479,6 +479,8 @@ class PreviewImageFragment : ) } else if (itemId == R.id.action_edit) { (requireActivity() as PreviewImageActivity).startImageEditor(file) + } else if (itemId == R.id.action_add_to_album) { + containerActivity.fileOperationsHelper.addFileToAlbum(listOf(file)) } } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt index e3a9ce9a0a7d..6b736e060be3 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaActivity.kt @@ -17,6 +17,7 @@ import android.content.ComponentName import android.content.DialogInterface import android.content.Intent import android.content.res.Configuration +import android.content.res.Resources import android.graphics.BitmapFactory import android.graphics.Color import android.graphics.drawable.Drawable @@ -87,6 +88,7 @@ import com.owncloud.android.operations.DownloadType import com.owncloud.android.operations.FetchRemoteFileOperation import com.owncloud.android.operations.RemoveFileOperation import com.owncloud.android.operations.SynchronizeFileOperation +import com.owncloud.android.operations.albums.CopyFileToAlbumOperation import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.activity.FileDisplayActivity import com.owncloud.android.ui.dialog.ConfirmationDialogFragment @@ -647,6 +649,12 @@ class PreviewMediaActivity : R.id.action_download_file -> { requestForDownload(file, null) } + + R.id.action_add_to_album -> { + file?.let { + fileOperationsHelper.addFileToAlbum(listOf(it)) + } + } } } @@ -666,6 +674,8 @@ class PreviewMediaActivity : } } else if (operation is SynchronizeFileOperation) { onSynchronizeFileOperationFinish(result) + } else if (operation is CopyFileToAlbumOperation) { + onCopyAlbumFileOperationFinish(operation, result) } } @@ -675,6 +685,22 @@ class PreviewMediaActivity : } } + private fun onCopyAlbumFileOperationFinish(operation: CopyFileToAlbumOperation, result: RemoteOperationResult<*>?) { + if (result?.isSuccess == true) { + DisplayUtils.showSnackMessage(this, getResources().getString(R.string.album_file_added_message)) + Log_OC.e(PreviewImageActivity.TAG, "Files copied successfully") + } else { + try { + DisplayUtils.showSnackMessage( + this, + ErrorMessageAdapter.getErrorCauseMessage(result, operation, getResources()) + ) + } catch (e: Resources.NotFoundException) { + Log_OC.e(PreviewImageActivity.TAG, "Error while trying to show fail message ", e) + } + } + } + override fun downloadFile(file: OCFile?, packageName: String?, activityName: String?) { requestForDownload(file, OCFileListFragment.DOWNLOAD_SEND, packageName, activityName) } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt index 68423473d79b..1a246bca3be7 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt @@ -1,7 +1,7 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2023-2026 TSI-mc * SPDX-FileCopyrightText: 2023 Parneet Singh * SPDX-FileCopyrightText: 2020 Andy Scherzinger * SPDX-FileCopyrightText: 2019 Chris Narkiewicz @@ -462,6 +462,10 @@ class PreviewMediaFragment : R.id.action_download_file -> { instance().downloadFileIfNotStartedBefore(user!!, file) } + + R.id.action_add_to_album -> { + containerActivity.fileOperationsHelper.addFileToAlbum(listOf(file)) + } } } diff --git a/app/src/main/res/drawable/album_sharing_btn_idle_state.xml b/app/src/main/res/drawable/album_sharing_btn_idle_state.xml new file mode 100644 index 000000000000..1b55a8649b18 --- /dev/null +++ b/app/src/main/res/drawable/album_sharing_btn_idle_state.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/album_sharing_btn_pressed_state.xml b/app/src/main/res/drawable/album_sharing_btn_pressed_state.xml new file mode 100644 index 000000000000..7ce22ad862f5 --- /dev/null +++ b/app/src/main/res/drawable/album_sharing_btn_pressed_state.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/album_sharing_curve_selector.xml b/app/src/main/res/drawable/album_sharing_curve_selector.xml new file mode 100644 index 000000000000..8d455ff6fcec --- /dev/null +++ b/app/src/main/res/drawable/album_sharing_curve_selector.xml @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/album_image_thumbnail.xml b/app/src/main/res/layout/album_image_thumbnail.xml new file mode 100644 index 000000000000..c1f36dad0a1b --- /dev/null +++ b/app/src/main/res/layout/album_image_thumbnail.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/album_sharing_bottom_sheet.xml b/app/src/main/res/layout/album_sharing_bottom_sheet.xml new file mode 100644 index 000000000000..0792d726344a --- /dev/null +++ b/app/src/main/res/layout/album_sharing_bottom_sheet.xml @@ -0,0 +1,407 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/albums_grid_item.xml b/app/src/main/res/layout/albums_grid_item.xml index b89959e9ddb6..72f89820e41f 100644 --- a/app/src/main/res/layout/albums_grid_item.xml +++ b/app/src/main/res/layout/albums_grid_item.xml @@ -50,14 +50,17 @@ + android:textStyle="bold" + app:drawableTint="@color/text_color" + tools:drawableEndCompat="@drawable/ic_share" + tools:text="Album" /> ~ SPDX-FileCopyrightText: 2022 Álvaro Brey ~ SPDX-FileCopyrightText: 2015-2022 Andy Scherzinger ~ SPDX-FileCopyrightText: 2020 Tobias Kaminsky @@ -160,4 +161,5 @@ 18dp 18dp 24dp + 4dp diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 24008a6445b2..9b0314bf9a6b 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -2,6 +2,7 @@