diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java index 3ef7090c30..70eb0ba3ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java @@ -34,6 +34,7 @@ import com.bumptech.glide.load.resource.bitmap.CenterCrop; import com.bumptech.glide.request.target.SimpleTarget; import com.bumptech.glide.request.transition.Transition; +import dagger.hilt.android.AndroidEntryPoint; import network.loki.messenger.R; import org.session.libsignal.utilities.Log; import com.bumptech.glide.Glide; @@ -43,6 +44,7 @@ import org.session.libsession.utilities.TextSecurePreferences; import java.io.ByteArrayOutputStream; +@AndroidEntryPoint public class Camera1Fragment extends Fragment implements TextureView.SurfaceTextureListener, Camera1Controller.EventListener { @@ -80,7 +82,7 @@ public class Camera1Fragment extends Fragment implements TextureView.SurfaceText controller = (Controller) getActivity(); camera = new Camera1Controller(TextSecurePreferences.getDirectCaptureCameraId(getContext()), displaySize.x, displaySize.y, this); orderEnforcer = new OrderEnforcer<>(Stage.SURFACE_AVAILABLE, Stage.CAMERA_PROPERTIES_AVAILABLE); - viewModel = new ViewModelProvider(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class); } @Nullable diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java index 82d6b93085..c2eb2e2b24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java @@ -28,11 +28,13 @@ import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; +import dagger.hilt.android.AndroidEntryPoint; import network.loki.messenger.R; /** * Allows the user to select a media folder to explore. */ +@AndroidEntryPoint public class MediaPickerFolderFragment extends Fragment implements MediaPickerFolderAdapter.EventListener { private static final String KEY_RECIPIENT_NAME = "recipient_name"; @@ -60,7 +62,7 @@ public class MediaPickerFolderFragment extends Fragment implements MediaPickerFo public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); recipientName = getArguments().getString(KEY_RECIPIENT_NAME); - viewModel = new ViewModelProvider(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java index b1c104e32e..c9532e6d05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -27,11 +27,13 @@ import org.session.libsession.utilities.Util; import java.util.ArrayList; import java.util.List; +import dagger.hilt.android.AndroidEntryPoint; import network.loki.messenger.R; /** * Allows the user to select a set of media items from a specified folder. */ +@AndroidEntryPoint public class MediaPickerItemFragment extends Fragment implements MediaPickerItemAdapter.EventListener { private static final String KEY_BUCKET_ID = "bucket_id"; @@ -66,7 +68,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem bucketId = getArguments().getString(KEY_BUCKET_ID); folderTitle = getArguments().getString(KEY_FOLDER_TITLE); maxSelection = getArguments().getInt(KEY_MAX_SELECTION); - viewModel = new ViewModelProvider(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); + viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java deleted file mode 100644 index 3a1e9da5c7..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ /dev/null @@ -1,446 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; - -import android.Manifest; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.view.View; -import android.view.animation.AccelerateDecelerateInterpolator; -import android.view.animation.AccelerateInterpolator; -import android.view.animation.Animation; -import android.view.animation.DecelerateInterpolator; -import android.view.animation.OvershootInterpolator; -import android.view.animation.ScaleAnimation; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.lifecycle.ViewModelProvider; -import com.squareup.phrase.Phrase; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import network.loki.messenger.R; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.MediaTypes; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.concurrent.SimpleTask; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.ScreenLockActionBarActivity; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; -import org.thoughtcrime.securesms.util.FilenameUtils; - -/** - * Encompasses the entire flow of sending media, starting from the selection process to the actual - * captioning and editing of the content. - * - * This activity is intended to be launched via {@link #startActivityForResult(Intent, int)}. - * It will return the {@link Media} that the user decided to send. - */ -public class MediaSendActivity extends ScreenLockActionBarActivity implements MediaPickerFolderFragment.Controller, - MediaPickerItemFragment.Controller, - MediaSendFragment.Controller, - ImageEditorFragment.Controller, - Camera1Fragment.Controller -{ - private static final String TAG = MediaSendActivity.class.getSimpleName(); - - public static final String EXTRA_MEDIA = "media"; - public static final String EXTRA_MESSAGE = "message"; - - private static final String KEY_ADDRESS = "address"; - private static final String KEY_BODY = "body"; - private static final String KEY_MEDIA = "media"; - private static final String KEY_IS_CAMERA = "is_camera"; - - private static final String TAG_FOLDER_PICKER = "folder_picker"; - private static final String TAG_ITEM_PICKER = "item_picker"; - private static final String TAG_SEND = "send"; - private static final String TAG_CAMERA = "camera"; - - private Recipient recipient; - private MediaSendViewModel viewModel; - - private View countButton; - private TextView countButtonText; - private View cameraButton; - - /** - * Get an intent to launch the media send flow starting with the picker. - */ - public static Intent buildGalleryIntent(@NonNull Context context, @NonNull Recipient recipient, @NonNull String body) { - Intent intent = new Intent(context, MediaSendActivity.class); - intent.putExtra(KEY_ADDRESS, recipient.getAddress().serialize()); - intent.putExtra(KEY_BODY, body); - return intent; - } - - /** - * Get an intent to launch the media send flow starting with the camera. - */ - public static Intent buildCameraIntent(@NonNull Context context, @NonNull Recipient recipient) { - Intent intent = buildGalleryIntent(context, recipient, ""); - intent.putExtra(KEY_IS_CAMERA, true); - return intent; - } - - /** - * Get an intent to launch the media send flow with a specific list of media. Will jump right to - * the editor screen. - */ - public static Intent buildEditorIntent(@NonNull Context context, - @NonNull List media, - @NonNull Recipient recipient, - @NonNull String body) - { - Intent intent = buildGalleryIntent(context, recipient, body); - intent.putParcelableArrayListExtra(KEY_MEDIA, new ArrayList<>(media)); - return intent; - } - - @Override - protected void onCreate(Bundle savedInstanceState, boolean ready) { - super.onCreate(savedInstanceState, ready); - - setContentView(R.layout.mediasend_activity); - setResult(RESULT_CANCELED); - - if (savedInstanceState != null) { return; } - - countButton = findViewById(R.id.mediasend_count_button); - countButtonText = findViewById(R.id.mediasend_count_button_text); - cameraButton = findViewById(R.id.mediasend_camera_button); - - viewModel = new ViewModelProvider(this, new MediaSendViewModel.Factory(getApplication(), new MediaRepository())).get(MediaSendViewModel.class); - recipient = Recipient.from(this, Address.fromSerialized(getIntent().getStringExtra(KEY_ADDRESS)), true); - - viewModel.onBodyChanged(getIntent().getStringExtra(KEY_BODY)); - - List media = getIntent().getParcelableArrayListExtra(KEY_MEDIA); - boolean isCamera = getIntent().getBooleanExtra(KEY_IS_CAMERA, false); - - if (isCamera) { - Fragment fragment = Camera1Fragment.newInstance(); - getSupportFragmentManager().beginTransaction() - .replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA) - .commit(); - - } else if (!Util.isEmpty(media)) { - viewModel.onSelectedMediaChanged(this, media); - - Fragment fragment = MediaSendFragment.newInstance(recipient); - getSupportFragmentManager().beginTransaction() - .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) - .commit(); - } else { - MediaPickerFolderFragment fragment = MediaPickerFolderFragment.newInstance(recipient); - getSupportFragmentManager().beginTransaction() - .replace(R.id.mediasend_fragment_container, fragment, TAG_FOLDER_PICKER) - .commit(); - } - - initializeCountButtonObserver(); - initializeCameraButtonObserver(); - initializeErrorObserver(); - - cameraButton.setOnClickListener(v -> { - int maxSelection = viewModel.getMaxSelection(); - - if (viewModel.getSelectedMedia().getValue() != null && viewModel.getSelectedMedia().getValue().size() >= maxSelection) { - Toast.makeText(this, getString(R.string.attachmentsErrorNumber), Toast.LENGTH_SHORT).show(); - } else { - navigateToCamera(); - } - }); - } - - @Override - public void onBackPressed() { - MediaSendFragment sendFragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND); - if (sendFragment == null || !sendFragment.isVisible() || !sendFragment.handleBackPress()) { - super.onBackPressed(); - - if (getIntent().getBooleanExtra(KEY_IS_CAMERA, false) && getSupportFragmentManager().getBackStackEntryCount() == 0) { - viewModel.onImageCaptureUndo(this); - } - } - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); - } - - @Override - public void onFolderSelected(@NonNull MediaFolder folder) { - if (viewModel == null) { return; } - - viewModel.onFolderSelected(folder.getBucketId()); - - MediaPickerItemFragment fragment = MediaPickerItemFragment.newInstance(folder.getBucketId(), folder.getTitle(), viewModel.getMaxSelection()); - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) - .replace(R.id.mediasend_fragment_container, fragment, TAG_ITEM_PICKER) - .addToBackStack(null) - .commit(); - } - - @Override - public void onMediaSelected(@NonNull Media media) { - viewModel.onSingleMediaSelected(this, media); - navigateToMediaSend(recipient); - } - - @Override - public void onAddMediaClicked(@NonNull String bucketId) { - MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(recipient); - MediaPickerItemFragment itemFragment = MediaPickerItemFragment.newInstance(bucketId, "", viewModel.getMaxSelection()); - - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.stationary, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) - .replace(R.id.mediasend_fragment_container, folderFragment, TAG_FOLDER_PICKER) - .addToBackStack(null) - .commit(); - - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_from_right, R.anim.stationary, R.anim.slide_from_left, R.anim.slide_to_right) - .replace(R.id.mediasend_fragment_container, itemFragment, TAG_ITEM_PICKER) - .addToBackStack(null) - .commit(); - } - - @Override - public void onSendClicked(@NonNull List media, @NonNull String message) { - viewModel.onSendClicked(); - - ArrayList mediaList = new ArrayList<>(media); - Intent intent = new Intent(); - - intent.putParcelableArrayListExtra(EXTRA_MEDIA, mediaList); - intent.putExtra(EXTRA_MESSAGE, message); - setResult(RESULT_OK, intent); - finish(); - - overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom); - } - - @Override - public void onNoMediaAvailable() { - setResult(RESULT_CANCELED); - finish(); - } - - @Override - public void onTouchEventsNeeded(boolean needed) { - MediaSendFragment fragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND); - if (fragment != null) { - fragment.onTouchEventsNeeded(needed); - } - } - - @Override - public void onCameraError() { - Toast.makeText(this, R.string.cameraErrorUnavailable, Toast.LENGTH_SHORT).show(); - setResult(RESULT_CANCELED, new Intent()); - finish(); - } - - @Override - public void onImageCaptured(@NonNull byte[] data, int width, int height) { - Log.i(TAG, "Camera image captured."); - SimpleTask.run(getLifecycle(), () -> { - try { - Uri uri = BlobProvider.getInstance() - .forData(data) - .withMimeType(MediaTypes.IMAGE_JPEG) - .createForSingleSessionOnDisk(this, e -> Log.w(TAG, "Failed to write to disk.", e)); - - return new Media(uri, - FilenameUtils.constructPhotoFilename(this), - MediaTypes.IMAGE_JPEG, - System.currentTimeMillis(), - width, - height, - data.length, - Optional.of(Media.ALL_MEDIA_BUCKET_ID), - Optional.absent() - ); - } catch (IOException e) { - return null; - } - }, media -> { - if (media == null) { - onNoMediaAvailable(); - return; - } - - Log.i(TAG, "Camera capture stored: " + media.getUri().toString()); - - viewModel.onImageCaptured(media); - navigateToMediaSend(recipient); - }); - } - - @Override - public int getDisplayRotation() { - return getWindowManager().getDefaultDisplay().getRotation(); - } - - private void initializeCountButtonObserver() { - viewModel.getCountButtonState().observe(this, buttonState -> { - if (buttonState == null) return; - - countButtonText.setText(String.valueOf(buttonState.getCount())); - countButton.setEnabled(buttonState.isVisible()); - animateButtonVisibility(countButton, countButton.getVisibility(), buttonState.isVisible() ? View.VISIBLE : View.GONE); - - if (buttonState.getCount() > 0) { - countButton.setOnClickListener(v -> navigateToMediaSend(recipient)); - if (buttonState.isVisible()) { - animateButtonTextChange(countButton); - } - } else { - countButton.setOnClickListener(null); - } - }); - } - - private void initializeCameraButtonObserver() { - viewModel.getCameraButtonVisibility().observe(this, visible -> { - if (visible == null) return; - animateButtonVisibility(cameraButton, cameraButton.getVisibility(), visible ? View.VISIBLE : View.GONE); - }); - } - - private void initializeErrorObserver() { - viewModel.getError().observe(this, error -> { - if (error == null) return; - - switch (error) { - case ITEM_TOO_LARGE: - Toast.makeText(this, R.string.attachmentsErrorSize, Toast.LENGTH_LONG).show(); - break; - case TOO_MANY_ITEMS: - // In modern session we'll say you can't sent more than 32 items, but if we ever want - // the exact count of how many items the user attempted to send it's: viewModel.getMaxSelection() - Toast.makeText(this, getString(R.string.attachmentsErrorNumber), Toast.LENGTH_SHORT).show(); - break; - } - }); - } - - private void navigateToMediaSend(@NonNull Recipient recipient) { - MediaSendFragment fragment = MediaSendFragment.newInstance(recipient); - String backstackTag = null; - - if (getSupportFragmentManager().findFragmentByTag(TAG_SEND) != null) { - getSupportFragmentManager().popBackStack(TAG_SEND, FragmentManager.POP_BACK_STACK_INCLUSIVE); - backstackTag = TAG_SEND; - } - - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) - .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) - .addToBackStack(backstackTag) - .commit(); - } - - private void navigateToCamera() { - - Context c = getApplicationContext(); - String permanentDenialTxt = Phrase.from(c, R.string.permissionsCameraDenied) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString(); - String requireCameraPermissionsTxt = Phrase.from(c, R.string.cameraGrantAccessDescription) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString(); - - Permissions.with(this) - .request(Manifest.permission.CAMERA) - .withPermanentDenialDialog(permanentDenialTxt) - .onAllGranted(() -> { - Camera1Fragment fragment = getOrCreateCameraFragment(); - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) - .replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA) - .addToBackStack(null) - .commit(); - }) - .onAnyDenied(() -> Toast.makeText(MediaSendActivity.this, requireCameraPermissionsTxt, Toast.LENGTH_LONG).show()) - .execute(); - } - - private Camera1Fragment getOrCreateCameraFragment() { - Camera1Fragment fragment = (Camera1Fragment) getSupportFragmentManager().findFragmentByTag(TAG_CAMERA); - - return fragment != null ? fragment - : Camera1Fragment.newInstance(); - } - - private void animateButtonVisibility(@NonNull View button, int oldVisibility, int newVisibility) { - if (oldVisibility == newVisibility) return; - - if (button.getAnimation() != null) { - button.clearAnimation(); - button.setVisibility(newVisibility); - } else if (newVisibility == View.VISIBLE) { - button.setVisibility(View.VISIBLE); - - Animation animation = new ScaleAnimation(0, 1, 0, 1, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); - animation.setDuration(250); - animation.setInterpolator(new OvershootInterpolator()); - button.startAnimation(animation); - } else { - Animation animation = new ScaleAnimation(1, 0, 1, 0, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); - animation.setDuration(150); - animation.setInterpolator(new AccelerateDecelerateInterpolator()); - animation.setAnimationListener(new SimpleAnimationListener() { - @Override - public void onAnimationEnd(Animation animation) { - button.clearAnimation(); - button.setVisibility(View.GONE); - } - }); - - button.startAnimation(animation); - } - } - - private void animateButtonTextChange(@NonNull View button) { - if (button.getAnimation() != null) { - button.clearAnimation(); - } - - Animation grow = new ScaleAnimation(1f, 1.3f, 1f, 1.3f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); - grow.setDuration(125); - grow.setInterpolator(new AccelerateInterpolator()); - grow.setAnimationListener(new SimpleAnimationListener() { - @Override - public void onAnimationEnd(Animation animation) { - Animation shrink = new ScaleAnimation(1.3f, 1f, 1.3f, 1f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); - shrink.setDuration(125); - shrink.setInterpolator(new DecelerateInterpolator()); - button.startAnimation(shrink); - } - }); - - button.startAnimation(grow); - } - - @Override - public void onRequestFullScreen(boolean fullScreen) { - MediaSendFragment sendFragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND); - if (sendFragment != null && sendFragment.isVisible()) { - sendFragment.onRequestFullScreen(fullScreen); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt new file mode 100644 index 0000000000..215bff717e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -0,0 +1,548 @@ +package org.thoughtcrime.securesms.mediasend + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.AccelerateInterpolator +import android.view.animation.Animation +import android.view.animation.DecelerateInterpolator +import android.view.animation.OvershootInterpolator +import android.view.animation.ScaleAnimation +import android.widget.TextView +import android.widget.Toast +import androidx.activity.viewModels +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import com.squareup.phrase.Phrase +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.MediaTypes +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.Util.isEmpty +import org.session.libsession.utilities.concurrent.SimpleTask +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.ScreenLockActionBarActivity +import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.CountButtonState +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment +import org.thoughtcrime.securesms.util.FilenameUtils.constructPhotoFilename +import java.io.IOException + +/** + * Encompasses the entire flow of sending media, starting from the selection process to the actual + * captioning and editing of the content. + * + * This activity is intended to be launched via [.startActivityForResult]. + * It will return the [Media] that the user decided to send. + */ +@AndroidEntryPoint +class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderFragment.Controller, + MediaPickerItemFragment.Controller, MediaSendFragment.Controller, + ImageEditorFragment.Controller, + Camera1Fragment.Controller { + private var recipient: Recipient? = null + private val viewModel: MediaSendViewModel by viewModels() + + private var countButton: View? = null + private var countButtonText: TextView? = null + private var cameraButton: View? = null + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + + setContentView(R.layout.mediasend_activity) + setResult(RESULT_CANCELED) + + if (savedInstanceState != null) { + return + } + + countButton = findViewById(R.id.mediasend_count_button) + countButtonText = findViewById(R.id.mediasend_count_button_text) + cameraButton = findViewById(R.id.mediasend_camera_button) + + recipient = Recipient.from( + this, fromSerialized( + intent.getStringExtra(KEY_ADDRESS)!! + ), true + ) + + viewModel.onBodyChanged(intent.getStringExtra(KEY_BODY)!!) + + val media: List? = intent.getParcelableArrayListExtra(KEY_MEDIA) + val isCamera = intent.getBooleanExtra(KEY_IS_CAMERA, false) + + if (isCamera) { + val fragment: Fragment = Camera1Fragment.newInstance() + supportFragmentManager.beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_CAMERA) + .commit() + } else if (!isEmpty(media)) { + viewModel.onSelectedMediaChanged(this, media!!) + + val fragment: Fragment = MediaSendFragment.newInstance(recipient!!) + supportFragmentManager.beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) + .commit() + } else { + val fragment = MediaPickerFolderFragment.newInstance( + recipient!! + ) + supportFragmentManager.beginTransaction() + .replace(R.id.mediasend_fragment_container, fragment, TAG_FOLDER_PICKER) + .commit() + } + + initializeCountButtonObserver() + initializeCameraButtonObserver() + initializeErrorObserver() + + cameraButton?.setOnClickListener { v: View? -> + val maxSelection = MediaSendViewModel.MAX_SELECTED_FILES + if (viewModel.getSelectedMedia().value != null && viewModel.getSelectedMedia().value!!.size >= maxSelection) { + Toast.makeText(this, getString(R.string.attachmentsErrorNumber), Toast.LENGTH_SHORT) + .show() + } else { + navigateToCamera() + } + } + } + + override fun onBackPressed() { + val sendFragment = supportFragmentManager.findFragmentByTag(TAG_SEND) as MediaSendFragment? + if (sendFragment == null || !sendFragment.isVisible || !sendFragment.handleBackPress()) { + super.onBackPressed() + + if (intent.getBooleanExtra( + KEY_IS_CAMERA, + false + ) && supportFragmentManager.backStackEntryCount == 0 + ) { + viewModel.onImageCaptureUndo(this) + } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } + + override fun onFolderSelected(folder: MediaFolder) { + viewModel.onFolderSelected(folder.bucketId) + + val fragment = MediaPickerItemFragment.newInstance( + folder.bucketId, + folder.title, + MediaSendViewModel.MAX_SELECTED_FILES + ) + supportFragmentManager.beginTransaction() + .setCustomAnimations( + R.anim.slide_from_right, + R.anim.slide_to_left, + R.anim.slide_from_left, + R.anim.slide_to_right + ) + .replace(R.id.mediasend_fragment_container, fragment, TAG_ITEM_PICKER) + .addToBackStack(null) + .commit() + } + + override fun onMediaSelected(media: Media) { + viewModel.onSingleMediaSelected(this, media) + navigateToMediaSend(recipient!!) + } + + override fun onAddMediaClicked(bucketId: String) { + val folderFragment = MediaPickerFolderFragment.newInstance( + recipient!! + ) + val itemFragment = + MediaPickerItemFragment.newInstance(bucketId, "", MediaSendViewModel.MAX_SELECTED_FILES) + + supportFragmentManager.beginTransaction() + .setCustomAnimations( + R.anim.stationary, + R.anim.slide_to_left, + R.anim.slide_from_left, + R.anim.slide_to_right + ) + .replace(R.id.mediasend_fragment_container, folderFragment, TAG_FOLDER_PICKER) + .addToBackStack(null) + .commit() + + supportFragmentManager.beginTransaction() + .setCustomAnimations( + R.anim.slide_from_right, + R.anim.stationary, + R.anim.slide_from_left, + R.anim.slide_to_right + ) + .replace(R.id.mediasend_fragment_container, itemFragment, TAG_ITEM_PICKER) + .addToBackStack(null) + .commit() + } + + override fun onSendClicked(media: List, message: String) { + viewModel.onSendClicked() + + val mediaList = ArrayList(media) + val intent = Intent() + + intent.putParcelableArrayListExtra(EXTRA_MEDIA, mediaList) + intent.putExtra(EXTRA_MESSAGE, message) + setResult(RESULT_OK, intent) + finish() + + overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom) + } + + override fun onNoMediaAvailable() { + setResult(RESULT_CANCELED) + finish() + } + + override fun onTouchEventsNeeded(needed: Boolean) { + val fragment = supportFragmentManager.findFragmentByTag(TAG_SEND) as MediaSendFragment? + fragment?.onTouchEventsNeeded(needed) + } + + override fun onCameraError() { + Toast.makeText(this, R.string.cameraErrorUnavailable, Toast.LENGTH_SHORT).show() + setResult(RESULT_CANCELED, Intent()) + finish() + } + + override fun onImageCaptured(data: ByteArray, width: Int, height: Int) { + Log.i(TAG, "Camera image captured.") + SimpleTask.run(lifecycle, { + try { + val uri = BlobProvider.getInstance() + .forData(data) + .withMimeType(MediaTypes.IMAGE_JPEG) + .createForSingleSessionOnDisk( + this + ) { e: IOException? -> + Log.w( + TAG, + "Failed to write to disk.", + e + ) + } + + return@run Media( + uri, + constructPhotoFilename(this), + MediaTypes.IMAGE_JPEG, + System.currentTimeMillis(), + width, + height, + data.size.toLong(), + Optional.of(Media.ALL_MEDIA_BUCKET_ID), + Optional.absent() + ) + } catch (e: IOException) { + return@run null + } + }, { media: Media? -> + if (media == null) { + onNoMediaAvailable() + return@run + } + Log.i(TAG, "Camera capture stored: " + media.uri.toString()) + + viewModel.onImageCaptured(media) + navigateToMediaSend(recipient!!) + }) + } + + override fun getDisplayRotation(): Int { + return windowManager.defaultDisplay.rotation + } + + private fun initializeCountButtonObserver() { + viewModel.getCountButtonState().observe( + this + ) { buttonState: CountButtonState? -> + if (buttonState == null) return@observe + countButtonText!!.text = buttonState.count.toString() + countButton!!.isEnabled = buttonState.isVisible + animateButtonVisibility( + countButton!!, + countButton!!.visibility, + if (buttonState.isVisible) View.VISIBLE else View.GONE + ) + if (buttonState.count > 0) { + countButton!!.setOnClickListener { v: View? -> + navigateToMediaSend( + recipient!! + ) + } + if (buttonState.isVisible) { + animateButtonTextChange(countButton!!) + } + } else { + countButton!!.setOnClickListener(null) + } + } + } + + private fun initializeCameraButtonObserver() { + viewModel.getCameraButtonVisibility().observe( + this + ) { visible: Boolean? -> + if (visible == null) return@observe + animateButtonVisibility( + cameraButton!!, + cameraButton!!.visibility, + if (visible) View.VISIBLE else View.GONE + ) + } + } + + private fun initializeErrorObserver() { + viewModel.getError().observe( + this + ) { error: MediaSendViewModel.Error? -> + if (error == null) return@observe + when (error) { + MediaSendViewModel.Error.ITEM_TOO_LARGE -> Toast.makeText( + this, + R.string.attachmentsErrorSize, + Toast.LENGTH_LONG + ).show() + + MediaSendViewModel.Error.TOO_MANY_ITEMS -> // In modern session we'll say you can't sent more than 32 items, but if we ever want + // the exact count of how many items the user attempted to send it's: viewModel.getMaxSelection() + Toast.makeText( + this, + getString(R.string.attachmentsErrorNumber), + Toast.LENGTH_SHORT + ).show() + } + } + } + + private fun navigateToMediaSend(recipient: Recipient) { + val fragment = MediaSendFragment.newInstance(recipient) + var backstackTag: String? = null + + if (supportFragmentManager.findFragmentByTag(TAG_SEND) != null) { + supportFragmentManager.popBackStack(TAG_SEND, FragmentManager.POP_BACK_STACK_INCLUSIVE) + backstackTag = TAG_SEND + } + + supportFragmentManager.beginTransaction() + .setCustomAnimations( + R.anim.slide_from_right, + R.anim.slide_to_left, + R.anim.slide_from_left, + R.anim.slide_to_right + ) + .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) + .addToBackStack(backstackTag) + .commit() + } + + private fun navigateToCamera() { + val c = applicationContext + val permanentDenialTxt = Phrase.from(c, R.string.permissionsCameraDenied) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString() + val requireCameraPermissionsTxt = Phrase.from(c, R.string.cameraGrantAccessDescription) + .put(APP_NAME_KEY, c.getString(R.string.app_name)) + .format().toString() + + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .withPermanentDenialDialog(permanentDenialTxt) + .onAllGranted { + val fragment = orCreateCameraFragment + supportFragmentManager.beginTransaction() + .setCustomAnimations( + R.anim.slide_from_right, + R.anim.slide_to_left, + R.anim.slide_from_left, + R.anim.slide_to_right + ) + .replace( + R.id.mediasend_fragment_container, + fragment, + TAG_CAMERA + ) + .addToBackStack(null) + .commit() + } + .onAnyDenied { + Toast.makeText( + this@MediaSendActivity, + requireCameraPermissionsTxt, + Toast.LENGTH_LONG + ).show() + } + .execute() + } + + private val orCreateCameraFragment: Camera1Fragment + get() { + val fragment = + supportFragmentManager.findFragmentByTag(TAG_CAMERA) as Camera1Fragment? + + return fragment ?: Camera1Fragment.newInstance() + } + + private fun animateButtonVisibility(button: View, oldVisibility: Int, newVisibility: Int) { + if (oldVisibility == newVisibility) return + + if (button.animation != null) { + button.clearAnimation() + button.visibility = newVisibility + } else if (newVisibility == View.VISIBLE) { + button.visibility = View.VISIBLE + + val animation: Animation = ScaleAnimation( + 0f, + 1f, + 0f, + 1f, + Animation.RELATIVE_TO_SELF, + 0.5f, + Animation.RELATIVE_TO_SELF, + 0.5f + ) + animation.duration = 250 + animation.interpolator = OvershootInterpolator() + button.startAnimation(animation) + } else { + val animation: Animation = ScaleAnimation( + 1f, + 0f, + 1f, + 0f, + Animation.RELATIVE_TO_SELF, + 0.5f, + Animation.RELATIVE_TO_SELF, + 0.5f + ) + animation.duration = 150 + animation.interpolator = AccelerateDecelerateInterpolator() + animation.setAnimationListener(object : SimpleAnimationListener() { + override fun onAnimationEnd(animation: Animation) { + button.clearAnimation() + button.visibility = View.GONE + } + }) + + button.startAnimation(animation) + } + } + + private fun animateButtonTextChange(button: View) { + if (button.animation != null) { + button.clearAnimation() + } + + val grow: Animation = ScaleAnimation( + 1f, + 1.3f, + 1f, + 1.3f, + Animation.RELATIVE_TO_SELF, + 0.5f, + Animation.RELATIVE_TO_SELF, + 0.5f + ) + grow.duration = 125 + grow.interpolator = AccelerateInterpolator() + grow.setAnimationListener(object : SimpleAnimationListener() { + override fun onAnimationEnd(animation: Animation) { + val shrink: Animation = ScaleAnimation( + 1.3f, + 1f, + 1.3f, + 1f, + Animation.RELATIVE_TO_SELF, + 0.5f, + Animation.RELATIVE_TO_SELF, + 0.5f + ) + shrink.duration = 125 + shrink.interpolator = DecelerateInterpolator() + button.startAnimation(shrink) + } + }) + + button.startAnimation(grow) + } + + override fun onRequestFullScreen(fullScreen: Boolean) { + val sendFragment = supportFragmentManager.findFragmentByTag(TAG_SEND) as MediaSendFragment? + if (sendFragment != null && sendFragment.isVisible) { + sendFragment.onRequestFullScreen(fullScreen) + } + } + + companion object { + private val TAG: String = MediaSendActivity::class.java.simpleName + + const val EXTRA_MEDIA: String = "media" + const val EXTRA_MESSAGE: String = "message" + + private const val KEY_ADDRESS = "address" + private const val KEY_BODY = "body" + private const val KEY_MEDIA = "media" + private const val KEY_IS_CAMERA = "is_camera" + + private const val TAG_FOLDER_PICKER = "folder_picker" + private const val TAG_ITEM_PICKER = "item_picker" + private const val TAG_SEND = "send" + private const val TAG_CAMERA = "camera" + + /** + * Get an intent to launch the media send flow starting with the picker. + */ + @JvmStatic + fun buildGalleryIntent(context: Context, recipient: Recipient, body: String): Intent { + val intent = Intent(context, MediaSendActivity::class.java) + intent.putExtra(KEY_ADDRESS, recipient.address.serialize()) + intent.putExtra(KEY_BODY, body) + return intent + } + + /** + * Get an intent to launch the media send flow starting with the camera. + */ + @JvmStatic + fun buildCameraIntent(context: Context, recipient: Recipient): Intent { + val intent = buildGalleryIntent(context, recipient, "") + intent.putExtra(KEY_IS_CAMERA, true) + return intent + } + + /** + * Get an intent to launch the media send flow with a specific list of media. Will jump right to + * the editor screen. + */ + fun buildEditorIntent( + context: Context, + media: List, + recipient: Recipient, + body: String + ): Intent { + val intent = buildGalleryIntent(context, recipient, body) + intent.putParcelableArrayListExtra(KEY_MEDIA, ArrayList(media)) + return intent + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java index 633555c672..610f4ff140 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java @@ -36,6 +36,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.ExecutionException; + +import dagger.hilt.android.AndroidEntryPoint; import network.loki.messenger.R; import org.session.libsession.utilities.MediaTypes; import org.session.libsession.utilities.TextSecurePreferences; @@ -59,6 +61,7 @@ import org.thoughtcrime.securesms.util.Stopwatch; /** * Allows the user to edit and caption a set of media items before choosing to send them. */ +@AndroidEntryPoint public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGlobalLayoutListener, MediaRailAdapter.RailItemListener, InputAwareLayout.OnKeyboardShownListener, @@ -108,6 +111,7 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl } controller = (Controller) requireActivity(); + viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class); } @Override @@ -264,8 +268,6 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl } private void initViewModel() { - viewModel = new ViewModelProvider(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); - viewModel.getSelectedMedia().observe(this, media -> { if (Util.isEmpty(media)) { controller.onNoMediaAvailable(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java deleted file mode 100644 index 0220dd9027..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java +++ /dev/null @@ -1,360 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.app.Application; -import android.content.Context; -import android.net.Uri; -import androidx.annotation.NonNull; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; -import com.annimon.stream.Stream; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; - -import org.session.libsession.utilities.FileUtils; -import org.session.libsession.utilities.Util; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.mms.MediaConstraints; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.util.SingleLiveEvent; - -/** - * Manages the observable datasets available in {@link MediaSendActivity}. - */ -class MediaSendViewModel extends ViewModel { - - private static final String TAG = MediaSendViewModel.class.getSimpleName(); - - private static final int MAX_SELECTION = 32; - - private final Application application; - private final MediaRepository repository; - private final MutableLiveData> selectedMedia; - private final MutableLiveData> bucketMedia; - private final MutableLiveData position; - private final MutableLiveData bucketId; - private final MutableLiveData> folders; - private final MutableLiveData countButtonState; - private final MutableLiveData cameraButtonVisibility; - private final SingleLiveEvent error; - private final Map savedDrawState; - - private final MediaConstraints mediaConstraints = MediaConstraints.getPushMediaConstraints(); - - private CharSequence body; - private CountButtonState.Visibility countButtonVisibility; - private boolean sentMedia; - private Optional lastImageCapture; - - private MediaSendViewModel(@NonNull Application application, @NonNull MediaRepository repository) { - this.application = application; - this.repository = repository; - this.selectedMedia = new MutableLiveData<>(); - this.bucketMedia = new MutableLiveData<>(); - this.position = new MutableLiveData<>(); - this.bucketId = new MutableLiveData<>(); - this.folders = new MutableLiveData<>(); - this.countButtonState = new MutableLiveData<>(); - this.cameraButtonVisibility = new MutableLiveData<>(); - this.error = new SingleLiveEvent<>(); - this.savedDrawState = new HashMap<>(); - this.countButtonVisibility = CountButtonState.Visibility.FORCED_OFF; - this.lastImageCapture = Optional.absent(); - this.body = ""; - - position.setValue(-1); - countButtonState.setValue(new CountButtonState(0, countButtonVisibility)); - cameraButtonVisibility.setValue(false); - } - - void onSelectedMediaChanged(@NonNull Context context, @NonNull List newMedia) { - repository.getPopulatedMedia(context, newMedia, populatedMedia -> { - Util.runOnMain(() -> { - - List filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints); - - if (filteredMedia.size() != newMedia.size()) { - error.setValue(Error.ITEM_TOO_LARGE); - } else if (filteredMedia.size() > MAX_SELECTION) { - filteredMedia = filteredMedia.subList(0, MAX_SELECTION); - error.setValue(Error.TOO_MANY_ITEMS); - } - - if (filteredMedia.size() > 0) { - String computedId = Stream.of(filteredMedia) - .skip(1) - .reduce(filteredMedia.get(0).getBucketId().or(Media.ALL_MEDIA_BUCKET_ID), (id, m) -> { - if (Util.equals(id, m.getBucketId().or(Media.ALL_MEDIA_BUCKET_ID))) { - return id; - } else { - return Media.ALL_MEDIA_BUCKET_ID; - } - }); - bucketId.setValue(computedId); - } else { - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID); - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL; - } - - selectedMedia.setValue(filteredMedia); - countButtonState.setValue(new CountButtonState(filteredMedia.size(), countButtonVisibility)); - }); - }); - } - - void onSingleMediaSelected(@NonNull Context context, @NonNull Media media) { - repository.getPopulatedMedia(context, Collections.singletonList(media), populatedMedia -> { - Util.runOnMain(() -> { - List filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints); - - if (filteredMedia.isEmpty()) { - error.setValue(Error.ITEM_TOO_LARGE); - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID); - } else { - bucketId.setValue(filteredMedia.get(0).getBucketId().or(Media.ALL_MEDIA_BUCKET_ID)); - } - - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF; - - selectedMedia.setValue(filteredMedia); - countButtonState.setValue(new CountButtonState(filteredMedia.size(), countButtonVisibility)); - }); - }); - } - - void onMultiSelectStarted() { - countButtonVisibility = CountButtonState.Visibility.FORCED_ON; - countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility)); - } - - void onImageEditorStarted() { - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF; - countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility)); - cameraButtonVisibility.setValue(false); - } - - void onCameraStarted() { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL; - countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility)); - cameraButtonVisibility.setValue(false); - } - - void onItemPickerStarted() { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL; - countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility)); - cameraButtonVisibility.setValue(true); - } - - void onFolderPickerStarted() { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL; - countButtonState.setValue(new CountButtonState(getSelectedMediaOrDefault().size(), countButtonVisibility)); - cameraButtonVisibility.setValue(true); - } - - void onBodyChanged(@NonNull CharSequence body) { - this.body = body; - } - - void onFolderSelected(@NonNull String bucketId) { - this.bucketId.setValue(bucketId); - bucketMedia.setValue(Collections.emptyList()); - } - - void onPageChanged(int position) { - if (position < 0 || position >= getSelectedMediaOrDefault().size()) { - Log.w(TAG, "Tried to move to an out-of-bounds item. Size: " + getSelectedMediaOrDefault().size() + ", position: " + position); - return; - } - - this.position.setValue(position); - } - - void onMediaItemRemoved(@NonNull Context context, int position) { - if (position < 0 || position >= getSelectedMediaOrDefault().size()) { - Log.w(TAG, "Tried to remove an out-of-bounds item. Size: " + getSelectedMediaOrDefault().size() + ", position: " + position); - return; - } - - Media removed = getSelectedMediaOrDefault().remove(position); - - if (removed != null && BlobProvider.isAuthority(removed.getUri())) { - BlobProvider.getInstance().delete(context, removed.getUri()); - } - - selectedMedia.setValue(selectedMedia.getValue()); - } - - void onImageCaptured(@NonNull Media media) { - List selected = selectedMedia.getValue(); - - if (selected == null) { - selected = new LinkedList<>(); - } - - if (selected.size() >= MAX_SELECTION) { - error.setValue(Error.TOO_MANY_ITEMS); - return; - } - - lastImageCapture = Optional.of(media); - - selected.add(media); - selectedMedia.setValue(selected); - position.setValue(selected.size() - 1); - bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID); - - if (selected.size() == 1) { - countButtonVisibility = CountButtonState.Visibility.FORCED_OFF; - } else { - countButtonVisibility = CountButtonState.Visibility.CONDITIONAL; - } - - countButtonState.setValue(new CountButtonState(selected.size(), countButtonVisibility)); - } - - void onImageCaptureUndo(@NonNull Context context) { - List selected = getSelectedMediaOrDefault(); - - if (lastImageCapture.isPresent() && selected.contains(lastImageCapture.get()) && selected.size() == 1) { - selected.remove(lastImageCapture.get()); - selectedMedia.setValue(selected); - countButtonState.setValue(new CountButtonState(selected.size(), countButtonVisibility)); - BlobProvider.getInstance().delete(context, lastImageCapture.get().getUri()); - } - } - - void saveDrawState(@NonNull Map state) { - savedDrawState.clear(); - savedDrawState.putAll(state); - } - - void onSendClicked() { - sentMedia = true; - } - - @NonNull Map getDrawState() { - return savedDrawState; - } - - @NonNull LiveData> getSelectedMedia() { - return selectedMedia; - } - - @NonNull LiveData> getMediaInBucket(@NonNull Context context, @NonNull String bucketId) { - repository.getMediaInBucket(context, bucketId, bucketMedia::postValue); - return bucketMedia; - } - - @NonNull LiveData> getFolders(@NonNull Context context) { - repository.getFolders(context, folders::postValue); - return folders; - } - - @NonNull LiveData getCountButtonState() { - return countButtonState; - } - - @NonNull LiveData getCameraButtonVisibility() { - return cameraButtonVisibility; - } - - @NonNull CharSequence getBody() { - return body; - } - - @NonNull LiveData getPosition() { - return position; - } - - @NonNull LiveData getBucketId() { - return bucketId; - } - - @NonNull LiveData getError() { - return error; - } - - int getMaxSelection() { - return MAX_SELECTION; - } - - private @NonNull List getSelectedMediaOrDefault() { - return selectedMedia.getValue() == null ? Collections.emptyList() - : selectedMedia.getValue(); - } - - private @NonNull List getFilteredMedia(@NonNull Context context, @NonNull List media, @NonNull MediaConstraints mediaConstraints) { - return Stream.of(media).filter(m -> MediaUtil.isGif(m.getMimeType()) || - MediaUtil.isImageType(m.getMimeType()) || - MediaUtil.isVideoType(m.getMimeType())) - .filter(m -> { - return (MediaUtil.isImageType(m.getMimeType()) && !MediaUtil.isGif(m.getMimeType())) || - (MediaUtil.isGif(m.getMimeType()) && m.getSize() < mediaConstraints.getGifMaxSize(context)) || - (MediaUtil.isVideoType(m.getMimeType()) && m.getSize() < mediaConstraints.getVideoMaxSize(context)); - }).toList(); - - } - - @Override - protected void onCleared() { - if (!sentMedia) { - Stream.of(getSelectedMediaOrDefault()) - .map(Media::getUri) - .filter(BlobProvider::isAuthority) - .forEach(uri -> BlobProvider.getInstance().delete(application.getApplicationContext(), uri)); - } - } - - enum Error { - ITEM_TOO_LARGE, TOO_MANY_ITEMS - } - - static class CountButtonState { - private final int count; - private final Visibility visibility; - - private CountButtonState(int count, @NonNull Visibility visibility) { - this.count = count; - this.visibility = visibility; - } - - int getCount() { - return count; - } - - boolean isVisible() { - switch (visibility) { - case FORCED_ON: return true; - case FORCED_OFF: return false; - case CONDITIONAL: return count > 0; - default: return false; - } - } - - enum Visibility { - CONDITIONAL, FORCED_ON, FORCED_OFF - } - } - - static class Factory extends ViewModelProvider.NewInstanceFactory { - - private final Application application; - private final MediaRepository repository; - - Factory(@NonNull Application application, @NonNull MediaRepository repository) { - this.application = application; - this.repository = repository; - } - - @Override - public @NonNull T create(@NonNull Class modelClass) { - return modelClass.cast(new MediaSendViewModel(application, repository)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt new file mode 100644 index 0000000000..c5150b2974 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.kt @@ -0,0 +1,367 @@ +package org.thoughtcrime.securesms.mediasend + +import android.app.Application +import android.content.Context +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.annimon.stream.Stream +import dagger.hilt.android.lifecycle.HiltViewModel +import org.session.libsession.utilities.Util.equals +import org.session.libsession.utilities.Util.runOnMain +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.mms.MediaConstraints +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.SingleLiveEvent +import java.util.LinkedList +import javax.inject.Inject + +/** + * Manages the observable datasets available in [MediaSendActivity]. + */ + +@HiltViewModel +internal class MediaSendViewModel @Inject constructor( + private val application: Application +) : ViewModel() { + private val selectedMedia: MutableLiveData?> + private val bucketMedia: MutableLiveData> + private val position: MutableLiveData + private val bucketId: MutableLiveData + private val folders: MutableLiveData> + private val countButtonState: MutableLiveData + private val cameraButtonVisibility: MutableLiveData + private val error: SingleLiveEvent + private val savedDrawState: MutableMap + + private val mediaConstraints: MediaConstraints = MediaConstraints.getPushMediaConstraints() + private val repository: MediaRepository = MediaRepository() + + var body: CharSequence + private set + private var countButtonVisibility: CountButtonState.Visibility + private var sentMedia: Boolean = false + private var lastImageCapture: Optional + + init { + this.selectedMedia = MutableLiveData() + this.bucketMedia = MutableLiveData() + this.position = MutableLiveData() + this.bucketId = MutableLiveData() + this.folders = MutableLiveData() + this.countButtonState = MutableLiveData() + this.cameraButtonVisibility = MutableLiveData() + this.error = SingleLiveEvent() + this.savedDrawState = HashMap() + this.countButtonVisibility = CountButtonState.Visibility.FORCED_OFF + this.lastImageCapture = Optional.absent() + this.body = "" + + position.value = -1 + countButtonState.value = CountButtonState(0, countButtonVisibility) + cameraButtonVisibility.value = false + } + + fun onSelectedMediaChanged(context: Context, newMedia: List) { + repository.getPopulatedMedia(context, newMedia, + { populatedMedia: List -> + runOnMain( + { + var filteredMedia: List = + getFilteredMedia(context, populatedMedia, mediaConstraints) + if (filteredMedia.size != newMedia.size) { + error.setValue(Error.ITEM_TOO_LARGE) + } else if (filteredMedia.size > MAX_SELECTED_FILES) { + filteredMedia = filteredMedia.subList(0, MAX_SELECTED_FILES) + error.setValue(Error.TOO_MANY_ITEMS) + } + + if (filteredMedia.size > 0) { + val computedId: String = Stream.of(filteredMedia) + .skip(1) + .reduce(filteredMedia.get(0).bucketId.or(Media.ALL_MEDIA_BUCKET_ID), + { id: String?, m: Media -> + if (equals(id, m.bucketId.or(Media.ALL_MEDIA_BUCKET_ID))) { + return@reduce id + } else { + return@reduce Media.ALL_MEDIA_BUCKET_ID + } + }) + bucketId.setValue(computedId) + } else { + bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) + countButtonVisibility = CountButtonState.Visibility.CONDITIONAL + } + + selectedMedia.setValue(filteredMedia) + countButtonState.setValue( + CountButtonState( + filteredMedia.size, + countButtonVisibility + ) + ) + }) + }) + } + + fun onSingleMediaSelected(context: Context, media: Media) { + repository.getPopulatedMedia(context, listOf(media), + { populatedMedia: List -> + runOnMain( + { + val filteredMedia: List = + getFilteredMedia(context, populatedMedia, mediaConstraints) + if (filteredMedia.isEmpty()) { + error.setValue(Error.ITEM_TOO_LARGE) + bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) + } else { + bucketId.setValue(filteredMedia.get(0).bucketId.or(Media.ALL_MEDIA_BUCKET_ID)) + } + + countButtonVisibility = CountButtonState.Visibility.FORCED_OFF + + selectedMedia.value = filteredMedia + countButtonState.setValue( + CountButtonState( + filteredMedia.size, + countButtonVisibility + ) + ) + }) + }) + } + + fun onMultiSelectStarted() { + countButtonVisibility = CountButtonState.Visibility.FORCED_ON + countButtonState.value = + CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) + } + + fun onImageEditorStarted() { + countButtonVisibility = CountButtonState.Visibility.FORCED_OFF + countButtonState.value = + CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) + cameraButtonVisibility.value = false + } + + fun onCameraStarted() { + countButtonVisibility = CountButtonState.Visibility.CONDITIONAL + countButtonState.value = + CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) + cameraButtonVisibility.value = false + } + + fun onItemPickerStarted() { + countButtonVisibility = CountButtonState.Visibility.CONDITIONAL + countButtonState.value = + CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) + cameraButtonVisibility.value = true + } + + fun onFolderPickerStarted() { + countButtonVisibility = CountButtonState.Visibility.CONDITIONAL + countButtonState.value = + CountButtonState(selectedMediaOrDefault.size, countButtonVisibility) + cameraButtonVisibility.value = true + } + + fun onBodyChanged(body: CharSequence) { + this.body = body + } + + fun onFolderSelected(bucketId: String) { + this.bucketId.value = bucketId + bucketMedia.value = + emptyList() + } + + fun onPageChanged(position: Int) { + if (position < 0 || position >= selectedMediaOrDefault.size) { + Log.w(TAG, + "Tried to move to an out-of-bounds item. Size: " + selectedMediaOrDefault.size + ", position: " + position + ) + return + } + + this.position.value = position + } + + fun onMediaItemRemoved(context: Context, position: Int) { + if (position < 0 || position >= selectedMediaOrDefault.size) { + Log.w( + TAG, + "Tried to remove an out-of-bounds item. Size: " + selectedMediaOrDefault.size + ", position: " + position + ) + return + } + + val updatedList = selectedMediaOrDefault.toMutableList() + val removed: Media = updatedList.removeAt(position) + + if (BlobProvider.isAuthority(removed.uri)) { + BlobProvider.getInstance().delete(context, removed.uri) + } + + selectedMedia.setValue(updatedList) + } + + fun onImageCaptured(media: Media) { + var selected: MutableList? = selectedMedia.value?.toMutableList() + + if (selected == null) { + selected = LinkedList() + } + + if (selected.size >= MAX_SELECTED_FILES) { + error.setValue(Error.TOO_MANY_ITEMS) + return + } + + lastImageCapture = Optional.of(media) + + selected.add(media) + selectedMedia.setValue(selected) + position.setValue(selected.size - 1) + bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID) + + if (selected.size == 1) { + countButtonVisibility = CountButtonState.Visibility.FORCED_OFF + } else { + countButtonVisibility = CountButtonState.Visibility.CONDITIONAL + } + + countButtonState.setValue(CountButtonState(selected.size, countButtonVisibility)) + } + + fun onImageCaptureUndo(context: Context) { + val selected: MutableList = selectedMediaOrDefault.toMutableList() + + if (lastImageCapture.isPresent && selected.contains(lastImageCapture.get()) && selected.size == 1) { + selected.remove(lastImageCapture.get()) + selectedMedia.value = selected + countButtonState.value = CountButtonState(selected.size, countButtonVisibility) + BlobProvider.getInstance().delete(context, lastImageCapture.get().uri) + } + } + + fun saveDrawState(state: Map) { + savedDrawState.clear() + savedDrawState.putAll(state) + } + + fun onSendClicked() { + sentMedia = true + } + + val drawState: Map + get() = savedDrawState + + fun getSelectedMedia(): LiveData?> { + return selectedMedia + } + + fun getMediaInBucket(context: Context, bucketId: String): LiveData> { + repository.getMediaInBucket(context, bucketId, + { value: List -> bucketMedia.postValue(value) }) + return bucketMedia + } + + fun getFolders(context: Context): LiveData> { + repository.getFolders(context, + { value: List -> folders.postValue(value) }) + return folders + } + + fun getCountButtonState(): LiveData { + return countButtonState + } + + fun getCameraButtonVisibility(): LiveData { + return cameraButtonVisibility + } + + fun getPosition(): LiveData { + return position + } + + fun getBucketId(): LiveData { + return bucketId + } + + fun getError(): LiveData { + return error + } + + private val selectedMediaOrDefault: List + get() = if (selectedMedia.value == null) emptyList() else + selectedMedia.value!! + + private fun getFilteredMedia( + context: Context, + media: List, + mediaConstraints: MediaConstraints + ): List { + return Stream.of(media).filter( + { m: Media -> + MediaUtil.isGif(m.mimeType) || + MediaUtil.isImageType(m.mimeType) || + MediaUtil.isVideoType(m.mimeType) + }) + .filter({ m: Media -> + (MediaUtil.isImageType(m.mimeType) && !MediaUtil.isGif(m.mimeType)) || + (MediaUtil.isGif(m.mimeType) && m.size < mediaConstraints.getGifMaxSize( + context + )) || + (MediaUtil.isVideoType(m.mimeType) && m.size < mediaConstraints.getVideoMaxSize( + context + )) + }).toList() + } + + override fun onCleared() { + if (!sentMedia) { + Stream.of(selectedMediaOrDefault) + .map({ obj: Media -> obj.uri }) + .filter({ uri: Uri? -> + BlobProvider.isAuthority( + uri!! + ) + }) + .forEach({ uri: Uri? -> + BlobProvider.getInstance().delete( + application.applicationContext, uri!! + ) + }) + } + } + + internal enum class Error { + ITEM_TOO_LARGE, TOO_MANY_ITEMS + } + + internal class CountButtonState(val count: Int, private val visibility: Visibility) { + val isVisible: Boolean + get() { + when (visibility) { + Visibility.FORCED_ON -> return true + Visibility.FORCED_OFF -> return false + Visibility.CONDITIONAL -> return count > 0 + else -> return false + } + } + + internal enum class Visibility { + CONDITIONAL, FORCED_ON, FORCED_OFF + } + } + + companion object { + private val TAG: String = MediaSendViewModel::class.java.simpleName + + // the maximum amount of files that can be selected to send as attachment + const val MAX_SELECTED_FILES: Int = 32 + } +}