[SES-3368] - Convert MediaSendFragment to Kotlin (#1064)
parent
1106987c0c
commit
e67d62a869
@ -1,464 +0,0 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Rect;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.WindowManager;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.view.ContextThemeWrapper;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
import com.bumptech.glide.Glide;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
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;
|
||||
import org.session.libsession.utilities.Util;
|
||||
import org.session.libsession.utilities.recipients.Recipient;
|
||||
import org.session.libsignal.utilities.ListenableFuture;
|
||||
import org.session.libsignal.utilities.Log;
|
||||
import org.session.libsignal.utilities.SettableFuture;
|
||||
import org.session.libsignal.utilities.guava.Optional;
|
||||
import org.thoughtcrime.securesms.components.ComposeText;
|
||||
import org.thoughtcrime.securesms.components.ControllableViewPager;
|
||||
import org.thoughtcrime.securesms.components.InputAwareLayout;
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
|
||||
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
|
||||
import org.thoughtcrime.securesms.util.PushCharacterCalculator;
|
||||
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,
|
||||
InputAwareLayout.OnKeyboardHiddenListener
|
||||
{
|
||||
private static final String TAG = MediaSendFragment.class.getSimpleName();
|
||||
|
||||
private static final String KEY_ADDRESS = "address";
|
||||
|
||||
private InputAwareLayout hud;
|
||||
private View captionAndRail;
|
||||
private ImageButton sendButton;
|
||||
private ComposeText composeText;
|
||||
private ViewGroup composeContainer;
|
||||
private ViewGroup playbackControlsContainer;
|
||||
private TextView charactersLeft;
|
||||
private View closeButton;
|
||||
private View loader;
|
||||
|
||||
private ControllableViewPager fragmentPager;
|
||||
private MediaSendFragmentPagerAdapter fragmentPagerAdapter;
|
||||
private RecyclerView mediaRail;
|
||||
private MediaRailAdapter mediaRailAdapter;
|
||||
|
||||
private int visibleHeight;
|
||||
private MediaSendViewModel viewModel;
|
||||
private Controller controller;
|
||||
|
||||
private final Rect visibleBounds = new Rect();
|
||||
|
||||
private final PushCharacterCalculator characterCalculator = new PushCharacterCalculator();
|
||||
|
||||
public static MediaSendFragment newInstance(@NonNull Recipient recipient) {
|
||||
Bundle args = new Bundle();
|
||||
args.putParcelable(KEY_ADDRESS, recipient.getAddress());
|
||||
|
||||
MediaSendFragment fragment = new MediaSendFragment();
|
||||
fragment.setArguments(args);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Context context) {
|
||||
super.onAttach(context);
|
||||
|
||||
if (!(requireActivity() instanceof Controller)) {
|
||||
throw new IllegalStateException("Parent activity must implement controller interface.");
|
||||
}
|
||||
|
||||
controller = (Controller) requireActivity();
|
||||
viewModel = new ViewModelProvider(requireActivity()).get(MediaSendViewModel.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.mediasend_fragment, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
initViewModel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
hud = view.findViewById(R.id.mediasend_hud);
|
||||
captionAndRail = view.findViewById(R.id.mediasend_caption_and_rail);
|
||||
sendButton = view.findViewById(R.id.mediasend_send_button);
|
||||
composeText = view.findViewById(R.id.mediasend_compose_text);
|
||||
composeContainer = view.findViewById(R.id.mediasend_compose_container);
|
||||
fragmentPager = view.findViewById(R.id.mediasend_pager);
|
||||
mediaRail = view.findViewById(R.id.mediasend_media_rail);
|
||||
playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container);
|
||||
charactersLeft = view.findViewById(R.id.mediasend_characters_left);
|
||||
closeButton = view.findViewById(R.id.mediasend_close_button);
|
||||
loader = view.findViewById(R.id.loader);
|
||||
|
||||
View sendButtonBkg = view.findViewById(R.id.mediasend_send_button_bkg);
|
||||
|
||||
sendButton.setOnClickListener(v -> {
|
||||
if (hud.isKeyboardOpen()) {
|
||||
hud.hideSoftkey(composeText, null);
|
||||
}
|
||||
|
||||
processMedia(fragmentPagerAdapter.getAllMedia(), fragmentPagerAdapter.getSavedState());
|
||||
});
|
||||
|
||||
ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener();
|
||||
|
||||
composeText.setOnKeyListener(composeKeyPressedListener);
|
||||
composeText.addTextChangedListener(composeKeyPressedListener);
|
||||
composeText.setOnClickListener(composeKeyPressedListener);
|
||||
composeText.setOnFocusChangeListener(composeKeyPressedListener);
|
||||
|
||||
composeText.requestFocus();
|
||||
|
||||
fragmentPagerAdapter = new MediaSendFragmentPagerAdapter(getChildFragmentManager());
|
||||
fragmentPager.setAdapter(fragmentPagerAdapter);
|
||||
|
||||
FragmentPageChangeListener pageChangeListener = new FragmentPageChangeListener();
|
||||
fragmentPager.addOnPageChangeListener(pageChangeListener);
|
||||
fragmentPager.post(() -> pageChangeListener.onPageSelected(fragmentPager.getCurrentItem()));
|
||||
|
||||
mediaRailAdapter = new MediaRailAdapter(Glide.with(this), this, true);
|
||||
mediaRail.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false));
|
||||
mediaRail.setAdapter(mediaRailAdapter);
|
||||
|
||||
hud.getRootView().getViewTreeObserver().addOnGlobalLayoutListener(this);
|
||||
hud.addOnKeyboardShownListener(this);
|
||||
hud.addOnKeyboardHiddenListener(this);
|
||||
|
||||
composeText.append(viewModel.getBody());
|
||||
|
||||
Recipient recipient = Recipient.from(requireContext(), getArguments().getParcelable(KEY_ADDRESS), false);
|
||||
String displayName = Optional.fromNullable(recipient.getName())
|
||||
.or(Optional.fromNullable(recipient.getProfileName())
|
||||
.or(recipient.getAddress().toString()));
|
||||
composeText.setHint(getString(R.string.message), null);
|
||||
composeText.setOnEditorActionListener((v, actionId, event) -> {
|
||||
boolean isSend = actionId == EditorInfo.IME_ACTION_SEND;
|
||||
if (isSend) sendButton.performClick();
|
||||
return isSend;
|
||||
});
|
||||
|
||||
closeButton.setOnClickListener(v -> requireActivity().onBackPressed());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
|
||||
fragmentPagerAdapter.restoreState(viewModel.getDrawState());
|
||||
viewModel.onImageEditorStarted();
|
||||
|
||||
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHiddenChanged(boolean hidden) {
|
||||
super.onHiddenChanged(hidden);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop() {
|
||||
super.onStop();
|
||||
fragmentPagerAdapter.saveAllState();
|
||||
viewModel.saveDrawState(fragmentPagerAdapter.getSavedState());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGlobalLayout() {
|
||||
hud.getRootView().getWindowVisibleDisplayFrame(visibleBounds);
|
||||
|
||||
int currentVisibleHeight = visibleBounds.height();
|
||||
|
||||
if (currentVisibleHeight != visibleHeight) {
|
||||
hud.getLayoutParams().height = currentVisibleHeight;
|
||||
hud.layout(visibleBounds.left, visibleBounds.top, visibleBounds.right, visibleBounds.bottom);
|
||||
hud.requestLayout();
|
||||
|
||||
visibleHeight = currentVisibleHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRailItemClicked(int distanceFromActive) {
|
||||
viewModel.onPageChanged(fragmentPager.getCurrentItem() + distanceFromActive);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRailItemDeleteClicked(int distanceFromActive) {
|
||||
viewModel.onMediaItemRemoved(requireContext(), fragmentPager.getCurrentItem() + distanceFromActive);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKeyboardShown() {
|
||||
if (composeText.hasFocus()) {
|
||||
mediaRail.setVisibility(View.VISIBLE);
|
||||
composeContainer.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
mediaRail.setVisibility(View.GONE);
|
||||
composeContainer.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKeyboardHidden() {
|
||||
composeContainer.setVisibility(View.VISIBLE);
|
||||
mediaRail.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
public void onTouchEventsNeeded(boolean needed) {
|
||||
if (fragmentPager != null) {
|
||||
fragmentPager.setEnabled(!needed);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean handleBackPress() {
|
||||
if (hud.isInputOpen()) {
|
||||
hud.hideCurrentInput(composeText);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void initViewModel() {
|
||||
viewModel.getSelectedMedia().observe(this, media -> {
|
||||
if (Util.isEmpty(media)) {
|
||||
controller.onNoMediaAvailable();
|
||||
return;
|
||||
}
|
||||
|
||||
fragmentPagerAdapter.setMedia(media);
|
||||
|
||||
mediaRail.setVisibility(View.VISIBLE);
|
||||
mediaRailAdapter.setMedia(media);
|
||||
});
|
||||
|
||||
viewModel.getPosition().observe(this, position -> {
|
||||
if (position == null || position < 0) return;
|
||||
|
||||
fragmentPager.setCurrentItem(position, true);
|
||||
mediaRailAdapter.setActivePosition(position);
|
||||
mediaRail.smoothScrollToPosition(position);
|
||||
|
||||
View playbackControls = fragmentPagerAdapter.getPlaybackControls(position);
|
||||
|
||||
if (playbackControls != null) {
|
||||
ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
playbackControls.setLayoutParams(params);
|
||||
playbackControlsContainer.removeAllViews();
|
||||
playbackControlsContainer.addView(playbackControls);
|
||||
} else {
|
||||
playbackControlsContainer.removeAllViews();
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.getBucketId().observe(this, bucketId -> {
|
||||
if (bucketId == null) return;
|
||||
|
||||
mediaRailAdapter.setAddButtonListener(() -> controller.onAddMediaClicked(bucketId));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private void presentCharactersRemaining() {
|
||||
String messageBody = composeText.getTextTrimmed();
|
||||
CharacterState characterState = characterCalculator.calculateCharacters(messageBody);
|
||||
|
||||
if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) {
|
||||
charactersLeft.setText(String.format(Locale.getDefault(),
|
||||
"%d/%d (%d)",
|
||||
characterState.charactersRemaining,
|
||||
characterState.maxTotalMessageSize,
|
||||
characterState.messagesSpent));
|
||||
charactersLeft.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
charactersLeft.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void processMedia(@NonNull List<Media> mediaList, @NonNull Map<Uri, Object> savedState) {
|
||||
Map<Media, ListenableFuture<Bitmap>> futures = new HashMap<>();
|
||||
|
||||
for (Media media : mediaList) {
|
||||
Object state = savedState.get(media.getUri());
|
||||
|
||||
if (state instanceof ImageEditorFragment.Data) {
|
||||
EditorModel model = ((ImageEditorFragment.Data) state).readModel();
|
||||
if (model != null && model.isChanged()) {
|
||||
futures.put(media, render(requireContext(), model));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new AsyncTask<Void, Void, List<Media>>() {
|
||||
|
||||
private Stopwatch renderTimer;
|
||||
private Runnable progressTimer;
|
||||
|
||||
@Override
|
||||
protected void onPreExecute() {
|
||||
renderTimer = new Stopwatch("ProcessMedia");
|
||||
progressTimer = () -> {
|
||||
loader.setVisibility(View.VISIBLE);
|
||||
};
|
||||
Util.runOnMainDelayed(progressTimer, 250);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<Media> doInBackground(Void... voids) {
|
||||
Context context = requireContext();
|
||||
List<Media> updatedMedia = new ArrayList<>(mediaList.size());
|
||||
|
||||
for (Media media : mediaList) {
|
||||
if (futures.containsKey(media)) {
|
||||
try {
|
||||
Bitmap bitmap = futures.get(media).get();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos);
|
||||
|
||||
Uri uri = BlobProvider.getInstance()
|
||||
.forData(baos.toByteArray())
|
||||
.withMimeType(MediaTypes.IMAGE_JPEG)
|
||||
.createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Failed to write to disk.", e));
|
||||
|
||||
Media updated = new Media(uri, media.getFilename(), MediaTypes.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), baos.size(), media.getBucketId(), media.getCaption());
|
||||
|
||||
updatedMedia.add(updated);
|
||||
renderTimer.split("item");
|
||||
} catch (InterruptedException | ExecutionException | IOException e) {
|
||||
Log.w(TAG, "Failed to render image. Using base image.");
|
||||
updatedMedia.add(media);
|
||||
}
|
||||
} else {
|
||||
updatedMedia.add(media);
|
||||
}
|
||||
}
|
||||
return updatedMedia;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(List<Media> media) {
|
||||
controller.onSendClicked(media, composeText.getTextTrimmed());
|
||||
Util.cancelRunnableOnMain(progressTimer);
|
||||
loader.setVisibility(View.GONE);
|
||||
renderTimer.stop(TAG);
|
||||
}
|
||||
}.execute();
|
||||
}
|
||||
|
||||
private static ListenableFuture<Bitmap> render(@NonNull Context context, @NonNull EditorModel model) {
|
||||
SettableFuture<Bitmap> future = new SettableFuture<>();
|
||||
|
||||
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> future.set(model.render(context)));
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
public void onRequestFullScreen(boolean fullScreen) {
|
||||
captionAndRail.setVisibility(fullScreen ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
|
||||
private class FragmentPageChangeListener extends ViewPager.SimpleOnPageChangeListener {
|
||||
@Override
|
||||
public void onPageSelected(int position) {
|
||||
viewModel.onPageChanged(position);
|
||||
}
|
||||
}
|
||||
|
||||
private class ComposeKeyPressedListener implements View.OnKeyListener, View.OnClickListener, TextWatcher, View.OnFocusChangeListener {
|
||||
|
||||
int beforeLength;
|
||||
|
||||
@Override
|
||||
public boolean onKey(View v, int keyCode, KeyEvent event) {
|
||||
if (event.getAction() == KeyEvent.ACTION_DOWN) {
|
||||
if (keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
if (TextSecurePreferences.isEnterSendsEnabled(requireContext())) {
|
||||
sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER));
|
||||
sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
hud.showSoftkey(composeText);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count,int after) {
|
||||
beforeLength = composeText.getTextTrimmed().length();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
presentCharactersRemaining();
|
||||
viewModel.onBodyChanged(s);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before,int count) {}
|
||||
|
||||
@Override
|
||||
public void onFocusChange(View v, boolean hasFocus) {}
|
||||
}
|
||||
|
||||
public interface Controller {
|
||||
void onAddMediaClicked(@NonNull String bucketId);
|
||||
void onSendClicked(@NonNull List<Media> media, @NonNull String body);
|
||||
void onNoMediaAvailable();
|
||||
}
|
||||
}
|
@ -0,0 +1,498 @@
|
||||
package org.thoughtcrime.securesms.mediasend
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.os.AsyncTask
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.OnFocusChangeListener
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver.OnGlobalLayoutListener
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import android.widget.TextView.OnEditorActionListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager.widget.ViewPager.SimpleOnPageChangeListener
|
||||
import com.bumptech.glide.Glide
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import network.loki.messenger.R
|
||||
import org.session.libsession.utilities.MediaTypes
|
||||
import org.session.libsession.utilities.TextSecurePreferences.Companion.isEnterSendsEnabled
|
||||
import org.session.libsession.utilities.Util.cancelRunnableOnMain
|
||||
import org.session.libsession.utilities.Util.isEmpty
|
||||
import org.session.libsession.utilities.Util.runOnMainDelayed
|
||||
import org.session.libsession.utilities.recipients.Recipient
|
||||
import org.session.libsignal.utilities.ListenableFuture
|
||||
import org.session.libsignal.utilities.Log
|
||||
import org.session.libsignal.utilities.SettableFuture
|
||||
import org.session.libsignal.utilities.guava.Optional
|
||||
import org.thoughtcrime.securesms.components.ComposeText
|
||||
import org.thoughtcrime.securesms.components.ControllableViewPager
|
||||
import org.thoughtcrime.securesms.components.InputAwareLayout
|
||||
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardHiddenListener
|
||||
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorModel
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter.RailItemListener
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendViewModel
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
|
||||
import org.thoughtcrime.securesms.util.PushCharacterCalculator
|
||||
import org.thoughtcrime.securesms.util.Stopwatch
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ExecutionException
|
||||
|
||||
/**
|
||||
* Allows the user to edit and caption a set of media items before choosing to send them.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MediaSendFragment : Fragment(), OnGlobalLayoutListener, RailItemListener,
|
||||
OnKeyboardShownListener, OnKeyboardHiddenListener {
|
||||
private var hud: InputAwareLayout? = null
|
||||
private var captionAndRail: View? = null
|
||||
private var sendButton: ImageButton? = null
|
||||
private var composeText: ComposeText? = null
|
||||
private var composeContainer: ViewGroup? = null
|
||||
private var playbackControlsContainer: ViewGroup? = null
|
||||
private var charactersLeft: TextView? = null
|
||||
private var closeButton: View? = null
|
||||
private var loader: View? = null
|
||||
|
||||
private var fragmentPager: ControllableViewPager? = null
|
||||
private var fragmentPagerAdapter: MediaSendFragmentPagerAdapter? = null
|
||||
private var mediaRail: RecyclerView? = null
|
||||
private var mediaRailAdapter: MediaRailAdapter? = null
|
||||
|
||||
private var visibleHeight = 0
|
||||
private var viewModel: MediaSendViewModel? = null
|
||||
private var controller: Controller? = null
|
||||
|
||||
private val visibleBounds = Rect()
|
||||
|
||||
private val characterCalculator = PushCharacterCalculator()
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
|
||||
check(requireActivity() is Controller) { "Parent activity must implement controller interface." }
|
||||
|
||||
controller = requireActivity() as Controller
|
||||
viewModel = ViewModelProvider(requireActivity()).get(
|
||||
MediaSendViewModel::class.java
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.mediasend_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
initViewModel()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
hud = view.findViewById(R.id.mediasend_hud)
|
||||
captionAndRail = view.findViewById(R.id.mediasend_caption_and_rail)
|
||||
sendButton = view.findViewById(R.id.mediasend_send_button)
|
||||
composeText = view.findViewById(R.id.mediasend_compose_text)
|
||||
composeContainer = view.findViewById(R.id.mediasend_compose_container)
|
||||
fragmentPager = view.findViewById(R.id.mediasend_pager)
|
||||
mediaRail = view.findViewById(R.id.mediasend_media_rail)
|
||||
playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container)
|
||||
charactersLeft = view.findViewById(R.id.mediasend_characters_left)
|
||||
closeButton = view.findViewById(R.id.mediasend_close_button)
|
||||
loader = view.findViewById(R.id.loader)
|
||||
|
||||
val sendButtonBkg = view.findViewById<View>(R.id.mediasend_send_button_bkg)
|
||||
|
||||
sendButton!!.setOnClickListener(View.OnClickListener { v: View? ->
|
||||
if (hud!!.isKeyboardOpen()) {
|
||||
hud!!.hideSoftkey(composeText, null)
|
||||
}
|
||||
processMedia(fragmentPagerAdapter!!.allMedia, fragmentPagerAdapter!!.savedState)
|
||||
})
|
||||
|
||||
val composeKeyPressedListener = ComposeKeyPressedListener()
|
||||
|
||||
composeText!!.setOnKeyListener(composeKeyPressedListener)
|
||||
composeText!!.addTextChangedListener(composeKeyPressedListener)
|
||||
composeText!!.setOnClickListener(composeKeyPressedListener)
|
||||
composeText!!.setOnFocusChangeListener(composeKeyPressedListener)
|
||||
|
||||
composeText!!.requestFocus()
|
||||
|
||||
fragmentPagerAdapter = MediaSendFragmentPagerAdapter(childFragmentManager)
|
||||
fragmentPager!!.setAdapter(fragmentPagerAdapter)
|
||||
|
||||
val pageChangeListener = FragmentPageChangeListener()
|
||||
fragmentPager!!.addOnPageChangeListener(pageChangeListener)
|
||||
fragmentPager!!.post(Runnable { pageChangeListener.onPageSelected(fragmentPager!!.currentItem) })
|
||||
|
||||
mediaRailAdapter = MediaRailAdapter(Glide.with(this), this, true)
|
||||
mediaRail!!.setLayoutManager(
|
||||
LinearLayoutManager(
|
||||
requireContext(),
|
||||
LinearLayoutManager.HORIZONTAL,
|
||||
false
|
||||
)
|
||||
)
|
||||
mediaRail!!.setAdapter(mediaRailAdapter)
|
||||
|
||||
hud!!.getRootView().viewTreeObserver.addOnGlobalLayoutListener(this)
|
||||
hud!!.addOnKeyboardShownListener(this)
|
||||
hud!!.addOnKeyboardHiddenListener(this)
|
||||
|
||||
composeText!!.append(viewModel!!.body)
|
||||
|
||||
val recipient = Recipient.from(
|
||||
requireContext(),
|
||||
arguments!!.getParcelable(KEY_ADDRESS)!!, false
|
||||
)
|
||||
val displayName = Optional.fromNullable(recipient.name)
|
||||
.or(
|
||||
Optional.fromNullable(recipient.profileName)
|
||||
.or(recipient.address.toString())
|
||||
)
|
||||
composeText!!.setHint(getString(R.string.message), null)
|
||||
composeText!!.setOnEditorActionListener(OnEditorActionListener { v: TextView?, actionId: Int, event: KeyEvent? ->
|
||||
val isSend = actionId == EditorInfo.IME_ACTION_SEND
|
||||
if (isSend) sendButton!!.performClick()
|
||||
isSend
|
||||
})
|
||||
|
||||
closeButton!!.setOnClickListener(View.OnClickListener { v: View? -> requireActivity().onBackPressed() })
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
fragmentPagerAdapter!!.restoreState(viewModel!!.drawState)
|
||||
viewModel!!.onImageEditorStarted()
|
||||
|
||||
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN)
|
||||
}
|
||||
|
||||
override fun onHiddenChanged(hidden: Boolean) {
|
||||
super.onHiddenChanged(hidden)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
fragmentPagerAdapter!!.saveAllState()
|
||||
viewModel!!.saveDrawState(fragmentPagerAdapter!!.savedState)
|
||||
}
|
||||
|
||||
override fun onGlobalLayout() {
|
||||
hud!!.rootView.getWindowVisibleDisplayFrame(visibleBounds)
|
||||
|
||||
val currentVisibleHeight = visibleBounds.height()
|
||||
|
||||
if (currentVisibleHeight != visibleHeight) {
|
||||
hud!!.layoutParams.height = currentVisibleHeight
|
||||
hud!!.layout(
|
||||
visibleBounds.left,
|
||||
visibleBounds.top,
|
||||
visibleBounds.right,
|
||||
visibleBounds.bottom
|
||||
)
|
||||
hud!!.requestLayout()
|
||||
|
||||
visibleHeight = currentVisibleHeight
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRailItemClicked(distanceFromActive: Int) {
|
||||
viewModel!!.onPageChanged(fragmentPager!!.currentItem + distanceFromActive)
|
||||
}
|
||||
|
||||
override fun onRailItemDeleteClicked(distanceFromActive: Int) {
|
||||
viewModel!!.onMediaItemRemoved(
|
||||
requireContext(),
|
||||
fragmentPager!!.currentItem + distanceFromActive
|
||||
)
|
||||
}
|
||||
|
||||
override fun onKeyboardShown() {
|
||||
if (composeText!!.hasFocus()) {
|
||||
mediaRail!!.visibility = View.VISIBLE
|
||||
composeContainer!!.visibility = View.VISIBLE
|
||||
} else {
|
||||
mediaRail!!.visibility = View.GONE
|
||||
composeContainer!!.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyboardHidden() {
|
||||
composeContainer!!.visibility = View.VISIBLE
|
||||
mediaRail!!.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
fun onTouchEventsNeeded(needed: Boolean) {
|
||||
if (fragmentPager != null) {
|
||||
fragmentPager!!.isEnabled = !needed
|
||||
}
|
||||
}
|
||||
|
||||
fun handleBackPress(): Boolean {
|
||||
if (hud!!.isInputOpen) {
|
||||
hud!!.hideCurrentInput(composeText)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun initViewModel() {
|
||||
viewModel!!.getSelectedMedia().observe(
|
||||
this
|
||||
) { media: List<Media?>? ->
|
||||
if (isEmpty(media)) {
|
||||
controller!!.onNoMediaAvailable()
|
||||
return@observe
|
||||
}
|
||||
fragmentPagerAdapter!!.setMedia(media!!)
|
||||
|
||||
mediaRail!!.visibility = View.VISIBLE
|
||||
mediaRailAdapter!!.setMedia(media)
|
||||
}
|
||||
|
||||
viewModel!!.getPosition().observe(this) { position: Int? ->
|
||||
if (position == null || position < 0) return@observe
|
||||
fragmentPager!!.setCurrentItem(position, true)
|
||||
mediaRailAdapter!!.setActivePosition(position)
|
||||
mediaRail!!.smoothScrollToPosition(position)
|
||||
|
||||
val playbackControls = fragmentPagerAdapter!!.getPlaybackControls(position)
|
||||
if (playbackControls != null) {
|
||||
val params = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
playbackControls.layoutParams = params
|
||||
playbackControlsContainer!!.removeAllViews()
|
||||
playbackControlsContainer!!.addView(playbackControls)
|
||||
} else {
|
||||
playbackControlsContainer!!.removeAllViews()
|
||||
}
|
||||
}
|
||||
|
||||
viewModel!!.getBucketId().observe(this) { bucketId: String? ->
|
||||
if (bucketId == null) return@observe
|
||||
mediaRailAdapter!!.setAddButtonListener { controller!!.onAddMediaClicked(bucketId) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun presentCharactersRemaining() {
|
||||
val messageBody = composeText!!.textTrimmed
|
||||
val characterState = characterCalculator.calculateCharacters(messageBody)
|
||||
|
||||
if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) {
|
||||
charactersLeft!!.text = String.format(
|
||||
Locale.getDefault(),
|
||||
"%d/%d (%d)",
|
||||
characterState.charactersRemaining,
|
||||
characterState.maxTotalMessageSize,
|
||||
characterState.messagesSpent
|
||||
)
|
||||
charactersLeft!!.visibility = View.VISIBLE
|
||||
} else {
|
||||
charactersLeft!!.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private fun processMedia(mediaList: List<Media>, savedState: Map<Uri, Any>) {
|
||||
val futures: MutableMap<Media, ListenableFuture<Bitmap>> = HashMap()
|
||||
|
||||
for (media in mediaList) {
|
||||
val state = savedState[media.uri]
|
||||
|
||||
if (state is ImageEditorFragment.Data) {
|
||||
val model = state.readModel()
|
||||
if (model != null && model.isChanged) {
|
||||
futures[media] = render(requireContext(), model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object : AsyncTask<Void?, Void?, List<Media>>() {
|
||||
private var renderTimer: Stopwatch? = null
|
||||
private var progressTimer: Runnable? = null
|
||||
|
||||
override fun onPreExecute() {
|
||||
renderTimer = Stopwatch("ProcessMedia")
|
||||
progressTimer = Runnable {
|
||||
loader!!.visibility = View.VISIBLE
|
||||
}
|
||||
runOnMainDelayed(progressTimer!!, 250)
|
||||
}
|
||||
|
||||
override fun doInBackground(vararg params: Void?): List<Media> {
|
||||
val context = requireContext()
|
||||
val updatedMedia: MutableList<Media> = ArrayList(mediaList.size)
|
||||
|
||||
for (media in mediaList) {
|
||||
if (futures.containsKey(media)) {
|
||||
try {
|
||||
val bitmap = futures[media]!!.get()
|
||||
val baos = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos)
|
||||
|
||||
val uri = BlobProvider.getInstance()
|
||||
.forData(baos.toByteArray())
|
||||
.withMimeType(MediaTypes.IMAGE_JPEG)
|
||||
.createForSingleSessionOnDisk(
|
||||
context
|
||||
) { e: IOException? ->
|
||||
Log.w(
|
||||
TAG,
|
||||
"Failed to write to disk.",
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
val updated = Media(
|
||||
uri,
|
||||
media.filename,
|
||||
MediaTypes.IMAGE_JPEG,
|
||||
media.date,
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
baos.size().toLong(),
|
||||
media.bucketId,
|
||||
media.caption
|
||||
)
|
||||
|
||||
updatedMedia.add(updated)
|
||||
renderTimer!!.split("item")
|
||||
} catch (e: InterruptedException) {
|
||||
Log.w(TAG, "Failed to render image. Using base image.")
|
||||
updatedMedia.add(media)
|
||||
} catch (e: ExecutionException) {
|
||||
Log.w(TAG, "Failed to render image. Using base image.")
|
||||
updatedMedia.add(media)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to render image. Using base image.")
|
||||
updatedMedia.add(media)
|
||||
}
|
||||
} else {
|
||||
updatedMedia.add(media)
|
||||
}
|
||||
}
|
||||
return updatedMedia
|
||||
}
|
||||
|
||||
override fun onPostExecute(media: List<Media>) {
|
||||
controller!!.onSendClicked(media, composeText!!.textTrimmed)
|
||||
cancelRunnableOnMain(progressTimer!!)
|
||||
loader!!.visibility = View.GONE
|
||||
renderTimer!!.stop(TAG)
|
||||
}
|
||||
}.execute()
|
||||
}
|
||||
|
||||
fun onRequestFullScreen(fullScreen: Boolean) {
|
||||
captionAndRail!!.visibility =
|
||||
if (fullScreen) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
private inner class FragmentPageChangeListener : SimpleOnPageChangeListener() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
viewModel!!.onPageChanged(position)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ComposeKeyPressedListener : View.OnKeyListener, View.OnClickListener,
|
||||
TextWatcher, OnFocusChangeListener {
|
||||
var beforeLength: Int = 0
|
||||
|
||||
override fun onKey(v: View, keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
if (keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
if (isEnterSendsEnabled(requireContext())) {
|
||||
sendButton!!.dispatchKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_DOWN,
|
||||
KeyEvent.KEYCODE_ENTER
|
||||
)
|
||||
)
|
||||
sendButton!!.dispatchKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_UP,
|
||||
KeyEvent.KEYCODE_ENTER
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
hud!!.showSoftkey(composeText)
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
|
||||
beforeLength = composeText!!.textTrimmed.length
|
||||
}
|
||||
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
presentCharactersRemaining()
|
||||
viewModel!!.onBodyChanged(s)
|
||||
}
|
||||
|
||||
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
|
||||
|
||||
override fun onFocusChange(v: View, hasFocus: Boolean) {}
|
||||
}
|
||||
|
||||
interface Controller {
|
||||
fun onAddMediaClicked(bucketId: String)
|
||||
fun onSendClicked(media: List<Media>, body: String)
|
||||
fun onNoMediaAvailable()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG: String = MediaSendFragment::class.java.simpleName
|
||||
|
||||
private const val KEY_ADDRESS = "address"
|
||||
|
||||
fun newInstance(recipient: Recipient): MediaSendFragment {
|
||||
val args = Bundle()
|
||||
args.putParcelable(KEY_ADDRESS, recipient.address)
|
||||
|
||||
val fragment = MediaSendFragment()
|
||||
fragment.arguments = args
|
||||
return fragment
|
||||
}
|
||||
|
||||
private fun render(context: Context, model: EditorModel): ListenableFuture<Bitmap> {
|
||||
val future = SettableFuture<Bitmap>()
|
||||
|
||||
AsyncTask.THREAD_POOL_EXECUTOR.execute { future.set(model.render(context)) }
|
||||
|
||||
return future
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue