package app.fedilab.android.mastodon.imageeditor; import android.Manifest; import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Typeface; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.provider.MediaStore; import android.view.MotionEvent; import android.view.View; import android.view.animation.AnticipateOvershootInterpolator; import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.constraintlayout.widget.ConstraintSet; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.exifinterface.media.ExifInterface; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.transition.ChangeBounds; import androidx.transition.TransitionManager; import com.canhub.cropper.CropImage; import com.canhub.cropper.CropImageContract; import com.canhub.cropper.CropImageContractOptions; import com.canhub.cropper.CropImageOptions; import com.canhub.cropper.CropImageView; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.io.File; import java.io.IOException; import java.io.InputStream; import app.fedilab.android.BuildConfig; import app.fedilab.android.R; import app.fedilab.android.databinding.ActivityEditImageBinding; import app.fedilab.android.mastodon.helper.CirclesDrawingView; import app.fedilab.android.mastodon.helper.Helper; import app.fedilab.android.mastodon.imageeditor.base.BaseActivity; import app.fedilab.android.mastodon.imageeditor.filters.FilterListener; import app.fedilab.android.mastodon.imageeditor.filters.FilterViewAdapter; import app.fedilab.android.mastodon.imageeditor.tools.EditingToolsAdapter; import app.fedilab.android.mastodon.imageeditor.tools.ToolType; import es.dmoral.toasty.Toasty; import ja.burhanrashid52.photoeditor.OnPhotoEditorListener; import ja.burhanrashid52.photoeditor.PhotoEditor; import ja.burhanrashid52.photoeditor.PhotoFilter; import ja.burhanrashid52.photoeditor.SaveSettings; import ja.burhanrashid52.photoeditor.TextStyleBuilder; import ja.burhanrashid52.photoeditor.ViewType; import ja.burhanrashid52.photoeditor.shape.ShapeBuilder; import ja.burhanrashid52.photoeditor.shape.ShapeType; public class EditImageActivity extends BaseActivity implements OnPhotoEditorListener, View.OnClickListener, PropertiesBSFragment.Properties, ShapeBSFragment.Properties, EmojiBSFragment.EmojiListener, EditingToolsAdapter.OnItemSelected, FilterListener { private static final int CAMERA_REQUEST = 52; private static final int PICK_REQUEST = 53; private final int STORE_REQUEST = 54; private final EditingToolsAdapter mEditingToolsAdapter = new EditingToolsAdapter(this); private final FilterViewAdapter mFilterViewAdapter = new FilterViewAdapter(this); private final ConstraintSet mConstraintSet = new ConstraintSet(); PhotoEditor mPhotoEditor; String path; CropImageContractOptions cropImageContractOptions; ActivityResultLauncher cropImageContractOptionsActivityResultLauncher; private PropertiesBSFragment mPropertiesBSFragment; private ShapeBSFragment mShapeBSFragment; private ShapeBuilder mShapeBuilder; private EmojiBSFragment mEmojiBSFragment; private boolean mIsFilterVisible; private Uri uri; private boolean exit; private ActivityEditImageBinding binding; private static int exifToDegrees(int exifOrientation) { if (exifOrientation == ExifInterface.ORIENTATION_ROTATE_90) { return 90; } else if (exifOrientation == ExifInterface.ORIENTATION_ROTATE_180) { return 180; } else if (exifOrientation == ExifInterface.ORIENTATION_ROTATE_270) { return 270; } return 0; } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); binding = ActivityEditImageBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); Bundle b = getIntent().getExtras(); if (getSupportActionBar() != null) getSupportActionBar().hide(); if (b != null) path = b.getString("imageUri", null); if (path == null) { finish(); } uri = Uri.parse("file://" + path); exit = false; initViews(); mPropertiesBSFragment = new PropertiesBSFragment(); mEmojiBSFragment = new EmojiBSFragment(); mShapeBSFragment = new ShapeBSFragment(); mEmojiBSFragment.setEmojiListener(this); mPropertiesBSFragment.setPropertiesChangeListener(this); mShapeBSFragment.setPropertiesChangeListener(this); LinearLayoutManager llmTools = new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false); binding.rvConstraintTools.setLayoutManager(llmTools); binding.rvConstraintTools.setAdapter(mEditingToolsAdapter); LinearLayoutManager llmFilters = new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false); binding.rvFilterView.setLayoutManager(llmFilters); binding.rvFilterView.setAdapter(mFilterViewAdapter); Typeface mEmojiTypeFace = Typeface.createFromAsset(getAssets(), "emojione-android.ttf"); mPhotoEditor = new PhotoEditor.Builder(this, binding.photoEditorView) .setPinchTextScalable(true) .setDefaultEmojiTypeface(mEmojiTypeFace) .build(); // build photo editor sdk mPhotoEditor.setOnPhotoEditorListener(this); //Set Image Dynamically try { binding.photoEditorView.getSource().setImageURI(uri); } catch (Exception e) { Toasty.error(EditImageActivity.this, getString(R.string.toast_error)).show(); } if (uri != null) { try (InputStream inputStream = getContentResolver().openInputStream(uri)) { ExifInterface exif = new ExifInterface(inputStream); int rotation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); int rotationInDegrees = exifToDegrees(rotation); binding.photoEditorView.getSource().setRotation(rotationInDegrees); } catch (Exception e) { e.printStackTrace(); } } cropImageContractOptions = new CropImageContractOptions(uri, new CropImageOptions()) .setGuidelines(CropImageView.Guidelines.ON) .setCropShape(CropImageView.CropShape.RECTANGLE) .setAllowRotation(true) .setAllowFlipping(true) .setOutputCompressFormat(Bitmap.CompressFormat.PNG) .setAllowCounterRotation(true) .setImageSource(true, false) .setScaleType(CropImageView.ScaleType.CENTER); cropImageContractOptionsActivityResultLauncher = registerForActivityResult( new CropImageContract(), result -> { if (result.isSuccessful()) { Uri resultUri = result.getUriContent(); if (resultUri != null) { binding.photoEditorView.getSource().setImageURI(resultUri); if (uri != null && uri.getPath() != null) { File fdelete = new File(uri.getPath()); if (fdelete.exists()) { //noinspection ResultOfMethodCallIgnored fdelete.delete(); } } uri = resultUri; } } }); mPhotoEditor.setFilterEffect(PhotoFilter.NONE); binding.send.setOnClickListener(v -> { exit = true; saveImage(); }); } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == STORE_REQUEST) {// If request is cancelled, the result arrays are empty. if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { saveImage(); } } } private void initViews() { binding.imgUndo.setOnClickListener(this); binding.imgRedo.setOnClickListener(this); binding.imgClose.setOnClickListener(this); } @Override public void onEditTextChangeListener(final View rootView, String text, int colorCode) { TextEditorDialogFragment textEditorDialogFragment = TextEditorDialogFragment.show(this, text, colorCode); textEditorDialogFragment.setOnTextEditorListener((inputText, newColorCode) -> { final TextStyleBuilder styleBuilder = new TextStyleBuilder(); styleBuilder.withTextColor(newColorCode); mPhotoEditor.editText(rootView, inputText, styleBuilder); binding.txtCurrentTool.setText(R.string.label_text); }); } @Override public void onAddViewListener(ViewType viewType, int numberOfAddedViews) { } @Override public void onRemoveViewListener(ViewType viewType, int numberOfAddedViews) { } @Override public void onStartViewChangeListener(ViewType viewType) { } @Override public void onStopViewChangeListener(ViewType viewType) { } @Override public void onTouchSourceImage(MotionEvent event) { } @Override public void onClick(View view) { int id = view.getId(); if (id == R.id.imgUndo) { mPhotoEditor.undo(); } else if (id == R.id.imgRedo) { mPhotoEditor.redo(); } else if (id == R.id.imgClose) { onBackPressed(); } } private void saveImage() { if (requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { showLoading(getString(R.string.saving)); File file = new File(path); try { //noinspection ResultOfMethodCallIgnored file.createNewFile(); SaveSettings saveSettings = new SaveSettings.Builder() .setClearViewsEnabled(true) .setTransparencyEnabled(true) .build(); if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { if (ContextCompat.checkSelfPermission(EditImageActivity.this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(EditImageActivity.this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, STORE_REQUEST); return; } } mPhotoEditor.saveAsFile(file.getAbsolutePath(), saveSettings, new PhotoEditor.OnSaveListener() { @Override public void onSuccess(@NonNull String imagePath) { hideLoading(); showSnackbar(getString(R.string.image_saved)); binding.photoEditorView.getSource().setImageURI(Uri.fromFile(new File(imagePath))); if (exit) { Intent intentImage = new Intent(Helper.INTENT_SEND_MODIFIED_IMAGE); intentImage.putExtra("imgpath", imagePath); CirclesDrawingView.CircleArea circleArea = binding.focusCircle.getTouchedCircle(); if (circleArea != null) { //Dimension of the editor containing the image int pHeight = binding.photoEditorView.getHeight(); int pWidth = binding.photoEditorView.getWidth(); //Load the original image in a bitmap BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeFile(new File(imagePath).getAbsolutePath(), options); //Get height and width of the original image int imageHeight = options.outHeight; int imageWidth = options.outWidth; //Evaluate the dimension of the image in the editor int imgHeightInEditor; int imgWidthInEditor; //If the original image has its height greater than width => heights are equals float focusX, focusY; if (imageHeight > imageWidth) { imgHeightInEditor = pHeight; float ratio = (float) pHeight / (float) imageHeight; imgWidthInEditor = (int) (pWidth * ratio); } else { //Otherwise widths are equals imgWidthInEditor = pWidth; float ratio = (float) pWidth / (float) imageWidth; imgHeightInEditor = (int) (pHeight * ratio); } focusY = (float) (circleArea.centerY * 2 - imgHeightInEditor / 2) / (float) imgHeightInEditor - 0.5f; focusX = (float) (circleArea.centerX * 2 - imgWidthInEditor / 2) / (float) imgWidthInEditor - 0.5f; if (focusX > 1) { focusX = 1; } else if (focusX < -1) { focusX = -1; } if (focusY > 1) { focusY = 1; } else if (focusY < -1) { focusY = -1; } intentImage.putExtra("focusX", focusX); intentImage.putExtra("focusY", focusY); } intentImage.setPackage(BuildConfig.APPLICATION_ID); sendBroadcast(intentImage); finish(); } } @Override public void onFailure(@NonNull Exception exception) { hideLoading(); showSnackbar(getString(R.string.save_image_failed)); } }); } catch (IOException e) { e.printStackTrace(); hideLoading(); if (e.getMessage() != null) { showSnackbar(e.getMessage()); } } } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (resultCode == RESULT_OK) { ExifInterface exif; int rotation; int rotationInDegrees = 0; if (data != null && data.getData() != null) { try (InputStream inputStream = getContentResolver().openInputStream(data.getData())) { assert inputStream != null; exif = new ExifInterface(inputStream); rotation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); rotationInDegrees = exifToDegrees(rotation); } catch (Exception e) { e.printStackTrace(); } } switch (requestCode) { case CAMERA_REQUEST: if (data != null && data.getExtras() != null) { mPhotoEditor.clearAllViews(); Bitmap photo = (Bitmap) data.getExtras().get("data"); binding.photoEditorView.getSource().setImageBitmap(photo); binding.photoEditorView.getSource().setRotation(rotationInDegrees); } break; case PICK_REQUEST: if (data != null && data.getData() != null) { try { mPhotoEditor.clearAllViews(); Uri uri = data.getData(); Bitmap bitmap = MediaStore.Images.Media.getBitmap(getContentResolver(), uri); binding.photoEditorView.getSource().setImageBitmap(bitmap); binding.photoEditorView.getSource().setRotation(rotationInDegrees); } catch (IOException e) { e.printStackTrace(); } } break; case CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE: if (data != null && data.getData() != null) { CropImageContractOptions cropImageContractOptions = new CropImageContractOptions(data.getData(), new CropImageOptions()) .setGuidelines(CropImageView.Guidelines.ON) .setCropShape(CropImageView.CropShape.RECTANGLE) .setAllowRotation(true) .setAllowFlipping(true) .setOutputCompressFormat(Bitmap.CompressFormat.PNG) .setAllowCounterRotation(true) .setImageSource(true, false) .setScaleType(CropImageView.ScaleType.CENTER); ActivityResultLauncher cropImageContractOptionsActivityResultLauncher = registerForActivityResult( new CropImageContract(), result -> { if (result.isSuccessful()) { Uri resultUri = result.getUriContent(); if (resultUri != null) { binding.photoEditorView.getSource().setImageURI(resultUri); if (uri != null && uri.getPath() != null) { File fdelete = new File(uri.getPath()); if (fdelete.exists()) { //noinspection ResultOfMethodCallIgnored fdelete.delete(); } } uri = resultUri; } } }); cropImageContractOptionsActivityResultLauncher.launch(cropImageContractOptions); } break; } } } @Override public void onColorChanged(int colorCode) { mPhotoEditor.setShape(mShapeBuilder.withShapeColor(colorCode)); binding.txtCurrentTool.setText(R.string.label_brush); } @Override public void onOpacityChanged(int opacity) { mPhotoEditor.setShape(mShapeBuilder.withShapeOpacity(opacity)); binding.txtCurrentTool.setText(R.string.label_brush); } @Override public void onShapeSizeChanged(int shapeSize) { mPhotoEditor.setShape(mShapeBuilder.withShapeSize(shapeSize)); binding.txtCurrentTool.setText(R.string.label_brush); } @Override public void onShapePicked(ShapeType shapeType) { mPhotoEditor.setShape(mShapeBuilder.withShapeType(shapeType)); } @Override public void onEmojiClick(String emojiUnicode) { mPhotoEditor.addEmoji(emojiUnicode); binding.txtCurrentTool.setText(R.string.label_emoji); } private void showSaveDialog() { AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this); builder.setMessage(getString(R.string.msg_save_image)); builder.setPositiveButton(R.string.cancel, (dialog, which) -> dialog.dismiss()); builder.setNegativeButton(R.string.discard, (dialog, which) -> finish()); builder.create().show(); } @Override public void onFilterSelected(PhotoFilter photoFilter) { mPhotoEditor.setFilterEffect(photoFilter); } @Override public void onToolSelected(ToolType toolType) { binding.focusCircle.setVisibility(View.GONE); switch (toolType) { case SHAPE: mPhotoEditor.setBrushDrawingMode(true); mShapeBuilder = new ShapeBuilder(); mPhotoEditor.setShape(mShapeBuilder); binding.txtCurrentTool.setText(R.string.label_shape); showBottomSheetDialogFragment(mShapeBSFragment); break; case TEXT: TextEditorDialogFragment textEditorDialogFragment = TextEditorDialogFragment.show(this); textEditorDialogFragment.setOnTextEditorListener((inputText, colorCode) -> { final TextStyleBuilder styleBuilder = new TextStyleBuilder(); styleBuilder.withTextColor(colorCode); mPhotoEditor.addText(inputText, styleBuilder); binding.txtCurrentTool.setText(R.string.label_text); }); break; case ERASER: mPhotoEditor.brushEraser(); binding.txtCurrentTool.setText(R.string.label_eraser_mode); break; case FILTER: binding.txtCurrentTool.setText(R.string.label_filter); showFilter(true); break; case EMOJI: showBottomSheetDialogFragment(mEmojiBSFragment); break; case BRUSH: mPhotoEditor.setBrushDrawingMode(true); binding.txtCurrentTool.setText(R.string.label_brush); mPropertiesBSFragment.show(getSupportFragmentManager(), mPropertiesBSFragment.getTag()); break; case CROP: cropImageContractOptionsActivityResultLauncher.launch(cropImageContractOptions); break; case FOCUS: binding.focusCircle.setVisibility(View.VISIBLE); break; } } private void showBottomSheetDialogFragment(BottomSheetDialogFragment fragment) { if (fragment == null || fragment.isAdded()) { return; } fragment.show(getSupportFragmentManager(), fragment.getTag()); } void showFilter(boolean isVisible) { mIsFilterVisible = isVisible; mConstraintSet.clone(binding.rootView); if (isVisible) { mConstraintSet.clear(binding.rvFilterView.getId(), ConstraintSet.START); mConstraintSet.connect(binding.rvFilterView.getId(), ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START); mConstraintSet.connect(binding.rvFilterView.getId(), ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END); } else { mConstraintSet.connect(binding.rvFilterView.getId(), ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.END); mConstraintSet.clear(binding.rvFilterView.getId(), ConstraintSet.END); } ChangeBounds changeBounds = new ChangeBounds(); changeBounds.setDuration(350); changeBounds.setInterpolator(new AnticipateOvershootInterpolator(1.0f)); TransitionManager.beginDelayedTransition(binding.rootView, changeBounds); mConstraintSet.applyTo(binding.rootView); } @Override public void onBackPressed() { if (mIsFilterVisible) { showFilter(false); binding.txtCurrentTool.setText(R.string.app_name); } else if (!mPhotoEditor.isCacheEmpty()) { showSaveDialog(); } else { super.onBackPressed(); } } }