Răsfoiți Sursa

Use bottom sheet for file actions

Signed-off-by: Álvaro Brey <alvaro.brey@nextcloud.com>
Álvaro Brey 2 ani în urmă
părinte
comite
f2d0acdba3

+ 4 - 0
app/src/main/java/com/nextcloud/client/di/ComponentsModule.java

@@ -34,6 +34,7 @@ import com.nextcloud.client.widget.DashboardWidgetProvider;
 import com.nextcloud.client.widget.DashboardWidgetService;
 import com.nextcloud.ui.ChooseAccountDialogFragment;
 import com.nextcloud.ui.SetStatusDialogFragment;
+import com.nextcloud.ui.fileactions.FileActionsBottomSheet;
 import com.owncloud.android.MainApp;
 import com.owncloud.android.authentication.AuthenticatorActivity;
 import com.owncloud.android.authentication.DeepLinkLoginActivity;
@@ -462,4 +463,7 @@ abstract class ComponentsModule {
 
     @ContributesAndroidInjector
     abstract SslUntrustedCertDialog sslUntrustedCertDialog();
+
+    @ContributesAndroidInjector
+    abstract FileActionsBottomSheet fileActionsBottomSheet();
 }

+ 55 - 0
app/src/main/java/com/nextcloud/ui/fileactions/FileAction.kt

@@ -0,0 +1,55 @@
+/*
+ * Nextcloud Android client application
+ *
+ *  @author Álvaro Brey
+ *  Copyright (C) 2022 Álvaro Brey
+ *  Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package com.nextcloud.ui.fileactions
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.IdRes
+import androidx.annotation.StringRes
+import com.owncloud.android.R
+
+// TODO get rid of id, use enum value directly
+enum class FileAction(@IdRes val id: Int, @StringRes val title: Int, @DrawableRes val icon: Int? = null) {
+    UNLOCK_FILE(R.id.action_unlock_file, R.string.unlock_file),
+    EDIT(R.id.action_edit, R.string.action_edit),
+    FAVORITE(R.id.action_favorite, R.string.favorite),
+    UNSET_FAVORITE(R.id.action_unset_favorite, R.string.unset_favorite),
+    SEE_DETAILS(R.id.action_see_details, R.string.actionbar_see_details),
+    LOCK_FILE(R.id.action_lock_file, R.string.lock_file),
+    RENAME_FILE(R.id.action_rename_file, R.string.common_rename),
+    MOVE(R.id.action_move, R.string.actionbar_move),
+    COPY(R.id.action_copy, R.string.actionbar_copy),
+    DOWNLOAD_FILE(R.id.action_download_file, R.string.filedetails_download),
+    EXPORT_FILE(R.id.action_export_file, R.string.filedetails_export),
+    STREAM_MEDIA(R.id.action_stream_media, R.string.stream),
+    SEND_SHARE_FILE(R.id.action_send_share_file, R.string.action_send_share),
+    SEND_FILE(R.id.action_send_file, R.string.common_send),
+    OPEN_FILE_WITH(R.id.action_open_file_with, R.string.actionbar_open_with),
+    SYNC_FILE(R.id.action_sync_file, R.string.filedetails_sync_file),
+    CANCEL_SYNC(R.id.action_cancel_sync, R.string.common_cancel_sync),
+    SELECT_ALL_ACTION_MENU(R.id.action_select_all_action_menu, R.string.select_all),
+    DESELECT_ALL_ACTION_MENU(R.id.action_deselect_all_action_menu, R.string.deselect_all),
+    ENCRYPTED(R.id.action_encrypted, R.string.encrypted),
+    UNSET_ENCRYPTED(R.id.action_unset_encrypted, R.string.unset_encrypted),
+    SET_AS_WALLPAPER(R.id.action_set_as_wallpaper, R.string.set_picture_as),
+    REMOVE_FILE(R.id.action_remove_file, R.string.common_remove)
+}

+ 132 - 0
app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt

@@ -0,0 +1,132 @@
+/*
+ * Nextcloud Android client application
+ *
+ *  @author Álvaro Brey
+ *  Copyright (C) 2022 Álvaro Brey
+ *  Copyright (C) 2022 Nextcloud GmbH
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package com.nextcloud.ui.fileactions
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.IdRes
+import androidx.core.os.bundleOf
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.nextcloud.client.account.CurrentAccountProvider
+import com.nextcloud.client.di.Injectable
+import com.owncloud.android.databinding.FileActionsBottomSheetBinding
+import com.owncloud.android.databinding.FileActionsBottomSheetItemBinding
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.files.FileMenuFilter
+import com.owncloud.android.ui.activity.ComponentsGetter
+import javax.inject.Inject
+
+// TODO add file name
+// TODO add lock info (see FileLockingMenuCustomization)
+// TODO give events back
+// TODO viewModel
+// TODO drag handle
+// TODO theming
+class FileActionsBottomSheet : BottomSheetDialogFragment(), Injectable {
+
+    // TODO refactor FileMenuFilter and inject needed things into it
+    lateinit var componentsGetter: ComponentsGetter
+
+    // TODO replace with fragment listener from Activity
+    lateinit var clickListener: ClickListener
+
+    @Inject
+    lateinit var currentAccountProvider: CurrentAccountProvider
+
+    private lateinit var binding: FileActionsBottomSheetBinding
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+        // TODO pass only IDs, fetch from DB to avoid TransactionTooLarge
+        val args = requireArguments()
+        val files: Array<OCFile>? = args.getParcelableArray(ARG_FILES) as Array<OCFile>?
+        require(files != null)
+        val numberOfAllFiles = args.getInt(ARG_ALL_FILES_COUNT, 1)
+        val isOverflow = args.getBoolean(ARG_IS_OVERFLOW, false)
+
+        binding = FileActionsBottomSheetBinding.inflate(inflater, container, false)
+        val toHide = FileMenuFilter(
+            numberOfAllFiles,
+            files.toList(),
+            componentsGetter,
+            requireContext(),
+            isOverflow,
+            currentAccountProvider.user
+        )
+            .getToHide(false)
+        FileAction.values()
+            .filter { it.id !in toHide }.forEach { action ->
+                // TODO change icon
+                val itemBinding = FileActionsBottomSheetItemBinding.inflate(inflater, binding.fileActionsList, false)
+                    .apply {
+                        root.setText(action.title)
+                        root.setOnClickListener {
+                            clickListener.onClick(action.id)
+                            dismiss()
+                        }
+                    }
+                binding.fileActionsList.addView(itemBinding.root)
+            }
+        return binding.root
+    }
+
+    interface ClickListener {
+        fun onClick(@IdRes itemId: Int)
+    }
+
+    companion object {
+        private const val ARG_ALL_FILES_COUNT = "ALL_FILES_COUNT"
+        private const val ARG_FILES = "FILES"
+        private const val ARG_IS_OVERFLOW = "OVERFLOW"
+
+        @JvmStatic
+        fun newInstance(
+            file: OCFile,
+            componentsGetter: ComponentsGetter,
+            isOverflow: Boolean,
+            onItemClick: ClickListener
+        ): FileActionsBottomSheet {
+            return newInstance(1, listOf(file), componentsGetter, isOverflow, onItemClick)
+        }
+
+        @JvmStatic
+        fun newInstance(
+            numberOfAllFiles: Int,
+            files: Collection<OCFile>,
+            componentsGetter: ComponentsGetter,
+            isOverflow: Boolean,
+            onItemClick: ClickListener
+        ): FileActionsBottomSheet {
+            return FileActionsBottomSheet().apply {
+                arguments = bundleOf(
+                    ARG_ALL_FILES_COUNT to numberOfAllFiles,
+                    ARG_FILES to files.toTypedArray(),
+                    ARG_IS_OVERFLOW to isOverflow
+                )
+                this.componentsGetter = componentsGetter
+                this.clickListener = onItemClick
+            }
+        }
+    }
+}

+ 20 - 0
app/src/main/java/com/owncloud/android/files/FileMenuFilter.java

@@ -49,7 +49,9 @@ import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 
+import androidx.annotation.IdRes;
 import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
 
 /**
  * Filters out the file actions available in a given {@link Menu} for a given {@link OCFile}
@@ -107,6 +109,7 @@ public class FileMenuFilter {
      * @param overflowMenu      true if the overflow menu items are being filtered
      * @param user              currently active user
      */
+    @VisibleForTesting // TODO remove this constructor for testing too
     public FileMenuFilter(OCFile file,
                           ComponentsGetter componentsGetter,
                           Context context,
@@ -116,6 +119,22 @@ public class FileMenuFilter {
         this(1, Collections.singletonList(file), componentsGetter, context, overflowMenu, user);
     }
 
+    /**
+     * Temporary while migrating to bottom sheet
+     *
+     * TODO refactor and remove
+     */
+    @IdRes
+    public List<Integer> getToHide(final boolean inSingleFileFragment){
+        if(files != null && ! files.isEmpty()){
+            List<Integer> toShow = new ArrayList<>();
+            List<Integer> toHide = new ArrayList<>();
+            filter(toShow, toHide, inSingleFileFragment);
+            return toHide;
+        }
+        return null;
+    }
+
     /**
      * Filters out the file actions available in the passed {@link Menu} taken into account the state of the {@link
      * OCFile} held by the filter.
@@ -368,6 +387,7 @@ public class FileMenuFilter {
     }
 
     @Nullable
+    // TODO this does NOT belong in this class
     public static Editor getEditor(ContentResolver contentResolver, User user, String mimeType) {
         String json = new ArbitraryDataProvider(contentResolver).getValue(user, ArbitraryDataProvider.DIRECT_EDITING);
 

+ 24 - 37
app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java

@@ -32,7 +32,6 @@ import android.graphics.Bitmap;
 import android.os.Bundle;
 import android.view.LayoutInflater;
 import android.view.Menu;
-import android.view.MenuItem;
 import android.view.View;
 import android.view.View.OnClickListener;
 import android.view.ViewGroup;
@@ -48,6 +47,7 @@ import com.nextcloud.client.jobs.BackgroundJobManager;
 import com.nextcloud.client.network.ClientFactory;
 import com.nextcloud.client.network.ConnectivityService;
 import com.nextcloud.client.preferences.AppPreferences;
+import com.nextcloud.ui.fileactions.FileActionsBottomSheet;
 import com.owncloud.android.MainApp;
 import com.owncloud.android.R;
 import com.owncloud.android.databinding.FileDetailsFragmentBinding;
@@ -80,6 +80,8 @@ import org.greenrobot.eventbus.ThreadMode;
 
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Set;
 
 import javax.inject.Inject;
 
@@ -240,13 +242,11 @@ public class FileDetailFragment extends FileFragment implements OnClickListener,
         }
     }
 
-    private void onOverflowIconClicked(View view) {
-        PopupMenu popup = new PopupMenu(getActivity(), view);
-        popup.inflate(R.menu.fragment_file_detail);
-        prepareOptionsMenu(popup.getMenu());
-
-        popup.setOnMenuItemClickListener(this::optionsItemSelected);
-        popup.show();
+    private void onOverflowIconClicked() {
+        // TODO this fragment originally used fragment_file_detail.xml menu, which has fewer things that item_file.xml. Figure that out
+        final OCFile file = getFile();
+        FileActionsBottomSheet.newInstance(file, containerActivity, true, this::optionsItemSelected)
+            .show(getActivity().getSupportFragmentManager(), "actions");
     }
 
     private void setupViewPager() {
@@ -369,48 +369,41 @@ public class FileDetailFragment extends FileFragment implements OnClickListener,
     }
 
     private void prepareOptionsMenu(Menu menu) {
-        if (containerActivity.getStorageManager() != null) {
-            User currentUser = accountManager.getUser();
-            FileMenuFilter mf = new FileMenuFilter(
-                getFile(),
-                containerActivity,
-                getActivity(),
-                false,
-                currentUser
-            );
-
-            mf.filter(menu, true);
-        }
-
+//        if (containerActivity.getStorageManager() != null) {
+//            User currentUser = accountManager.getUser();
+//            FileMenuFilter mf = new FileMenuFilter(
+//                getFile(),
+//                containerActivity,
+//                getActivity(),
+//                false,
+//                currentUser
+//            );
+//
+//            mf.filter(menu, true);
+//        }
+
+        // TODO handle this
         if (getFile().isFolder()) {
             FileMenuFilter.hideMenuItems(menu.findItem(R.id.action_send_file));
             FileMenuFilter.hideMenuItems(menu.findItem(R.id.action_sync_file));
         }
     }
 
-    private boolean optionsItemSelected(MenuItem item) {
-        int itemId = item.getItemId();
-
+    private void optionsItemSelected(final int itemId) {
         if (itemId == R.id.action_send_file) {
             containerActivity.getFileOperationsHelper().sendShareFile(getFile(), true);
-            return true;
         } else if (itemId == R.id.action_open_file_with) {
             containerActivity.getFileOperationsHelper().openFile(getFile());
-            return true;
         } else if (itemId == R.id.action_remove_file) {
             RemoveFilesDialogFragment dialog = RemoveFilesDialogFragment.newInstance(getFile());
             dialog.show(getFragmentManager(), FTAG_CONFIRMATION);
-            return true;
         } else if (itemId == R.id.action_rename_file) {
             RenameFileDialogFragment dialog = RenameFileDialogFragment.newInstance(getFile(), parentFolder);
             dialog.show(getFragmentManager(), FTAG_RENAME_FILE);
-            return true;
         } else if (itemId == R.id.action_cancel_sync) {
             ((FileDisplayActivity) containerActivity).cancelTransference(getFile());
-            return true;
         } else if (itemId == R.id.action_download_file || itemId == R.id.action_sync_file) {
             containerActivity.getFileOperationsHelper().syncFile(getFile());
-            return true;
         } else if (itemId == R.id.action_export_file) {
             ArrayList<OCFile> list = new ArrayList<>();
             list.add(getFile());
@@ -418,17 +411,11 @@ public class FileDetailFragment extends FileFragment implements OnClickListener,
                                                                     getContext(),
                                                                     getView(),
                                                                     backgroundJobManager);
-            return true;
         } else if (itemId == R.id.action_set_as_wallpaper) {
             containerActivity.getFileOperationsHelper().setPictureAs(getFile(), getView());
-            return true;
         } else if (itemId == R.id.action_encrypted) {// TODO implement or remove
-            return true;
         } else if (itemId == R.id.action_unset_encrypted) {// TODO implement or remove
-            return true;
         }
-
-        return super.onOptionsItemSelected(item);
     }
 
     @Override
@@ -441,7 +428,7 @@ public class FileDetailFragment extends FileFragment implements OnClickListener,
             containerActivity.getFileOperationsHelper().toggleFavoriteFile(getFile(), !getFile().isFavorite());
             setFavoriteIconStatus(!getFile().isFavorite());
         } else if (id == R.id.overflow_menu) {
-            onOverflowIconClicked(v);
+            onOverflowIconClicked();
         } else if (id == R.id.last_modification_timestamp) {
             boolean showDetailedTimestamp = !preferences.isShowDetailedTimestampEnabled();
             preferences.setShowDetailedTimestampEnabled(showDetailedTimestamp);

+ 41 - 39
app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java

@@ -45,8 +45,6 @@ import android.widget.Toast;
 import com.google.android.material.behavior.HideBottomViewOnScrollBehavior;
 import com.google.android.material.floatingactionbutton.FloatingActionButton;
 import com.google.android.material.snackbar.Snackbar;
-import com.nextcloud.android.files.FileLockingMenuCustomization;
-import com.nextcloud.android.files.ThemedPopupMenu;
 import com.nextcloud.android.lib.resources.files.ToggleFileLockRemoteOperation;
 import com.nextcloud.android.lib.richWorkspace.RichWorkspaceDirectEditingRemoteOperation;
 import com.nextcloud.client.account.User;
@@ -58,6 +56,7 @@ import com.nextcloud.client.network.ClientFactory;
 import com.nextcloud.client.preferences.AppPreferences;
 import com.nextcloud.client.utils.Throttler;
 import com.nextcloud.common.NextcloudClient;
+import com.nextcloud.ui.fileactions.FileActionsBottomSheet;
 import com.nextcloud.utils.view.FastScrollUtils;
 import com.owncloud.android.MainApp;
 import com.owncloud.android.R;
@@ -118,13 +117,13 @@ import org.greenrobot.eventbus.ThreadMode;
 import java.io.File;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 
 import javax.inject.Inject;
 
+import androidx.annotation.IdRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
@@ -572,22 +571,29 @@ public class OCFileListFragment extends ExtendedListFragment implements
     @Override
     public void onOverflowIconClicked(OCFile file, View view) {
         throttler.run("overflowClick", () -> {
-            final ThemedPopupMenu popup = new ThemedPopupMenu(requireContext(), view);
-            popup.inflate(R.menu.item_file);
-            FileMenuFilter mf = new FileMenuFilter(mAdapter.getFiles().size(),
-                                                   Collections.singleton(file),
-                                                   mContainerActivity, getActivity(),
-                                                   true,
-                                                   accountManager.getUser());
-            mf.filter(popup.getMenu(), true);
-            new FileLockingMenuCustomization(requireContext()).customizeMenu(popup.getMenu(), file);
-            popup.setOnMenuItemClickListener(item -> {
-                Set<OCFile> checkedFiles = new HashSet<>();
-                checkedFiles.add(file);
-                return onFileActionChosen(item, checkedFiles);
-            });
-
-            popup.show();
+            FileActionsBottomSheet.newInstance(file, mContainerActivity, true, itemId -> {
+                    Set<OCFile> checkedFiles = new HashSet<>();
+                    checkedFiles.add(file);
+                    onFileActionChosen(itemId, checkedFiles);
+                })
+                .show(getActivity().getSupportFragmentManager(), "actions");
+
+//            final ThemedPopupMenu popup = new ThemedPopupMenu(requireContext(), view);
+//            popup.inflate(R.menu.item_file);
+//            FileMenuFilter mf = new FileMenuFilter(mAdapter.getFiles().size(),
+//                                                   Collections.singleton(file),
+//                                                   mContainerActivity, getActivity(),
+//                                                   true,
+//                                                   accountManager.getUser());
+//            mf.filter(popup.getMenu(), true);
+//            new FileLockingMenuCustomization(requireContext()).customizeMenu(popup.getMenu(), file);
+//            popup.setOnMenuItemClickListener(item -> {
+//                Set<OCFile> checkedFiles = new HashSet<>();
+//                checkedFiles.add(file);
+//                return onFileActionChosen(item, checkedFiles);
+//            });
+//
+////            popup.show();
         });
     }
 
@@ -710,8 +716,10 @@ public class OCFileListFragment extends ExtendedListFragment implements
             // Determine if actionMode is "new" or not (already affected by item-selection)
             mIsActionModeNew = true;
 
+            // fake menu to be able to use bottom sheet instead
+            // TODO android:iconTint does not work below API 26. Color icon with viewThemeUtils
             MenuInflater inflater = getActivity().getMenuInflater();
-            inflater.inflate(R.menu.item_file, menu);
+            inflater.inflate(R.menu.custom_menu_placeholder, menu);
             mode.invalidate();
 
             //set actionMode color
@@ -726,6 +734,7 @@ public class OCFileListFragment extends ExtendedListFragment implements
             return true;
         }
 
+
         /**
          * Updates available action in menu depending on current selection.
          */
@@ -735,23 +744,10 @@ public class OCFileListFragment extends ExtendedListFragment implements
             final int checkedCount = checkedFiles.size();
             String title = getResources().getQuantityString(R.plurals.items_selected_count, checkedCount, checkedCount);
             mode.setTitle(title);
-            FileMenuFilter mf = new FileMenuFilter(
-                getCommonAdapter().getFilesCount(),
-                checkedFiles,
-                mContainerActivity,
-                getActivity(),
-                false,
-                accountManager.getUser()
-            );
-
-            mf.filter(menu, false);
 
             // Determine if we need to finish the action mode because there are no items selected
             if (checkedCount == 0 && !mIsActionModeNew) {
                 exitSelectionMode();
-            } else if (checkedCount == 1) {
-                // customize for locking if file is locked
-                new FileLockingMenuCustomization(requireContext()).customizeMenu(menu, checkedFiles.iterator().next());
             }
 
             return true;
@@ -762,8 +758,16 @@ public class OCFileListFragment extends ExtendedListFragment implements
          */
         @Override
         public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
-            Set<OCFile> checkedFiles = getCommonAdapter().getCheckedItems();
-            return onFileActionChosen(item, checkedFiles);
+            final Set<OCFile> checkedFiles = getCommonAdapter().getCheckedItems();
+            if (item.getItemId() == R.id.custom_menu_placeholder_item) {
+                FileActionsBottomSheet.newInstance(getCommonAdapter().getFilesCount(),
+                                                   checkedFiles,
+                                                   mContainerActivity,
+                                                   false,
+                                                   itemId -> onFileActionChosen(itemId, checkedFiles))
+                    .show(getActivity().getSupportFragmentManager(), "actions");
+            }
+            return true;
         }
 
         /**
@@ -1099,11 +1103,11 @@ public class OCFileListFragment extends ExtendedListFragment implements
     /**
      * Start the appropriate action(s) on the currently selected files given menu selected by the user.
      *
-     * @param item       MenuItem selected by the user
+     * @param item         MenuItem selected by the user
      * @param checkedFiles List of files selected by the user on which the action should be performed
      * @return 'true' if the menu selection started any action, 'false' otherwise.
      */
-    public boolean onFileActionChosen(MenuItem item, Set<OCFile> checkedFiles) {
+    public boolean onFileActionChosen(@IdRes final int itemId, Set<OCFile> checkedFiles) {
         if (checkedFiles.isEmpty()) {
             return false;
         }
@@ -1111,7 +1115,6 @@ public class OCFileListFragment extends ExtendedListFragment implements
         if (checkedFiles.size() == SINGLE_SELECTION) {
             /// action only possible on a single file
             OCFile singleFile = checkedFiles.iterator().next();
-            int itemId = item.getItemId();
 
             if (itemId == R.id.action_send_share_file) {
                 mContainerActivity.getFileOperationsHelper().sendShareFile(singleFile);
@@ -1162,7 +1165,6 @@ public class OCFileListFragment extends ExtendedListFragment implements
         }
 
         /// actions possible on a batch of files
-        int itemId = item.getItemId();
         if (itemId == R.id.action_remove_file) {
             RemoveFilesDialogFragment dialog =
                 RemoveFilesDialogFragment.newInstance(new ArrayList<>(checkedFiles), mActiveActionMode);

+ 56 - 57
app/src/main/java/com/owncloud/android/ui/preview/PreviewImageFragment.java

@@ -23,10 +23,8 @@ package com.owncloud.android.ui.preview;
 
 import android.app.Activity;
 import android.content.Context;
-import android.content.res.Configuration;
 import android.content.res.Resources;
 import android.graphics.Bitmap;
-import android.graphics.Color;
 import android.graphics.Point;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
@@ -35,8 +33,6 @@ import android.graphics.drawable.PictureDrawable;
 import android.os.AsyncTask;
 import android.os.Bundle;
 import android.os.Process;
-import android.text.SpannableString;
-import android.text.style.ForegroundColorSpan;
 import android.util.DisplayMetrics;
 import android.view.LayoutInflater;
 import android.view.Menu;
@@ -57,6 +53,7 @@ import com.nextcloud.client.account.UserAccountManager;
 import com.nextcloud.client.di.Injectable;
 import com.nextcloud.client.jobs.BackgroundJobManager;
 import com.nextcloud.client.network.ConnectivityService;
+import com.nextcloud.ui.fileactions.FileActionsBottomSheet;
 import com.owncloud.android.MainApp;
 import com.owncloud.android.R;
 import com.owncloud.android.databinding.PreviewImageFragmentBinding;
@@ -341,74 +338,84 @@ public class PreviewImageFragment extends FileFragment implements Injectable {
      * {@inheritDoc}
      */
     @Override
+    // TODO replace with MenuProvider
     public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
         super.onCreateOptionsMenu(menu, inflater);
-        inflater.inflate(R.menu.item_file, menu);
-
-        int nightModeFlag = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
-
-        if (Configuration.UI_MODE_NIGHT_NO == nightModeFlag) {
-            for (int i = 0; i < menu.size(); i++) {
-                MenuItem menuItem = menu.getItem(i);
-
-                SpannableString spanString = new SpannableString(menuItem.getTitle().toString());
-                spanString.setSpan(new ForegroundColorSpan(Color.BLACK), 0, spanString.length(), 0);
-                menuItem.setTitle(spanString);
-            }
-        }
+        inflater.inflate(R.menu.custom_menu_placeholder, menu);
     }
 
     /**
      * {@inheritDoc}
      */
-    @Override
-    public void onPrepareOptionsMenu(@NonNull Menu menu) {
+    // TODO delete
+    public void prepareOptionsMenu_old(@NonNull Menu menu) {
         super.onPrepareOptionsMenu(menu);
 
-        if (containerActivity.getStorageManager() != null && getFile() != null) {
-            // Update the file
-            final OCFile updatedFile = containerActivity.getStorageManager().getFileById(getFile().getFileId());
-            setFile(updatedFile);
-
-            if (getFile() != null) {
-                User currentUser = accountManager.getUser();
-                FileMenuFilter mf = new FileMenuFilter(
-                    getFile(),
-                    containerActivity,
-                    getActivity(),
-                    false,
-                    currentUser
-                );
-
-                mf.filter(menu, true);
-            }
-        }
-
+//        if (containerActivity.getStorageManager() != null && getFile() != null) {
+//            // Update the file
+//            final OCFile updatedFile = containerActivity.getStorageManager().getFileById(getFile().getFileId());
+//            setFile(updatedFile);
+//
+//            if (getFile() != null) {
+//                User currentUser = accountManager.getUser();
+//                FileMenuFilter mf = new FileMenuFilter(
+//                    getFile(),
+//                    containerActivity,
+//                    getActivity(),
+//                    false,
+//                    currentUser
+//                );
+//
+//                mf.filter(menu, true);
+//            }
+//        }
+
+        // TODO remove items from bottom sheet too
         // additional restriction for this fragment
         // TODO allow renaming in PreviewImageFragment
         // TODO allow refresh file in PreviewImageFragment
         FileMenuFilter.hideMenuItems(
-                menu.findItem(R.id.action_rename_file),
-                menu.findItem(R.id.action_sync_file),
-                menu.findItem(R.id.action_select_all),
-                menu.findItem(R.id.action_move),
-                menu.findItem(R.id.action_copy),
-                menu.findItem(R.id.action_favorite),
-                menu.findItem(R.id.action_unset_favorite)
-        );
+            menu.findItem(R.id.action_rename_file),
+            menu.findItem(R.id.action_sync_file),
+            menu.findItem(R.id.action_select_all),
+            menu.findItem(R.id.action_move),
+            menu.findItem(R.id.action_copy),
+            menu.findItem(R.id.action_favorite),
+            menu.findItem(R.id.action_unset_favorite)
+                                    );
 
         if (getFile() != null && getFile().isSharedWithMe() && !getFile().canReshare()) {
             FileMenuFilter.hideMenuItem(menu.findItem(R.id.action_send_share_file));
         }
     }
 
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (item.getItemId() == R.id.custom_menu_placeholder_item) {
+            final OCFile file = getFile();
+            if (containerActivity.getStorageManager() != null && file != null) {
+                // Update the file
+                final OCFile updatedFile = containerActivity.getStorageManager().getFileById(file.getFileId());
+                setFile(updatedFile);
+
+                final OCFile fileNew = getFile();
+                if (fileNew != null) {
+                    FileActionsBottomSheet.newInstance(fileNew,
+                                                       containerActivity,
+                                                       false,
+                                                       this::onFileActionChosen)
+                        .show(getActivity().getSupportFragmentManager(), "actions");
+                }
+            }
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
 
     /**
      * {@inheritDoc}
      */
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        int itemId = item.getItemId();
+    public void onFileActionChosen(final int itemId) {
         if (itemId == R.id.action_send_share_file) {
             if (getFile().isSharedWithMe() && !getFile().canReshare()) {
                 Snackbar.make(requireView(),
@@ -419,23 +426,17 @@ public class PreviewImageFragment extends FileFragment implements Injectable {
             } else {
                 containerActivity.getFileOperationsHelper().sendShareFile(getFile());
             }
-            return true;
         } else if (itemId == R.id.action_open_file_with) {
             openFile();
-            return true;
         } else if (itemId == R.id.action_remove_file) {
             RemoveFilesDialogFragment dialog = RemoveFilesDialogFragment.newInstance(getFile());
             dialog.show(getFragmentManager(), ConfirmationDialogFragment.FTAG_CONFIRMATION);
-            return true;
         } else if (itemId == R.id.action_see_details) {
             seeDetails();
-            return true;
         } else if (itemId == R.id.action_download_file || itemId == R.id.action_sync_file) {
             containerActivity.getFileOperationsHelper().syncFile(getFile());
-            return true;
         } else if (itemId == R.id.action_set_as_wallpaper) {
             containerActivity.getFileOperationsHelper().setPictureAs(getFile(), getImageView());
-            return true;
         } else if (itemId == R.id.action_export_file) {
             ArrayList<OCFile> list = new ArrayList<>();
             list.add(getFile());
@@ -443,9 +444,7 @@ public class PreviewImageFragment extends FileFragment implements Injectable {
                                                                     getContext(),
                                                                     getView(),
                                                                     backgroundJobManager);
-            return true;
         }
-        return super.onOptionsItemSelected(item);
     }
 
     private void seeDetails() {

+ 44 - 26
app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.java

@@ -57,6 +57,7 @@ import com.nextcloud.client.media.NextcloudExoPlayer;
 import com.nextcloud.client.media.PlayerServiceConnection;
 import com.nextcloud.client.network.ClientFactory;
 import com.nextcloud.common.NextcloudClient;
+import com.nextcloud.ui.fileactions.FileActionsBottomSheet;
 import com.owncloud.android.R;
 import com.owncloud.android.databinding.FragmentPreviewMediaBinding;
 import com.owncloud.android.datamodel.OCFile;
@@ -367,27 +368,28 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
     @Override
     public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
         super.onCreateOptionsMenu(menu, inflater);
-        menu.removeItem(R.id.action_search);
-        inflater.inflate(R.menu.item_file, menu);
+        menu.removeItem(R.id.action_search); // TODO handle in bottom sheet?
+        inflater.inflate(R.menu.custom_menu_placeholder, menu);
     }
 
-    @Override
-    public void onPrepareOptionsMenu(@NonNull Menu menu) {
+    // TODO remove
+    public void onPrepareOptionsMenu_old(@NonNull Menu menu) {
         super.onPrepareOptionsMenu(menu);
 
-        if (containerActivity.getStorageManager() != null) {
-            User currentUser = accountManager.getUser();
-            FileMenuFilter mf = new FileMenuFilter(
-                getFile(),
-                containerActivity,
-                getActivity(),
-                false,
-                currentUser
-            );
-
-            mf.filter(menu, true);
-        }
-
+//        if (containerActivity.getStorageManager() != null) {
+//            User currentUser = accountManager.getUser();
+//            FileMenuFilter mf = new FileMenuFilter(
+//                getFile(),
+//                containerActivity,
+//                getActivity(),
+//                false,
+//                currentUser
+//            );
+//
+//            mf.filter(menu, true);
+//        }
+
+        // TODO handle this in bottomsheet to
         // additional restriction for this fragment
         // TODO allow renaming in PreviewImageFragment
         MenuItem item = menu.findItem(R.id.action_rename_file);
@@ -441,25 +443,43 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
         }
     }
 
+
     @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        int itemId = item.getItemId();
+    // TODO replace with MenuProvider
+    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+        if (item.getItemId() == R.id.custom_menu_placeholder_item) {
+            final OCFile file = getFile();
+            if (containerActivity.getStorageManager() != null && file != null) {
+                // Update the file
+                final OCFile updatedFile = containerActivity.getStorageManager().getFileById(file.getFileId());
+                setFile(updatedFile);
+
+                final OCFile fileNew = getFile();
+                if (fileNew != null) {
+                    FileActionsBottomSheet.newInstance(fileNew,
+                                                       containerActivity,
+                                                       false,
+                                                       this::onFileActionChosen)
+                        .show(getActivity().getSupportFragmentManager(), "actions");
+                }
+            }
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    public void onFileActionChosen(final int itemId) {
         if (itemId == R.id.action_send_share_file) {
             sendShareFile();
-            return true;
         } else if (itemId == R.id.action_open_file_with) {
             openFile();
-            return true;
         } else if (itemId == R.id.action_remove_file) {
             RemoveFilesDialogFragment dialog = RemoveFilesDialogFragment.newInstance(getFile());
             dialog.show(getFragmentManager(), ConfirmationDialogFragment.FTAG_CONFIRMATION);
-            return true;
         } else if (itemId == R.id.action_see_details) {
             seeDetails();
-            return true;
         } else if (itemId == R.id.action_sync_file) {
             containerActivity.getFileOperationsHelper().syncFile(getFile());
-            return true;
         } else if (itemId == R.id.action_stream_media) {
             containerActivity.getFileOperationsHelper().streamMediaFile(getFile());
         } else if (itemId == R.id.action_export_file) {
@@ -469,9 +489,7 @@ public class PreviewMediaFragment extends FileFragment implements OnTouchListene
                                                                     getContext(),
                                                                     getView(),
                                                                     backgroundJobManager);
-            return true;
         }
-        return super.onOptionsItemSelected(item);
     }
 
     /**

+ 42 - 32
app/src/main/java/com/owncloud/android/ui/preview/PreviewTextFileFragment.java

@@ -34,6 +34,7 @@ import android.widget.TextView;
 
 import com.nextcloud.client.account.User;
 import com.nextcloud.client.account.UserAccountManager;
+import com.nextcloud.ui.fileactions.FileActionsBottomSheet;
 import com.owncloud.android.R;
 import com.owncloud.android.datamodel.OCFile;
 import com.owncloud.android.files.FileMenuFilter;
@@ -254,9 +255,10 @@ public class PreviewTextFileFragment extends PreviewTextFragment {
      * {@inheritDoc}
      */
     @Override
+    // TODO replace with MenuProvider
     public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
         super.onCreateOptionsMenu(menu, inflater);
-        inflater.inflate(R.menu.item_file, menu);
+        inflater.inflate(R.menu.custom_menu_placeholder, menu);
 
         MenuItem menuItem = menu.findItem(R.id.action_search);
         menuItem.setVisible(true);
@@ -271,25 +273,23 @@ public class PreviewTextFileFragment extends PreviewTextFragment {
         }
     }
 
-    /**
-     * {@inheritDoc}
-     */
-    @Override
-    public void onPrepareOptionsMenu(@NonNull Menu menu) {
+    // TODO remove
+    public void onPrepareOptionsMenu_old(@NonNull Menu menu) {
         super.onPrepareOptionsMenu(menu);
 
-        if (containerActivity.getStorageManager() != null) {
-            User user = accountManager.getUser();
-            FileMenuFilter mf = new FileMenuFilter(
-                getFile(),
-                containerActivity,
-                getActivity(),
-                false,
-                user
-            );
-            mf.filter(menu, true);
-        }
-
+//        if (containerActivity.getStorageManager() != null) {
+//            User user = accountManager.getUser();
+//            FileMenuFilter mf = new FileMenuFilter(
+//                getFile(),
+//                containerActivity,
+//                getActivity(),
+//                false,
+//                user
+//            );
+//            mf.filter(menu, true);
+//        }
+
+        // TODO remove in bottom sheet too
         // additional restriction for this fragment
         FileMenuFilter.hideMenuItems(
             menu.findItem(R.id.action_rename_file),
@@ -299,46 +299,56 @@ public class PreviewTextFileFragment extends PreviewTextFragment {
             menu.findItem(R.id.action_sync_file),
             menu.findItem(R.id.action_favorite),
             menu.findItem(R.id.action_unset_favorite)
-        );
+                                    );
 
         if (getFile().isSharedWithMe() && !getFile().canReshare()) {
             FileMenuFilter.hideMenuItem(menu.findItem(R.id.action_send_share_file));
         }
     }
 
-    /**
-     * {@inheritDoc}
-     */
+
     @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        int itemId = item.getItemId();
+    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+        if (item.getItemId() == R.id.custom_menu_placeholder_item) {
+            final OCFile file = getFile();
+            if (containerActivity.getStorageManager() != null && file != null) {
+                // Update the file
+                final OCFile updatedFile = containerActivity.getStorageManager().getFileById(file.getFileId());
+                setFile(updatedFile);
+
+                final OCFile fileNew = getFile();
+                if (fileNew != null) {
+                    FileActionsBottomSheet.newInstance(fileNew,
+                                                       containerActivity,
+                                                       false,
+                                                       this::onFileActionChosen)
+                        .show(getActivity().getSupportFragmentManager(), "actions");
+                }
+            }
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
 
+    private void onFileActionChosen(final int itemId) {
         if (itemId == R.id.action_send_share_file) {
             if (getFile().isSharedWithMe() && !getFile().canReshare()) {
                 DisplayUtils.showSnackMessage(getView(), R.string.resharing_is_not_allowed);
             } else {
                 containerActivity.getFileOperationsHelper().sendShareFile(getFile());
             }
-            return true;
         } else if (itemId == R.id.action_open_file_with) {
             openFile();
-            return true;
         } else if (itemId == R.id.action_remove_file) {
             RemoveFilesDialogFragment dialog = RemoveFilesDialogFragment.newInstance(getFile());
             dialog.show(getFragmentManager(), ConfirmationDialogFragment.FTAG_CONFIRMATION);
-            return true;
         } else if (itemId == R.id.action_see_details) {
             seeDetails();
-            return true;
         } else if (itemId == R.id.action_sync_file) {
             containerActivity.getFileOperationsHelper().syncFile(getFile());
-            return true;
         } else if (itemId == R.id.action_edit) {
             containerActivity.getFileOperationsHelper().openFileWithTextEditor(getFile(), getContext());
-            return true;
         }
-
-        return super.onOptionsItemSelected(item);
     }
 
     /**

+ 42 - 0
app/src/main/res/layout/file_actions_bottom_sheet.xml

@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Android client application
+  ~
+  ~  @author Álvaro Brey
+  ~  Copyright (C) 2022 Álvaro Brey
+  ~  Copyright (C) 2022 Nextcloud GmbH
+  ~
+  ~ This program is free software; you can redistribute it and/or
+  ~ modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+  ~ License as published by the Free Software Foundation; either
+  ~ version 3 of the License, or any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  ~ GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+  ~
+  ~ You should have received a copy of the GNU Affero General Public
+  ~ License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+  ~
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <FrameLayout
+        android:id="@+id/bottom_sheet"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
+
+        <LinearLayout
+            android:id="@+id/file_actions_list"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical">
+        </LinearLayout>
+    </FrameLayout>
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 43 - 0
app/src/main/res/layout/file_actions_bottom_sheet_item.xml

@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Android client application
+  ~
+  ~  @author Álvaro Brey
+  ~  Copyright (C) 2022 Álvaro Brey
+  ~  Copyright (C) 2022 Nextcloud GmbH
+  ~
+  ~ This program is free software; you can redistribute it and/or
+  ~ modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+  ~ License as published by the Free Software Foundation; either
+  ~ version 3 of the License, or any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  ~ GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+  ~
+  ~ You should have received a copy of the GNU Affero General Public
+  ~ License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+  ~
+  -->
+
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="@dimen/bottom_sheet_item_height"
+    android:background="?android:attr/selectableItemBackground"
+    android:drawablePadding="@dimen/bottom_sheet_text_start_margin"
+    android:gravity="center_vertical"
+    android:clickable="true"
+    android:focusable="true"
+    android:orientation="horizontal"
+    android:paddingLeft="@dimen/standard_padding"
+    android:paddingTop="@dimen/standard_half_padding"
+    android:paddingRight="@dimen/standard_padding"
+    android:paddingBottom="@dimen/standard_half_padding"
+    android:textColor="@color/text_color"
+    android:textSize="@dimen/bottom_sheet_text_size"
+    app:drawableStartCompat="@drawable/ic_warning"
+    tools:text="Share file">
+    <!--TODO change drawable-->
+</TextView>

+ 33 - 0
app/src/main/res/menu/custom_menu_placeholder.xml

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Android client application
+  ~
+  ~  @author Álvaro Brey
+  ~  Copyright (C) 2022 Álvaro Brey
+  ~  Copyright (C) 2022 Nextcloud GmbH
+  ~
+  ~ This program is free software; you can redistribute it and/or
+  ~ modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+  ~ License as published by the Free Software Foundation; either
+  ~ version 3 of the License, or any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  ~ GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+  ~
+  ~ You should have received a copy of the GNU Affero General Public
+  ~ License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+  ~
+  -->
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+    <!-- This is a "fake" menu so that we can implement custom actions on menu click, while still behaving and looking like a traditional menu item -->
+    <!-- TODO only color white in actionmode and always-black bars (previewimagefragment), use viewthemeutils for that-->
+    <item
+        android:id="@+id/custom_menu_placeholder_item"
+        android:icon="@drawable/ic_dots_vertical"
+        android:iconTint="@color/white"
+        android:title="@string/overflow_menu"
+        app:showAsAction="always" />
+
+</menu>