diff --git a/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt index 6953e66c7d77..dc1bfbb23a8d 100644 --- a/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt +++ b/app/src/main/java/com/nextcloud/ui/fileactions/FileActionsBottomSheet.kt @@ -74,7 +74,7 @@ class FileActionsBottomSheet : BottomSheetDialogFragment(), Injectable { private val thumbnailAsyncTasks = mutableListOf() - interface ResultListener { + fun interface ResultListener { fun onResult(@IdRes actionId: Int) } diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.java b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.java deleted file mode 100644 index 2fc1de717437..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.java +++ /dev/null @@ -1,707 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 TSI-mc - * SPDX-FileCopyrightText: 2023 Parneet Singh - * SPDX-FileCopyrightText: 2020 Andy Scherzinger - * SPDX-FileCopyrightText: 2019 Chris Narkiewicz - * SPDX-FileCopyrightText: 2016 ownCloud Inc. - * SPDX-FileCopyrightText: 2013 David A. Velasco - * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) - */ -package com.owncloud.android.ui.preview; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.drawable.Drawable; -import android.media.MediaMetadataRetriever; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.MotionEvent; -import android.view.View; -import android.view.View.OnTouchListener; -import android.view.ViewGroup; - -import com.nextcloud.client.account.User; -import com.nextcloud.client.account.UserAccountManager; -import com.nextcloud.client.di.Injectable; -import com.nextcloud.client.jobs.BackgroundJobManager; -import com.nextcloud.client.jobs.download.FileDownloadHelper; -import com.nextcloud.client.media.ExoplayerListener; -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.nextcloud.utils.extensions.BundleExtensionsKt; -import com.owncloud.android.MainApp; -import com.owncloud.android.R; -import com.owncloud.android.databinding.FragmentPreviewMediaBinding; -import com.owncloud.android.datamodel.OCFile; -import com.owncloud.android.datamodel.ThumbnailsCacheManager; -import com.owncloud.android.files.StreamMediaFileOperation; -import com.owncloud.android.lib.common.OwnCloudClient; -import com.owncloud.android.lib.common.operations.RemoteOperationResult; -import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.ui.activity.DrawerActivity; -import com.owncloud.android.ui.dialog.ConfirmationDialogFragment; -import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment; -import com.owncloud.android.ui.fragment.FileFragment; -import com.owncloud.android.utils.MimeTypeUtil; - -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.Executors; - -import javax.inject.Inject; - -import androidx.annotation.NonNull; -import androidx.annotation.OptIn; -import androidx.annotation.StringRes; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.drawerlayout.widget.DrawerLayout; -import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.FragmentManager; -import androidx.media3.common.MediaItem; -import androidx.media3.common.util.UnstableApi; -import androidx.media3.exoplayer.ExoPlayer; - -/** - * This fragment shows a preview of a downloaded media file (audio or video). - *

- * Trying to get an instance with NULL {@link OCFile} or ownCloud {@link User} values will produce an - * {@link IllegalStateException}. - *

- * By now, if the {@link OCFile} passed is not downloaded, an {@link IllegalStateException} is generated on - * instantiation too. - */ -public class PreviewMediaFragment extends FileFragment implements OnTouchListener, - Injectable { - - private static final String TAG = PreviewMediaFragment.class.getSimpleName(); - - public static final String EXTRA_FILE = "FILE"; - public static final String EXTRA_USER = "USER"; - public static final String EXTRA_AUTOPLAY = "AUTOPLAY"; - public static final String EXTRA_START_POSITION = "START_POSITION"; - - private static final String EXTRA_PLAY_POSITION = "PLAY_POSITION"; - private static final String EXTRA_PLAYING = "PLAYING"; - private static final double MIN_DENSITY_RATIO = 24.0; - - - private static final String FILE = "FILE"; - private static final String USER = "USER"; - private static final String PLAYBACK_POSITION = "PLAYBACK_POSITION"; - private static final String AUTOPLAY = "AUTOPLAY"; - private static final String IS_LIVE_PHOTO = "IS_LIVE_PHOTO"; - - private User user; - private long savedPlaybackPosition; - - private boolean autoplay; - private boolean isLivePhoto; - private boolean prepared; - private PlayerServiceConnection mediaPlayerServiceConnection; - - private Uri videoUri; - @Inject ClientFactory clientFactory; - @Inject UserAccountManager accountManager; - @Inject BackgroundJobManager backgroundJobManager; - FragmentPreviewMediaBinding binding; - private ViewGroup emptyListView; - private ExoPlayer exoPlayer; - private NextcloudClient nextcloudClient; - - /** - * Creates a fragment to preview a file. - *

- * When 'fileToDetail' or 'user' are null - * - * @param fileToDetail An {@link OCFile} to preview in the fragment - * @param user Currently active user - */ - public static PreviewMediaFragment newInstance(OCFile fileToDetail, - User user, - long startPlaybackPosition, - boolean autoplay, - boolean isLivePhoto) { - PreviewMediaFragment previewMediaFragment = new PreviewMediaFragment(); - - Bundle bundle = new Bundle(); - bundle.putParcelable(FILE, fileToDetail); - bundle.putParcelable(USER, user); - bundle.putLong(PLAYBACK_POSITION, startPlaybackPosition); - bundle.putBoolean(AUTOPLAY, autoplay); - bundle.putBoolean(IS_LIVE_PHOTO, isLivePhoto); - - previewMediaFragment.setArguments(bundle); - - return previewMediaFragment; - } - - /** - * Creates an empty fragment for previews. - *

- * MUST BE KEPT: the system uses it when tries to reinstantiate a fragment automatically (for instance, when the - * device is turned a aside). - *

- * DO NOT CALL IT: an {@link OCFile} and {@link User} must be provided for a successful construction - */ - public PreviewMediaFragment() { - super(); - - savedPlaybackPosition = 0; - autoplay = true; - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - - Bundle bundle = getArguments(); - - if (bundle != null) { - setFile(BundleExtensionsKt.getParcelableArgument(bundle, FILE, OCFile.class)); - user = BundleExtensionsKt.getParcelableArgument(bundle, USER, User.class); - - savedPlaybackPosition = bundle.getLong(PLAYBACK_POSITION); - autoplay = bundle.getBoolean(AUTOPLAY); - isLivePhoto = bundle.getBoolean(IS_LIVE_PHOTO); - } - - mediaPlayerServiceConnection = new PlayerServiceConnection(requireContext()); - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - super.onCreateView(inflater, container, savedInstanceState); - Log_OC.v(TAG, "onCreateView"); - - binding = FragmentPreviewMediaBinding.inflate(inflater, container, false); - View view = binding.getRoot(); - - emptyListView = binding.emptyView.emptyListView; - - setLoadingView(); - return view; - } - - private void setLoadingView() { - binding.progress.setVisibility(View.VISIBLE); - binding.emptyView.emptyListView.setVisibility(View.GONE); - } - - private void setVideoErrorMessage(String headline, @StringRes int message) { - binding.emptyView.emptyListViewHeadline.setText(headline); - binding.emptyView.emptyListViewText.setText(message); - binding.emptyView.emptyListIcon.setImageResource(R.drawable.file_movie); - binding.emptyView.emptyListViewText.setVisibility(View.VISIBLE); - binding.emptyView.emptyListIcon.setVisibility(View.VISIBLE); - binding.progress.setVisibility(View.GONE); - binding.emptyView.emptyListView.setVisibility(View.VISIBLE); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - Log_OC.v(TAG, "onActivityCreated"); - - OCFile file = getFile(); - if (savedInstanceState == null) { - if (file == null) { - throw new IllegalStateException("Instanced with a NULL OCFile"); - } - if (user == null) { - throw new IllegalStateException("Instanced with a NULL ownCloud Account"); - } - } else { - file = BundleExtensionsKt.getParcelableArgument(savedInstanceState, EXTRA_FILE, OCFile.class); - setFile(file); - user = BundleExtensionsKt.getParcelableArgument(savedInstanceState, EXTRA_USER, User.class); - savedPlaybackPosition = savedInstanceState.getInt(EXTRA_PLAY_POSITION); - autoplay = savedInstanceState.getBoolean(EXTRA_PLAYING); - } - - if (file != null) { - if (MimeTypeUtil.isVideo(file)) { - binding.exoplayerView.setVisibility(View.VISIBLE); - binding.imagePreview.setVisibility(View.GONE); - } else { - binding.exoplayerView.setVisibility(View.GONE); - binding.imagePreview.setVisibility(View.VISIBLE); - extractAndSetCoverArt(file); - } - } - toggleDrawerLockMode(containerActivity, DrawerLayout.LOCK_MODE_LOCKED_CLOSED); - } - - /** - * tries to read the cover art from the audio file and sets it as cover art. - * - * @param file audio file with potential cover art - */ - private void extractAndSetCoverArt(OCFile file) { - if (MimeTypeUtil.isAudio(file)) { - if (file.getStoragePath() == null) { - setThumbnailForAudio(file); - } else { - try { - MediaMetadataRetriever mmr = new MediaMetadataRetriever(); - mmr.setDataSource(file.getStoragePath()); - byte[] data = mmr.getEmbeddedPicture(); - if (data != null) { - Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length); - binding.imagePreview.setImageBitmap(bitmap); //associated cover art in bitmap - } else { - setThumbnailForAudio(file); - } - } catch (Throwable t) { - setGenericThumbnail(); - } - } - } - } - - private void setThumbnailForAudio(OCFile file) { - Bitmap thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache( - ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.getRemoteId()); - - if (thumbnail != null) { - binding.imagePreview.setImageBitmap(thumbnail); - } else { - setGenericThumbnail(); - } - } - - /** - * Set generic icon (logo) as placeholder for thumbnail in preview. - */ - private void setGenericThumbnail() { - Drawable logo = AppCompatResources.getDrawable(requireContext(), R.drawable.logo); - if (logo != null) { - if (!getResources().getBoolean(R.bool.is_branded_client)) { - // only colour logo of non-branded client - DrawableCompat.setTint(logo, getResources().getColor(R.color.primary, requireContext().getTheme())); - } - binding.imagePreview.setImageDrawable(logo); - } - } - - @Override - public void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - Log_OC.v(TAG, "onSaveInstanceState"); - toggleDrawerLockMode(containerActivity, DrawerLayout.LOCK_MODE_LOCKED_CLOSED); - outState.putParcelable(EXTRA_FILE, getFile()); - outState.putParcelable(EXTRA_USER, user); - - if (MimeTypeUtil.isVideo(getFile()) && exoPlayer != null) { - savedPlaybackPosition = exoPlayer.getCurrentPosition(); - autoplay = exoPlayer.isPlaying(); - outState.putLong(EXTRA_PLAY_POSITION, savedPlaybackPosition); - outState.putBoolean(EXTRA_PLAYING, autoplay); - } else if (mediaPlayerServiceConnection != null && mediaPlayerServiceConnection.isConnected()) { - outState.putInt(EXTRA_PLAY_POSITION, mediaPlayerServiceConnection.getCurrentPosition()); - outState.putBoolean(EXTRA_PLAYING, mediaPlayerServiceConnection.isPlaying()); - } - } - - @Override - public void onStart() { - super.onStart(); - Log_OC.v(TAG, "onStart"); - - @NonNull Context context; - if (getContext() != null) { - context = getContext(); - } else { - context = MainApp.getAppContext(); - } - - - OCFile file = getFile(); - if (file != null) { - // bind to any existing player - mediaPlayerServiceConnection.bind(); - - if (MimeTypeUtil.isAudio(file)) { - binding.mediaController.setMediaPlayer(mediaPlayerServiceConnection); - binding.mediaController.setVisibility(View.VISIBLE); - mediaPlayerServiceConnection.start(user, file, autoplay, savedPlaybackPosition); - binding.emptyView.emptyListView.setVisibility(View.GONE); - binding.progress.setVisibility(View.GONE); - } else if (MimeTypeUtil.isVideo(file)) { - if (mediaPlayerServiceConnection.isConnected()) { - // always stop player - stopAudio(); - } - if (exoPlayer != null) { - playVideo(); - } else { - final Handler handler = new Handler(Looper.getMainLooper()); - Executors.newSingleThreadExecutor().execute(() -> { - try { - nextcloudClient = clientFactory.createNextcloudClient(accountManager.getUser()); - handler.post(() -> { - exoPlayer = NextcloudExoPlayer.createNextcloudExoplayer(context, nextcloudClient); - - exoPlayer.addListener(new ExoplayerListener(context, binding.exoplayerView, exoPlayer, () -> { - goBackToLivePhoto(); - return null; - })); - - playVideo(); - }); - } catch (ClientFactory.CreationException e) { - handler.post(() -> Log_OC.e(TAG, "error setting up ExoPlayer", e)); - } - }); - } - } - } - } - - private void goBackToLivePhoto() { - if (!isLivePhoto) { - return; - } - - showActionBar(); - - requireActivity().getSupportFragmentManager().popBackStack(); - } - - private void showActionBar() { - Activity currentActivity = requireActivity(); - if (currentActivity instanceof PreviewImageActivity activity) { - activity.toggleActionBarVisibility(false); - } - } - @OptIn(markerClass = UnstableApi.class) - private void setupVideoView() { - binding.exoplayerView.setShowNextButton(false); - binding.exoplayerView.setShowPreviousButton(false); - binding.exoplayerView.setPlayer(exoPlayer); - binding.exoplayerView.setFullscreenButtonClickListener(isFullScreen -> startFullScreenVideo()); - } - - private void stopAudio() { - mediaPlayerServiceConnection.stop(); - } - - @Override - public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - menu.removeItem(R.id.action_search); - inflater.inflate(R.menu.custom_menu_placeholder, menu); - } - - @Override - 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) { - showFileActions(fileNew); - } - } - } - return super.onOptionsItemSelected(item); - } - - private void showFileActions(OCFile file) { - final List additionalFilter = new ArrayList<>( - Arrays.asList( - R.id.action_rename_file, - R.id.action_sync_file, - R.id.action_move_or_copy, - R.id.action_favorite, - R.id.action_unset_favorite, - R.id.action_pin_to_homescreen - )); - if (getFile() != null && getFile().isSharedWithMe() && !getFile().canReshare()) { - additionalFilter.add(R.id.action_send_share_file); - } - final FragmentManager fragmentManager = getChildFragmentManager(); - FileActionsBottomSheet.newInstance(file, false, additionalFilter) - .setResultListener(fragmentManager, this, this::onFileActionChosen) - .show(fragmentManager, "actions"); - } - - public void onFileActionChosen(final int itemId) { - if (itemId == R.id.action_send_share_file) { - sendShareFile(); - } else if (itemId == R.id.action_open_file_with) { - openFile(); - } else if (itemId == R.id.action_remove_file) { - RemoveFilesDialogFragment dialog = RemoveFilesDialogFragment.newInstance(getFile()); - dialog.show(getFragmentManager(), ConfirmationDialogFragment.FTAG_CONFIRMATION); - } else if (itemId == R.id.action_see_details) { - seeDetails(); - } else if (itemId == R.id.action_sync_file) { - containerActivity.getFileOperationsHelper().syncFile(getFile()); - } else if (itemId == R.id.action_cancel_sync) { - containerActivity.getFileOperationsHelper().cancelTransference(getFile()); - } else if (itemId == R.id.action_stream_media) { - containerActivity.getFileOperationsHelper().streamMediaFile(getFile()); - } else if (itemId == R.id.action_export_file) { - ArrayList list = new ArrayList<>(); - list.add(getFile()); - containerActivity.getFileOperationsHelper().exportFiles(list, - getContext(), - getView(), - backgroundJobManager); - } else if (itemId == R.id.action_download_file) { - FileDownloadHelper.Companion.instance().downloadFileIfNotStartedBefore(user, getFile()); - } - } - - /** - * Update the file of the fragment with file value - * - * @param file Replaces the held file with a new one - */ - public void updateFile(OCFile file) { - setFile(file); - } - - private void seeDetails() { - stopPreview(false); - containerActivity.showDetails(getFile()); - } - - private void sendShareFile() { - stopPreview(false); - containerActivity.getFileOperationsHelper().sendShareFile(getFile()); - } - - private void playVideo() { - setupVideoView(); - // load the video file in the video player - // when done, VideoHelper#onPrepared() will be called - if (getFile().isDown()) { - playVideoUri(getFile().getStorageUri()); - } else { - try { - new LoadStreamUrl(this, user, clientFactory).execute(getFile().getLocalId()); - } catch (Exception e) { - Log_OC.e(TAG, "Loading stream url not possible: " + e); - } - } - } - - private void playVideoUri(final Uri uri) { - binding.progress.setVisibility(View.GONE); - - exoPlayer.setMediaItem(MediaItem.fromUri(uri)); - exoPlayer.setPlayWhenReady(autoplay); - exoPlayer.prepare(); - - if (savedPlaybackPosition >= 0) { - exoPlayer.seekTo(savedPlaybackPosition); - } - - // only autoplay video once - autoplay = false; - } - - private static class LoadStreamUrl extends AsyncTask { - - private final ClientFactory clientFactory; - private final User user; - private final WeakReference previewMediaFragmentWeakReference; - - public LoadStreamUrl(PreviewMediaFragment previewMediaFragment, User user, ClientFactory clientFactory) { - this.previewMediaFragmentWeakReference = new WeakReference<>(previewMediaFragment); - this.user = user; - this.clientFactory = clientFactory; - } - - @Override - protected Uri doInBackground(Long... fileId) { - OwnCloudClient client; - try { - client = clientFactory.create(user); - } catch (ClientFactory.CreationException e) { - Log_OC.e(TAG, "Loading stream url not possible: " + e); - return null; - } - - StreamMediaFileOperation sfo = new StreamMediaFileOperation(fileId[0]); - RemoteOperationResult result = sfo.execute(client); - - if (!result.isSuccess()) { - return null; - } - - return Uri.parse((String) result.getData().get(0)); - } - - @Override - protected void onPostExecute(Uri uri) { - final PreviewMediaFragment previewMediaFragment = previewMediaFragmentWeakReference.get(); - final Context context = previewMediaFragment != null ? previewMediaFragment.getContext() : null; - if (previewMediaFragment != null && previewMediaFragment.binding != null && context != null) { - if (uri != null) { - previewMediaFragment.videoUri = uri; - previewMediaFragment.playVideoUri(uri); - } else { - previewMediaFragment.emptyListView.setVisibility(View.VISIBLE); - previewMediaFragment.setVideoErrorMessage( - previewMediaFragment.getString(R.string.stream_not_possible_headline), - R.string.stream_not_possible_message); - } - } else { - Log_OC.e(TAG, "Error streaming file: no previewMediaFragment!"); - } - } - } - - @Override - public void onPause() { - Log_OC.v(TAG, "onPause"); - super.onPause(); - } - - @Override - public void onResume() { - super.onResume(); - Log_OC.v(TAG, "onResume"); - } - - @Override - public void onDestroy() { - Log_OC.v(TAG, "onDestroy"); - super.onDestroy(); - } - - @Override - public void onDestroyView() { - Log_OC.v(TAG, "onDestroyView"); - super.onDestroyView(); - binding = null; - } - - @Override - public void onStop() { - Log_OC.v(TAG, "onStop"); - final OCFile file = getFile(); - if (MimeTypeUtil.isAudio(file) && !mediaPlayerServiceConnection.isPlaying()) { - stopAudio(); - } else if (MimeTypeUtil.isVideo(file) && exoPlayer != null && exoPlayer.isPlaying()) { - savedPlaybackPosition = exoPlayer.getCurrentPosition(); - exoPlayer.pause(); - } - - mediaPlayerServiceConnection.unbind(); - toggleDrawerLockMode(containerActivity, DrawerLayout.LOCK_MODE_UNLOCKED); - super.onStop(); - } - - @Override - public boolean onTouch(View v, MotionEvent event) { - if (event.getAction() == MotionEvent.ACTION_DOWN && v.equals(binding.exoplayerView)) { - // added a margin on the left to avoid interfering with gesture to open navigation drawer - if (event.getX() / Resources.getSystem().getDisplayMetrics().density > MIN_DENSITY_RATIO) { - startFullScreenVideo(); - } - return true; - } - return false; - } - - private void startFullScreenVideo() { - final FragmentActivity activity = getActivity(); - if (activity != null) { - new PreviewVideoFullscreenDialog(activity, nextcloudClient, exoPlayer, binding.exoplayerView).show(); - } - } - - @Override - public void onConfigurationChanged(@NonNull Configuration newConfig) { - super.onConfigurationChanged(newConfig); - Log_OC.v(TAG, "onConfigurationChanged " + this); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - Log_OC.v(TAG, "onActivityResult " + this); - super.onActivityResult(requestCode, resultCode, data); - if (resultCode == Activity.RESULT_OK) { - savedPlaybackPosition = data.getLongExtra(PreviewMediaFragment.EXTRA_START_POSITION, 0); - autoplay = data.getBooleanExtra(PreviewMediaFragment.EXTRA_AUTOPLAY, false); - } - } - - /** - * Opens the previewed file with an external application. - */ - private void openFile() { - stopPreview(true); - containerActivity.getFileOperationsHelper().openFile(getFile()); - } - - /** - * Helper method to test if an {@link OCFile} can be passed to a {@link PreviewMediaFragment} to be previewed. - * - * @param file File to test if can be previewed. - * @return 'True' if the file can be handled by the fragment. - */ - public static boolean canBePreviewed(OCFile file) { - return file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)); - } - - public void stopPreview(boolean stopAudio) { - if (stopAudio && mediaPlayerServiceConnection != null) { - mediaPlayerServiceConnection.stop(); - } else if (exoPlayer != null) { - savedPlaybackPosition = exoPlayer.getCurrentPosition(); - exoPlayer.stop(); - } - } - - public long getPosition() { - if (prepared) { - savedPlaybackPosition = exoPlayer.getCurrentPosition(); - } - Log_OC.v(TAG, "getting position: " + savedPlaybackPosition); - return savedPlaybackPosition; - } - - private void toggleDrawerLockMode(ContainerActivity containerActivity, int lockMode) { - ((DrawerActivity) containerActivity).setDrawerLockMode(lockMode); - } - - @Override - public void onDetach() { - - if (exoPlayer != null) { - exoPlayer.stop(); - exoPlayer.release(); - } - - super.onDetach(); - } -} 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 new file mode 100644 index 000000000000..b06c0fea4ad5 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt @@ -0,0 +1,720 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 TSI-mc + * SPDX-FileCopyrightText: 2023 Parneet Singh + * SPDX-FileCopyrightText: 2020 Andy Scherzinger + * SPDX-FileCopyrightText: 2019 Chris Narkiewicz + * SPDX-FileCopyrightText: 2016 ownCloud Inc. + * SPDX-FileCopyrightText: 2013 David A. Velasco + * SPDX-License-Identifier: GPL-2.0-only AND (AGPL-3.0-or-later OR GPL-2.0-only) + */ +package com.owncloud.android.ui.preview + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.content.res.Configuration +import android.content.res.Resources +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.AsyncTask +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.view.View.OnTouchListener +import android.view.ViewGroup +import androidx.annotation.OptIn +import androidx.annotation.StringRes +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider +import androidx.drawerlayout.widget.DrawerLayout +import androidx.lifecycle.Lifecycle +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.jobs.download.FileDownloadHelper.Companion.instance +import com.nextcloud.client.media.ExoplayerListener +import com.nextcloud.client.media.NextcloudExoPlayer.createNextcloudExoplayer +import com.nextcloud.client.media.PlayerServiceConnection +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.network.ClientFactory.CreationException +import com.nextcloud.common.NextcloudClient +import com.nextcloud.ui.fileactions.FileActionsBottomSheet.Companion.newInstance +import com.nextcloud.utils.extensions.getParcelableArgument +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.databinding.FragmentPreviewMediaBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.files.StreamMediaFileOperation +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.activity.DrawerActivity +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment +import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment +import com.owncloud.android.ui.fragment.FileFragment +import com.owncloud.android.utils.MimeTypeUtil +import java.lang.ref.WeakReference +import java.util.concurrent.Executors +import javax.inject.Inject + +/** + * This fragment shows a preview of a downloaded media file (audio or video). + * + * + * Trying to get an instance with NULL [OCFile] or ownCloud [User] values will produce an + * [IllegalStateException]. + * + * + * By now, if the [OCFile] passed is not downloaded, an [IllegalStateException] is generated on + * instantiation too. + */ + +/** + * Creates an empty fragment for previews. + * + * + * MUST BE KEPT: the system uses it when tries to reinstantiate a fragment automatically (for instance, when the + * device is turned a aside). + * + * + * DO NOT CALL IT: an [OCFile] and [User] must be provided for a successful construction + */ + +@Suppress("NestedBlockDepth", "ComplexMethod", "LongMethod", "TooManyFunctions") +class PreviewMediaFragment : FileFragment(), OnTouchListener, Injectable { + private var user: User? = null + private var savedPlaybackPosition: Long = 0 + + private var autoplay = true + private var isLivePhoto = false + private val prepared = false + private var mediaPlayerServiceConnection: PlayerServiceConnection? = null + + private var videoUri: Uri? = null + + @Inject + lateinit var clientFactory: ClientFactory + + @Inject + lateinit var accountManager: UserAccountManager + + @Inject + lateinit var backgroundJobManager: BackgroundJobManager + + lateinit var binding: FragmentPreviewMediaBinding + private var emptyListView: ViewGroup? = null + private var exoPlayer: ExoPlayer? = null + private var nextcloudClient: NextcloudClient? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + arguments?.let { bundle -> + file = bundle.getParcelableArgument(FILE, OCFile::class.java) + user = bundle.getParcelableArgument(USER, User::class.java) + + savedPlaybackPosition = bundle.getLong(PLAYBACK_POSITION) + autoplay = bundle.getBoolean(AUTOPLAY) + isLivePhoto = bundle.getBoolean(IS_LIVE_PHOTO) + } + + mediaPlayerServiceConnection = PlayerServiceConnection(requireContext()) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + super.onCreateView(inflater, container, savedInstanceState) + Log_OC.v(TAG, "onCreateView") + + binding = FragmentPreviewMediaBinding.inflate(inflater, container, false) + emptyListView = binding.emptyView.emptyListView + setLoadingView() + + return binding.root + } + + private fun setLoadingView() { + binding.progress.visibility = View.VISIBLE + binding.emptyView.emptyListView.visibility = View.GONE + } + + private fun setVideoErrorMessage(headline: String, @StringRes message: Int = R.string.stream_not_possible_message) { + binding.emptyView.run { + emptyListViewHeadline.text = headline + emptyListViewText.setText(message) + emptyListIcon.setImageResource(R.drawable.file_movie) + emptyListViewText.visibility = View.VISIBLE + emptyListIcon.visibility = View.VISIBLE + emptyListView.visibility = View.VISIBLE + } + + binding.progress.visibility = View.GONE + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + Log_OC.v(TAG, "onActivityCreated") + + var file = file + if (savedInstanceState == null) { + checkNotNull(file) { "Instanced with a NULL OCFile" } + checkNotNull(user) { "Instanced with a NULL ownCloud Account" } + } else { + file = savedInstanceState.getParcelableArgument(EXTRA_FILE, OCFile::class.java) + setFile(file) + user = savedInstanceState.getParcelableArgument(EXTRA_USER, User::class.java) + savedPlaybackPosition = savedInstanceState.getInt(EXTRA_PLAY_POSITION).toLong() + autoplay = savedInstanceState.getBoolean(EXTRA_PLAYING) + } + + if (file != null) { + if (MimeTypeUtil.isVideo(file)) { + binding.exoplayerView.visibility = View.VISIBLE + binding.imagePreview.visibility = View.GONE + } else { + binding.exoplayerView.visibility = View.GONE + binding.imagePreview.visibility = View.VISIBLE + extractAndSetCoverArt(file) + } + } + + toggleDrawerLockMode(containerActivity, DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + addMenuHost() + } + + /** + * tries to read the cover art from the audio file and sets it as cover art. + * + * @param file audio file with potential cover art + */ + + @Suppress("TooGenericExceptionCaught") + private fun extractAndSetCoverArt(file: OCFile) { + if (!MimeTypeUtil.isAudio(file)) return + + if (file.storagePath == null) { + setThumbnailForAudio(file) + } else { + try { + val mmr = MediaMetadataRetriever().apply { + setDataSource(file.storagePath) + } + + val data = mmr.embeddedPicture + if (data != null) { + val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size) + binding.imagePreview.setImageBitmap(bitmap) // associated cover art in bitmap + } else { + setThumbnailForAudio(file) + } + } catch (t: Throwable) { + setGenericThumbnail() + } + } + } + + private fun setThumbnailForAudio(file: OCFile) { + val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache( + ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId + ) + + if (thumbnail != null) { + binding.imagePreview.setImageBitmap(thumbnail) + } else { + setGenericThumbnail() + } + } + + /** + * Set generic icon (logo) as placeholder for thumbnail in preview. + */ + private fun setGenericThumbnail() { + AppCompatResources.getDrawable(requireContext(), R.drawable.logo)?.let { logo -> + if (!resources.getBoolean(R.bool.is_branded_client)) { + // only colour logo of non-branded client + DrawableCompat.setTint(logo, resources.getColor(R.color.primary, requireContext().theme)) + } + binding.imagePreview.setImageDrawable(logo) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Log_OC.v(TAG, "onSaveInstanceState") + toggleDrawerLockMode(containerActivity, DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + + outState.run { + putParcelable(EXTRA_FILE, file) + putParcelable(EXTRA_USER, user) + + if (MimeTypeUtil.isVideo(file) && exoPlayer != null) { + savedPlaybackPosition = exoPlayer?.currentPosition ?: 0L + autoplay = exoPlayer?.isPlaying ?: false + putLong(EXTRA_PLAY_POSITION, savedPlaybackPosition) + putBoolean(EXTRA_PLAYING, autoplay) + } else if (mediaPlayerServiceConnection != null && mediaPlayerServiceConnection?.isConnected == true) { + putInt(EXTRA_PLAY_POSITION, mediaPlayerServiceConnection?.currentPosition ?: 0) + putBoolean(EXTRA_PLAYING, mediaPlayerServiceConnection?.isPlaying ?: false) + } + } + } + + @Suppress("TooGenericExceptionCaught") + override fun onStart() { + super.onStart() + Log_OC.v(TAG, "onStart") + val context = if (context != null) { + requireContext() + } else { + MainApp.getAppContext() + } + + val file = file + if (file != null) { + // bind to any existing player + mediaPlayerServiceConnection?.bind() + + if (MimeTypeUtil.isAudio(file)) { + binding.mediaController.setMediaPlayer(mediaPlayerServiceConnection) + binding.mediaController.visibility = View.VISIBLE + mediaPlayerServiceConnection?.start(user!!, file, autoplay, savedPlaybackPosition) + binding.emptyView.emptyListView.visibility = View.GONE + binding.progress.visibility = View.GONE + } else if (MimeTypeUtil.isVideo(file)) { + if (mediaPlayerServiceConnection?.isConnected == true) { + // always stop player + stopAudio() + } + if (exoPlayer != null) { + playVideo() + } else { + val handler = Handler(Looper.getMainLooper()) + Executors.newSingleThreadExecutor().execute { + try { + nextcloudClient = clientFactory.createNextcloudClient(accountManager.user) + handler.post { + exoPlayer = createNextcloudExoplayer(context, nextcloudClient!!) + exoPlayer?.addListener( + ExoplayerListener( + context, + binding.exoplayerView, + exoPlayer!! + ) { + goBackToLivePhoto() + } + ) + playVideo() + } + } catch (e: CreationException) { + handler.post { Log_OC.e(TAG, "error setting up ExoPlayer", e) } + } + } + } + } + } + } + + private fun goBackToLivePhoto() { + if (!isLivePhoto) { + return + } + + showActionBar() + requireActivity().supportFragmentManager.popBackStack() + } + + private fun showActionBar() { + val currentActivity: Activity = requireActivity() + if (currentActivity is PreviewImageActivity) { + currentActivity.toggleActionBarVisibility(false) + } + } + + @OptIn(UnstableApi::class) + private fun setupVideoView() { + binding.exoplayerView.run { + setShowNextButton(false) + setShowPreviousButton(false) + player = exoPlayer + setFullscreenButtonClickListener { startFullScreenVideo() } + } + } + + private fun stopAudio() { + mediaPlayerServiceConnection?.stop() + } + + private fun addMenuHost() { + val menuHost: MenuHost = requireActivity() + + menuHost.addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menu.removeItem(R.id.action_search) + menuInflater.inflate(R.menu.custom_menu_placeholder, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.custom_menu_placeholder_item -> { + if (containerActivity.storageManager == null || file == null) return false + + val updatedFile = containerActivity.storageManager.getFileById(file.fileId) + file = updatedFile + file?.let { newFile -> + showFileActions(newFile) + } + + true + } + + else -> false + } + } + }, + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + } + + private fun showFileActions(file: OCFile) { + val additionalFilter: MutableList = ArrayList( + listOf( + R.id.action_rename_file, + R.id.action_sync_file, + R.id.action_move_or_copy, + R.id.action_favorite, + R.id.action_unset_favorite, + R.id.action_pin_to_homescreen + ) + ) + + if (getFile() != null && getFile().isSharedWithMe && !getFile().canReshare()) { + additionalFilter.add(R.id.action_send_share_file) + } + + newInstance(file, false, additionalFilter) + .setResultListener(childFragmentManager, this) { itemId: Int -> this.onFileActionChosen(itemId) } + .show(childFragmentManager, "actions") + } + + private fun onFileActionChosen(itemId: Int) { + when (itemId) { + R.id.action_send_share_file -> { + sendShareFile() + } + + R.id.action_open_file_with -> { + openFile() + } + + R.id.action_remove_file -> { + val dialog = RemoveFilesDialogFragment.newInstance(file) + dialog.show(requireFragmentManager(), ConfirmationDialogFragment.FTAG_CONFIRMATION) + } + + R.id.action_see_details -> { + seeDetails() + } + + R.id.action_sync_file -> { + containerActivity.fileOperationsHelper.syncFile(file) + } + + R.id.action_cancel_sync -> { + containerActivity.fileOperationsHelper.cancelTransference(file) + } + + R.id.action_stream_media -> { + containerActivity.fileOperationsHelper.streamMediaFile(file) + } + + R.id.action_export_file -> { + val list = ArrayList() + list.add(file) + containerActivity.fileOperationsHelper.exportFiles( + list, + context, + view, + backgroundJobManager + ) + } + + R.id.action_download_file -> { + instance().downloadFileIfNotStartedBefore(user!!, file) + } + } + } + + /** + * Update the file of the fragment with file value + * + * @param file Replaces the held file with a new one + */ + fun updateFile(file: OCFile?) { + setFile(file) + } + + private fun seeDetails() { + stopPreview(false) + containerActivity.showDetails(file) + } + + private fun sendShareFile() { + stopPreview(false) + containerActivity.fileOperationsHelper.sendShareFile(file) + } + + @Suppress("TooGenericExceptionCaught") + private fun playVideo() { + setupVideoView() + // load the video file in the video player + // when done, VideoHelper#onPrepared() will be called + if (file.isDown) { + playVideoUri(file.storageUri) + } else { + try { + LoadStreamUrl(this, user, clientFactory).execute( + file.localId + ) + } catch (e: Exception) { + Log_OC.e(TAG, "Loading stream url not possible: $e") + } + } + } + + private fun playVideoUri(uri: Uri) { + binding.progress.visibility = View.GONE + + exoPlayer?.setMediaItem(MediaItem.fromUri(uri)) + exoPlayer?.playWhenReady = autoplay + exoPlayer?.prepare() + + if (savedPlaybackPosition >= 0) { + exoPlayer?.seekTo(savedPlaybackPosition) + } + + // only autoplay video once + autoplay = false + } + + @Suppress("DEPRECATION", "ReturnCount") + private class LoadStreamUrl( + previewMediaFragment: PreviewMediaFragment, + private val user: User?, + private val clientFactory: ClientFactory? + ) : AsyncTask() { + private val previewMediaFragmentWeakReference = WeakReference(previewMediaFragment) + + @Deprecated("Deprecated in Java") + override fun doInBackground(vararg fileId: Long?): Uri? { + val client: OwnCloudClient? + try { + client = clientFactory?.create(user) + } catch (e: CreationException) { + Log_OC.e(TAG, "Loading stream url not possible: $e") + return null + } + + val sfo = fileId[0]?.let { StreamMediaFileOperation(it) } + val result = sfo?.execute(client) + + if (result?.isSuccess == false) { + return null + } + + return Uri.parse(result?.data?.get(0) as String) + } + + @Deprecated("Deprecated in Java") + override fun onPostExecute(uri: Uri?) { + val previewMediaFragment = previewMediaFragmentWeakReference.get() + val context = previewMediaFragment?.context + + if (previewMediaFragment?.binding == null || context == null) { + Log_OC.e(TAG, "Error streaming file: no previewMediaFragment!") + return + } + + previewMediaFragment.run { + if (uri != null) { + videoUri = uri + playVideoUri(uri) + } else { + emptyListView?.visibility = View.VISIBLE + setVideoErrorMessage(getString(R.string.stream_not_possible_headline)) + } + } + } + } + + override fun onStop() { + Log_OC.v(TAG, "onStop") + val file = file + + if (MimeTypeUtil.isAudio(file) && mediaPlayerServiceConnection?.isPlaying == false) { + stopAudio() + } else if (MimeTypeUtil.isVideo(file) && exoPlayer != null && exoPlayer?.isPlaying == true) { + savedPlaybackPosition = exoPlayer?.currentPosition ?: 0L + exoPlayer?.pause() + } + + mediaPlayerServiceConnection?.unbind() + toggleDrawerLockMode(containerActivity, DrawerLayout.LOCK_MODE_UNLOCKED) + + super.onStop() + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View, event: MotionEvent): Boolean { + if (event.action == MotionEvent.ACTION_DOWN && v == binding.exoplayerView) { + // added a margin on the left to avoid interfering with gesture to open navigation drawer + if (event.x / Resources.getSystem().displayMetrics.density > MIN_DENSITY_RATIO) { + startFullScreenVideo() + } + return true + } + return false + } + + private fun startFullScreenVideo() { + activity?.let { activity -> + nextcloudClient?.let { client -> + exoPlayer?.let { player -> + PreviewVideoFullscreenDialog(activity, client, player, binding.exoplayerView).show() + } + } + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + Log_OC.v(TAG, "onConfigurationChanged $this") + } + + @Suppress("DEPRECATION") + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + Log_OC.v(TAG, "onActivityResult $this") + super.onActivityResult(requestCode, resultCode, data) + if (resultCode == Activity.RESULT_OK) { + savedPlaybackPosition = data?.getLongExtra(EXTRA_START_POSITION, 0) ?: 0L + autoplay = data?.getBooleanExtra(EXTRA_AUTOPLAY, false) ?: false + } + } + + /** + * Opens the previewed file with an external application. + */ + private fun openFile() { + stopPreview(true) + containerActivity.fileOperationsHelper.openFile(file) + } + + private fun stopPreview(stopAudio: Boolean) { + if (stopAudio && mediaPlayerServiceConnection != null) { + mediaPlayerServiceConnection?.stop() + } else if (exoPlayer != null) { + savedPlaybackPosition = exoPlayer?.currentPosition ?: 0L + exoPlayer?.stop() + } + } + + val position: Long + get() { + if (prepared) { + savedPlaybackPosition = exoPlayer?.currentPosition ?: 0 + } + Log_OC.v(TAG, "getting position: $savedPlaybackPosition") + return savedPlaybackPosition + } + + private fun toggleDrawerLockMode(containerActivity: ContainerActivity, lockMode: Int) { + (containerActivity as DrawerActivity).setDrawerLockMode(lockMode) + } + + override fun onDetach() { + exoPlayer?.let { + it.stop() + it.release() + } + + super.onDetach() + } + + companion object { + private val TAG: String = PreviewMediaFragment::class.java.simpleName + + const val EXTRA_FILE: String = "FILE" + const val EXTRA_USER: String = "USER" + const val EXTRA_AUTOPLAY: String = "AUTOPLAY" + const val EXTRA_START_POSITION: String = "START_POSITION" + + private const val EXTRA_PLAY_POSITION = "PLAY_POSITION" + private const val EXTRA_PLAYING = "PLAYING" + private const val MIN_DENSITY_RATIO = 24.0 + + private const val FILE = "FILE" + private const val USER = "USER" + private const val PLAYBACK_POSITION = "PLAYBACK_POSITION" + private const val AUTOPLAY = "AUTOPLAY" + private const val IS_LIVE_PHOTO = "IS_LIVE_PHOTO" + + /** + * Creates a fragment to preview a file. + * + * + * When 'fileToDetail' or 'user' are null + * + * @param fileToDetail An [OCFile] to preview in the fragment + * @param user Currently active user + */ + @JvmStatic + fun newInstance( + fileToDetail: OCFile?, + user: User?, + startPlaybackPosition: Long, + autoplay: Boolean, + isLivePhoto: Boolean + ): PreviewMediaFragment { + val previewMediaFragment = PreviewMediaFragment() + + val bundle = Bundle().apply { + putParcelable(FILE, fileToDetail) + putParcelable(USER, user) + putLong(PLAYBACK_POSITION, startPlaybackPosition) + putBoolean(AUTOPLAY, autoplay) + putBoolean(IS_LIVE_PHOTO, isLivePhoto) + } + + previewMediaFragment.arguments = bundle + + return previewMediaFragment + } + + /** + * Helper method to test if an [OCFile] can be passed to a [PreviewMediaFragment] to be previewed. + * + * @param file File to test if can be previewed. + * @return 'True' if the file can be handled by the fragment. + */ + @JvmStatic + fun canBePreviewed(file: OCFile?): Boolean { + return file != null && (MimeTypeUtil.isAudio(file) || MimeTypeUtil.isVideo(file)) + } + } +} diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt index 3e0520e4c5f4..9906cedffc41 100644 --- a/scripts/analysis/lint-results.txt +++ b/scripts/analysis/lint-results.txt @@ -1,2 +1,2 @@ DO NOT TOUCH; GENERATED BY DRONE - Lint Report: 3 errors and 69 warnings + Lint Report: 3 errors and 68 warnings