diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d82267a0a7..f55961a792 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -552,6 +552,15 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode" android:launchMode="singleTask" /> + + + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java index 18784a167d..7a4a0f8c71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java @@ -61,6 +61,9 @@ public final class ImageEditorView extends FrameLayout { @Nullable private DrawingChangedListener drawingChangedListener; + @Nullable + private SizeChangedListener sizeChangedListener; + @Nullable private UndoRedoStackListener undoRedoStackListener; @@ -93,7 +96,7 @@ public final class ImageEditorView extends FrameLayout { private void init() { setWillNotDraw(false); - setModel(new EditorModel()); + setModel(EditorModel.create()); editText = createAHiddenTextEntryField(); @@ -170,6 +173,9 @@ public final class ImageEditorView extends FrameLayout { protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); updateViewMatrix(); + if (sizeChangedListener != null) { + sizeChangedListener.onSizeChanged(w, h); + } } private void updateViewMatrix() { @@ -393,6 +399,10 @@ public final class ImageEditorView extends FrameLayout { this.drawingChangedListener = drawingChangedListener; } + public void setSizeChangedListener(@Nullable SizeChangedListener sizeChangedListener) { + this.sizeChangedListener = sizeChangedListener; + } + public void setUndoRedoStackListener(@Nullable UndoRedoStackListener undoRedoStackListener) { this.undoRedoStackListener = undoRedoStackListener; } @@ -463,6 +473,10 @@ public final class ImageEditorView extends FrameLayout { void onDrawingChanged(); } + public interface SizeChangedListener { + void onSizeChanged(int newWidth, int newHeight); + } + public interface TapListener { void onEntityDown(@Nullable EditorElement editorElement); diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java index cab52e3f48..2c7cb59359 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java @@ -45,11 +45,15 @@ import org.thoughtcrime.securesms.imageeditor.renderers.OvalGuideRenderer; final class EditorElementHierarchy { static @NonNull EditorElementHierarchy create() { - return new EditorElementHierarchy(createRoot(false)); + return new EditorElementHierarchy(createRoot(CropStyle.RECTANGLE)); } static @NonNull EditorElementHierarchy createForCircleEditing() { - return new EditorElementHierarchy(createRoot(true)); + return new EditorElementHierarchy(createRoot(CropStyle.CIRCLE)); + } + + static @NonNull EditorElementHierarchy createForPinchAndPanCropping() { + return new EditorElementHierarchy(createRoot(CropStyle.PINCH_AND_PAN)); } static @NonNull EditorElementHierarchy create(@NonNull EditorElement root) { @@ -78,7 +82,24 @@ final class EditorElementHierarchy { this.thumbs = this.cropEditorElement.getChild(1); } - private static @NonNull EditorElement createRoot(boolean circleEdit) { + private enum CropStyle { + /** + * A rectangular overlay with 8 thumbs, corners and edges. + */ + RECTANGLE, + + /** + * Cropping with a circular template overlay with Corner thumbs only. + */ + CIRCLE, + + /** + * No overlay and no thumbs. Cropping achieved through pinching and panning. + */ + PINCH_AND_PAN + } + + private static @NonNull EditorElement createRoot(@NonNull CropStyle cropStyle) { EditorElement root = new EditorElement(null); EditorElement imageRoot = new EditorElement(null); @@ -96,7 +117,8 @@ final class EditorElementHierarchy { EditorElement imageCrop = new EditorElement(null); overlay.addElement(imageCrop); - EditorElement cropEditorElement = new EditorElement(new CropAreaRenderer(R.color.crop_area_renderer_outer_color, !circleEdit)); + boolean renderCenterThumbs = cropStyle == CropStyle.RECTANGLE; + EditorElement cropEditorElement = new EditorElement(new CropAreaRenderer(R.color.crop_area_renderer_outer_color, renderCenterThumbs)); cropEditorElement.getFlags() .setRotateLocked(true) @@ -116,14 +138,18 @@ final class EditorElementHierarchy { cropEditorElement.addElement(blackout); - cropEditorElement.addElement(createThumbs(cropEditorElement, !circleEdit)); + if (cropStyle == CropStyle.PINCH_AND_PAN) { + cropEditorElement.addElement(new EditorElement(null)); + } else { + cropEditorElement.addElement(createThumbs(cropEditorElement, renderCenterThumbs)); - if (circleEdit) { - EditorElement circle = new EditorElement(new OvalGuideRenderer(R.color.crop_circle_guide_color)); - circle.getFlags().setSelectable(false) - .persist(); + if (cropStyle == CropStyle.CIRCLE) { + EditorElement circle = new EditorElement(new OvalGuideRenderer(R.color.crop_circle_guide_color)); + circle.getFlags().setSelectable(false) + .persist(); - cropEditorElement.addElement(circle); + cropEditorElement.addElement(circle); + } } return root; @@ -197,11 +223,14 @@ final class EditorElementHierarchy { return flipRotate; } - void startCrop(@NonNull Runnable invalidate) { - Matrix editor = new Matrix(); - float scaleInForCrop = 0.8f; + /** + * @param scaleIn Use 1 for no scale in, use less than 1 and it will zoom the image out + * so user can see more of the surrounding image while cropping. + */ + void startCrop(@NonNull Runnable invalidate, float scaleIn) { + Matrix editor = new Matrix(); - editor.postScale(scaleInForCrop, scaleInForCrop); + editor.postScale(scaleIn, scaleIn); root.animateEditorTo(editor, invalidate); cropEditorElement.getFlags() diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java index b94b54ac62..0ed3e94802 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java @@ -60,17 +60,21 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { private EditorElementHierarchy editorElementHierarchy; - private final RectF visibleViewPort = new RectF(); - private final Point size; - private final boolean circleEditing; + private final RectF visibleViewPort = new RectF(); + private final Point size; + private final EditingPurpose editingPurpose; + private float fixedRatio; - public EditorModel() { - this(false, EditorElementHierarchy.create()); + private enum EditingPurpose { + IMAGE, + AVATAR_CIRCLE, + WALLPAPER } private EditorModel(@NonNull Parcel in) { ClassLoader classLoader = getClass().getClassLoader(); - this.circleEditing = in.readByte() == 1; + this.editingPurpose = EditingPurpose.values()[in.readInt()]; + this.fixedRatio = in.readFloat(); this.size = new Point(in.readInt(), in.readInt()); //noinspection ConstantConditions this.editorElementHierarchy = EditorElementHierarchy.create(in.readParcelable(classLoader)); @@ -78,8 +82,9 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { this.cropUndoRedoStacks = in.readParcelable(classLoader); } - public EditorModel(boolean circleEditing, @NonNull EditorElementHierarchy editorElementHierarchy) { - this.circleEditing = circleEditing; + public EditorModel(@NonNull EditingPurpose editingPurpose, float fixedRatio, @NonNull EditorElementHierarchy editorElementHierarchy) { + this.editingPurpose = editingPurpose; + this.fixedRatio = fixedRatio; this.size = new Point(1024, 1024); this.editorElementHierarchy = editorElementHierarchy; this.undoRedoStacks = new UndoRedoStacks(50); @@ -87,11 +92,17 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { } public static EditorModel create() { - return new EditorModel(false, EditorElementHierarchy.create()); + return new EditorModel(EditingPurpose.IMAGE, 0, EditorElementHierarchy.create()); } public static EditorModel createForCircleEditing() { - EditorModel editorModel = new EditorModel(true, EditorElementHierarchy.createForCircleEditing()); + EditorModel editorModel = new EditorModel(EditingPurpose.AVATAR_CIRCLE, 1, EditorElementHierarchy.createForCircleEditing()); + editorModel.setCropAspectLock(true); + return editorModel; + } + + public static EditorModel createForWallpaperEditing(float fixedRatio) { + EditorModel editorModel = new EditorModel(EditingPurpose.WALLPAPER, fixedRatio, EditorElementHierarchy.createForPinchAndPanCropping()); editorModel.setCropAspectLock(true); return editorModel; } @@ -260,9 +271,11 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { } public void startCrop() { + float scaleIn = editingPurpose == EditingPurpose.WALLPAPER ? 1 : 0.8f; + pushUndoPoint(); cropUndoRedoStacks.clear(editorElementHierarchy.getRoot()); - editorElementHierarchy.startCrop(invalidate); + editorElementHierarchy.startCrop(invalidate, scaleIn); inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement()); updateUndoRedoAvailableState(cropUndoRedoStacks); } @@ -538,7 +551,8 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { @Override public void writeToParcel(Parcel dest, int flags) { - dest.writeByte((byte) (circleEditing ? 1 : 0)); + dest.writeInt(editingPurpose.ordinal()); + dest.writeFloat(fixedRatio); dest.writeInt(size.x); dest.writeInt(size.y); dest.writeParcelable(editorElementHierarchy.getRoot(), flags); @@ -628,10 +642,10 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { if (imageCropMatrix.isIdentity()) { imageCropMatrix.set(cropMatrix); - if (circleEditing) { + if (editingPurpose == EditingPurpose.AVATAR_CIRCLE || editingPurpose == EditingPurpose.WALLPAPER) { Matrix userCropMatrix = editorElementHierarchy.getCropEditorElement().getLocalMatrix(); if (size.x > size.y) { - userCropMatrix.setScale(size.y / (float) size.x, 1f); + userCropMatrix.setScale(fixedRatio * size.y / (float) size.x, 1f); } else { userCropMatrix.setScale(1f, size.x / (float) size.y); } @@ -643,13 +657,37 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { undoRedoStacks.clear(editorElementHierarchy.getRoot()); } - if (circleEditing) { - startCrop(); + switch (editingPurpose) { + case AVATAR_CIRCLE: { + startCrop(); + break; + } + case WALLPAPER: { + setFixedRatio(fixedRatio); + startCrop(); + break; + } } } } } + public void setFixedRatio(float r) { + fixedRatio = r; + Matrix userCropMatrix = editorElementHierarchy.getCropEditorElement().getLocalMatrix(); + float w = size.x; + float h = size.y; + float imageRatio = w / h; + if (imageRatio > r) { + userCropMatrix.setScale(r / imageRatio, 1f); + } else { + userCropMatrix.setScale(1f, imageRatio / r); + } + + editorElementHierarchy.doneCrop(visibleViewPort, null); + startCrop(); + } + private boolean isRendererOfMainImage(@NonNull Renderer renderer) { EditorElement mainImage = editorElementHierarchy.getMainImage(); Renderer mainImageRenderer = mainImage != null ? mainImage.getRenderer() : null; @@ -790,7 +828,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { return editorElementHierarchy.getRoot(); } - public EditorElement getMainImage() { + public @Nullable EditorElement getMainImage() { return editorElementHierarchy.getMainImage(); } 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 e97eb7ee67..8bd54bccfa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java @@ -29,13 +29,19 @@ import org.thoughtcrime.securesms.recipients.Recipient; public class MediaPickerFolderFragment extends Fragment implements MediaPickerFolderAdapter.EventListener { private static final String KEY_TOOLBAR_TITLE = "toolbar_title"; + private static final String KEY_HIDE_CAMERA = "hide_camera"; private String toolbarTitle; + private boolean showCamera; private MediaSendViewModel viewModel; private Controller controller; private GridLayoutManager layoutManager; public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Context context, @Nullable Recipient recipient) { + return newInstance(context, recipient, false); + } + + public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Context context, @Nullable Recipient recipient, boolean hideCamera) { String toolbarTitle; if (recipient != null) { @@ -45,8 +51,13 @@ public class MediaPickerFolderFragment extends Fragment implements MediaPickerFo toolbarTitle = ""; } + return newInstance(toolbarTitle, hideCamera); + } + + public static @NonNull MediaPickerFolderFragment newInstance(@NonNull String toolbarTitle, boolean hideCamera) { Bundle args = new Bundle(); args.putString(KEY_TOOLBAR_TITLE, toolbarTitle); + args.putBoolean(KEY_HIDE_CAMERA, hideCamera); MediaPickerFolderFragment fragment = new MediaPickerFolderFragment(); fragment.setArguments(args); @@ -60,6 +71,7 @@ public class MediaPickerFolderFragment extends Fragment implements MediaPickerFo setHasOptionsMenu(true); toolbarTitle = getArguments().getString(KEY_TOOLBAR_TITLE); + showCamera = !getArguments().getBoolean(KEY_HIDE_CAMERA); viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); } @@ -105,16 +117,14 @@ public class MediaPickerFolderFragment extends Fragment implements MediaPickerFo @Override public void onPrepareOptionsMenu(@NonNull Menu menu) { - requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu); + if (showCamera) { + requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu); + } } @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { - switch (item.getItemId()) { - case R.id.mediapicker_menu_camera: - controller.onCameraSelected(); - return true; - } + if (item.getItemId() == R.id.mediapicker_menu_camera) { controller.onCameraSelected(); return true; } return false; } 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 972dcecb26..123b858268 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -22,9 +22,7 @@ import androidx.recyclerview.widget.RecyclerView; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.mms.GlideApp; -import org.thoughtcrime.securesms.util.Util; -import java.util.ArrayList; import java.util.List; /** @@ -36,10 +34,12 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem private static final String KEY_FOLDER_TITLE = "folder_title"; private static final String KEY_MAX_SELECTION = "max_selection"; private static final String KEY_FORCE_MULTI_SELECT = "force_multi_select"; + private static final String KEY_HIDE_CAMERA = "hide_camera"; private String bucketId; private String folderTitle; private int maxSelection; + private boolean showCamera; private MediaSendViewModel viewModel; private MediaPickerItemAdapter adapter; private Controller controller; @@ -50,11 +50,16 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem } public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection, boolean forceMultiSelect) { + return newInstance(bucketId, folderTitle, maxSelection, forceMultiSelect, false); + } + + public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection, boolean forceMultiSelect, boolean hideCamera) { Bundle args = new Bundle(); args.putString(KEY_BUCKET_ID, bucketId); args.putString(KEY_FOLDER_TITLE, folderTitle); args.putInt(KEY_MAX_SELECTION, maxSelection); args.putBoolean(KEY_FORCE_MULTI_SELECT, forceMultiSelect); + args.putBoolean(KEY_HIDE_CAMERA, hideCamera); MediaPickerItemFragment fragment = new MediaPickerItemFragment(); fragment.setArguments(args); @@ -70,6 +75,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); + showCamera = !getArguments().getBoolean(KEY_HIDE_CAMERA); viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class); } @@ -120,16 +126,14 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem @Override public void onPrepareOptionsMenu(@NonNull Menu menu) { - requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu); + if (showCamera) { + requireActivity().getMenuInflater().inflate(R.menu.mediapicker_default, menu); + } } @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { - switch (item.getItemId()) { - case R.id.mediapicker_menu_camera: - controller.onCameraSelected(); - return true; - } + if (item.getItemId() == R.id.mediapicker_menu_camera) { controller.onCameraSelected(); return true; } return false; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java index 97b31313df..420668e961 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java @@ -41,13 +41,16 @@ import java.util.concurrent.ExecutionException; * * The image can be encrypted. */ -final class UriGlideRenderer implements Renderer { +public final class UriGlideRenderer implements Renderer { private static final String TAG = Log.tag(UriGlideRenderer.class); private static final int PREVIEW_DIMENSION_LIMIT = 2048; private static final int MAX_BLUR_DIMENSION = 300; + public static final float WEAK_BLUR = 3f; + public static final float STRONG_BLUR = 25f; + private final Uri imageUri; private final Paint paint = new Paint(); private final Matrix imageProjectionMatrix = new Matrix(); @@ -56,16 +59,22 @@ final class UriGlideRenderer implements Renderer { private final boolean decryptable; private final int maxWidth; private final int maxHeight; + private final float blurRadius; - @Nullable private Bitmap bitmap; - @Nullable private Bitmap blurredBitmap; - @Nullable private Paint blurPaint; + @Nullable private Bitmap bitmap; + @Nullable private Bitmap blurredBitmap; + @Nullable private Paint blurPaint; - UriGlideRenderer(@NonNull Uri imageUri, boolean decryptable, int maxWidth, int maxHeight) { + public UriGlideRenderer(@NonNull Uri imageUri, boolean decryptable, int maxWidth, int maxHeight) { + this(imageUri, decryptable, maxWidth, maxHeight, STRONG_BLUR); + } + + public UriGlideRenderer(@NonNull Uri imageUri, boolean decryptable, int maxWidth, int maxHeight, float blurRadius) { this.imageUri = imageUri; this.decryptable = decryptable; this.maxWidth = maxWidth; this.maxHeight = maxHeight; + this.blurRadius = blurRadius; paint.setAntiAlias(true); paint.setFilterBitmap(true); paint.setDither(true); @@ -148,7 +157,7 @@ final class UriGlideRenderer implements Renderer { blurPaint.setMaskFilter(null); if (blurredBitmap == null) { - blurredBitmap = blur(bitmap, rendererContext.context); + blurredBitmap = blur(bitmap, rendererContext.context, blurRadius); blurScaleMatrix.setRectToRect(new RectF(0, 0, blurredBitmap.getWidth(), blurredBitmap.getHeight()), new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight()), @@ -235,7 +244,7 @@ final class UriGlideRenderer implements Renderer { return matrix; } - private static @NonNull Bitmap blur(Bitmap bitmap, Context context) { + private static @NonNull Bitmap blur(Bitmap bitmap, Context context, float blurRadius) { Point previewSize = scaleKeepingAspectRatio(new Point(bitmap.getWidth(), bitmap.getHeight()), PREVIEW_DIMENSION_LIMIT); Point blurSize = scaleKeepingAspectRatio(new Point(previewSize.x / 2, previewSize.y / 2 ), MAX_BLUR_DIMENSION); Bitmap small = BitmapUtil.createScaledBitmap(bitmap, blurSize.x, blurSize.y); @@ -247,7 +256,7 @@ final class UriGlideRenderer implements Renderer { Allocation output = Allocation.createTyped(rs, input.getType()); ScriptIntrinsicBlur script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); - script.setRadius(25f); + script.setRadius(blurRadius); script.setInput(input); script.forEach(output); @@ -283,7 +292,8 @@ final class UriGlideRenderer implements Renderer { return new UriGlideRenderer(Uri.parse(in.readString()), in.readInt() == 1, in.readInt(), - in.readInt() + in.readInt(), + in.readFloat() ); } @@ -304,5 +314,6 @@ final class UriGlideRenderer implements Renderer { dest.writeInt(decryptable ? 1 : 0); dest.writeInt(maxWidth); dest.writeInt(maxHeight); + dest.writeFloat(blurRadius); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java index cc2e74a075..fba9971be7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BitmapUtil.java @@ -10,6 +10,7 @@ import android.graphics.Rect; import android.graphics.YuvImage; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; +import android.os.Build; import android.util.Pair; import androidx.annotation.NonNull; @@ -267,6 +268,17 @@ public class BitmapUtil { return stream.toByteArray(); } + public static @Nullable byte[] toWebPByteArray(@Nullable Bitmap bitmap) { + if (bitmap == null) return null; + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + if (Build.VERSION.SDK_INT >= 30) { + bitmap.compress(CompressFormat.WEBP_LOSSLESS, 100, stream); + } else { + bitmap.compress(CompressFormat.WEBP, 100, stream); + } + return stream.toByteArray(); + } + public static @Nullable Bitmap fromByteArray(@Nullable byte[] bytes) { if (bytes == null) return null; return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java index 30b91d885e..86b4667bb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperSelectionFragment.java @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.wallpaper; import android.app.Activity; import android.content.Intent; -import android.net.Uri; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -20,11 +19,11 @@ import com.google.android.flexbox.JustifyContent; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.util.ActivityTransitionUtil; +import org.thoughtcrime.securesms.wallpaper.crop.WallpaperImageSelectionActivity; public class ChatWallpaperSelectionFragment extends Fragment { - private static final short CHOOSE_PHOTO = 1; - private static final short PREVIEW = 2; + private static final short CHOOSE_WALLPAPER = 1; private ChatWallpaperViewModel viewModel; @@ -40,13 +39,12 @@ public class ChatWallpaperSelectionFragment extends Fragment { FlexboxLayoutManager flexboxLayoutManager = new FlexboxLayoutManager(requireContext()); chooseFromPhotos.setOnClickListener(unused -> { - // Navigate to photo selection (akin to what we did for profile avatar selection.) - //startActivityForResult(..., CHOOSE_PHOTO); + startActivityForResult(WallpaperImageSelectionActivity.getIntent(requireContext(), viewModel.getRecipientId()), CHOOSE_WALLPAPER); }); @SuppressWarnings("CodeBlock2Expr") ChatWallpaperSelectionAdapter adapter = new ChatWallpaperSelectionAdapter(chatWallpaper -> { - startActivityForResult(ChatWallpaperPreviewActivity.create(requireActivity(), chatWallpaper, viewModel.getDimInDarkTheme().getValue()), PREVIEW); + startActivityForResult(ChatWallpaperPreviewActivity.create(requireActivity(), chatWallpaper, viewModel.getDimInDarkTheme().getValue()), CHOOSE_WALLPAPER); ActivityTransitionUtil.setSlideInTransition(requireActivity()); }); @@ -60,17 +58,7 @@ public class ChatWallpaperSelectionFragment extends Fragment { @Override public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (requestCode == CHOOSE_PHOTO && resultCode == Activity.RESULT_OK && data != null) { - Uri uri = data.getData(); - if (uri == null || uri == Uri.EMPTY) { - throw new AssertionError("Should never have an empty uri."); - } else { - ChatWallpaper wallpaper = ChatWallpaperFactory.create(uri); - viewModel.setWallpaper(wallpaper); - viewModel.saveWallpaperSelection(); - Navigation.findNavController(requireView()).popBackStack(); - } - } else if (requestCode == PREVIEW && resultCode == Activity.RESULT_OK && data != null) { + if (requestCode == CHOOSE_WALLPAPER && resultCode == Activity.RESULT_OK && data != null) { ChatWallpaper chatWallpaper = data.getParcelableExtra(ChatWallpaperPreviewActivity.EXTRA_CHAT_WALLPAPER); viewModel.setWallpaper(chatWallpaper); viewModel.saveWallpaperSelection(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java index 5b41df04f1..3d87445bd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java @@ -1,19 +1,30 @@ package org.thoughtcrime.securesms.wallpaper; +import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; import android.widget.ImageView; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.Target; + +import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; import org.thoughtcrime.securesms.mms.GlideApp; import java.util.Objects; final class UriChatWallpaper implements ChatWallpaper, Parcelable { + private static final String TAG = Log.tag(UriChatWallpaper.class); + private final Uri uri; private final float dimLevelInDarkTheme; @@ -30,7 +41,20 @@ final class UriChatWallpaper implements ChatWallpaper, Parcelable { @Override public void loadInto(@NonNull ImageView imageView) { GlideApp.with(imageView) - .load(uri) + .load(new DecryptableStreamUriLoader.DecryptableUri(uri)) + .addListener(new RequestListener() { + @Override + public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + Log.w(TAG, "Failed to load wallpaper " + uri); + return false; + } + + @Override + public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { + Log.i(TAG, "Loaded wallpaper " + uri); + return false; + } + }) .into(imageView); } @@ -59,7 +83,7 @@ final class UriChatWallpaper implements ChatWallpaper, Parcelable { if (o == null || getClass() != o.getClass()) return false; UriChatWallpaper that = (UriChatWallpaper) o; return Float.compare(that.dimLevelInDarkTheme, dimLevelInDarkTheme) == 0 && - uri.equals(that.uri); + uri.equals(that.uri); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropActivity.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropActivity.java new file mode 100644 index 0000000000..ba92814b59 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropActivity.java @@ -0,0 +1,210 @@ +package org.thoughtcrime.securesms.wallpaper.crop; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.graphics.PorterDuff; +import android.net.Uri; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.view.MenuItem; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.SwitchCompat; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProviders; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BaseActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.ImageEditorView; +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.imageeditor.renderers.FaceBlurRenderer; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.scribbles.UriGlideRenderer; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperPreviewActivity; + +import java.util.Locale; +import java.util.Objects; + +public final class WallpaperCropActivity extends BaseActivity { + + private static final String TAG = Log.tag(WallpaperCropActivity.class); + + private static final String EXTRA_RECIPIENT_ID = "RECIPIENT_ID"; + private static final String EXTRA_IMAGE_URI = "IMAGE_URI"; + + private final DynamicTheme dynamicTheme = new DynamicWallpaperTheme(); + + private ImageEditorView imageEditor; + private WallpaperCropViewModel viewModel; + + public static Intent newIntent(@NonNull Context context, + @Nullable RecipientId recipientId, + @NonNull Uri imageUri) + { + Intent intent = new Intent(context, WallpaperCropActivity.class); + intent.putExtra(EXTRA_RECIPIENT_ID, recipientId); + intent.putExtra(EXTRA_IMAGE_URI, Objects.requireNonNull(imageUri)); + return intent; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + dynamicTheme.onCreate(this); + setContentView(R.layout.chat_wallpaper_crop_activity); + + RecipientId recipientId = getIntent().getParcelableExtra(EXTRA_RECIPIENT_ID); + Uri inputImage = Objects.requireNonNull(getIntent().getParcelableExtra(EXTRA_IMAGE_URI)); + + Log.i(TAG, "Cropping wallpaper for " + (recipientId == null ? "default wallpaper" : recipientId)); + + WallpaperCropViewModel.Factory factory = new WallpaperCropViewModel.Factory(recipientId); + viewModel = ViewModelProviders.of(this, factory).get(WallpaperCropViewModel.class); + + imageEditor = findViewById(R.id.image_editor); + View receivedBubble = findViewById(R.id.preview_bubble_1); + TextView bubble2Text = findViewById(R.id.chat_wallpaper_bubble2_text); + View setWallPaper = findViewById(R.id.preview_set_wallpaper); + SwitchCompat blur = findViewById(R.id.preview_blur); + + setupImageEditor(inputImage); + + setWallPaper.setOnClickListener(v -> setWallpaper()); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + ActionBar supportActionBar = Objects.requireNonNull(getSupportActionBar()); + supportActionBar.setHomeAsUpIndicator(ContextCompat.getDrawable(this, R.drawable.ic_arrow_left_24)); + supportActionBar.setDisplayHomeAsUpEnabled(true); + + blur.setOnCheckedChangeListener((v, checked) -> viewModel.setBlur(checked)); + + viewModel.getBlur() + .observe(this, blurred -> { + setBlurred(blurred); + if (blurred != blur.isChecked()) { + blur.setChecked(blurred); + } + }); + + viewModel.getRecipient() + .observe(this, r -> { + if (r.getId().isUnknown()) { + bubble2Text.setText(R.string.WallpaperCropActivity__set_wallpaper_for_all_chats); + } else { + bubble2Text.setText(getString(R.string.WallpaperCropActivity__set_wallpaper_for_s, r.getDisplayName(this))); + receivedBubble.getBackground().setColorFilter(r.getColor().toConversationColor(this), PorterDuff.Mode.SRC_IN); + } + }); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (super.onOptionsItemSelected(item)) { + return true; + } + + int itemId = item.getItemId(); + + if (itemId == android.R.id.home) { + finish(); + return true; + } + + return false; + } + + private void setWallpaper() { + EditorModel model = imageEditor.getModel(); + + Point size = new Point(imageEditor.getWidth(), imageEditor.getHeight()); + + AlertDialog dialog = SimpleProgressDialog.show(this); + viewModel.render(this, model, size, + new AsynchronousCallback.MainThread() { + @Override public void onComplete(@Nullable ChatWallpaper result) { + dialog.dismiss(); + setResult(RESULT_OK, new Intent().putExtra(ChatWallpaperPreviewActivity.EXTRA_CHAT_WALLPAPER, result)); + finish(); + } + + @Override public void onError(@Nullable WallpaperCropViewModel.Error error) { + dialog.dismiss(); + Toast.makeText(WallpaperCropActivity.this, R.string.WallpaperCropActivity__error_setting_wallpaper, Toast.LENGTH_SHORT).show(); + } + }.toWorkerCallback()); + } + + private void setupImageEditor(@NonNull Uri imageUri) { + DisplayMetrics displayMetrics = new DisplayMetrics(); + getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); + int height = displayMetrics.heightPixels; + int width = displayMetrics.widthPixels; + float ratio = width / (float) height; + + EditorModel editorModel = EditorModel.createForWallpaperEditing(ratio); + + EditorElement image = new EditorElement(new UriGlideRenderer(imageUri, true, width, height, UriGlideRenderer.WEAK_BLUR)); + image.getFlags() + .setSelectable(false) + .persist(); + + editorModel.addElement(image); + + imageEditor.setModel(editorModel); + + imageEditor.setSizeChangedListener((newWidth, newHeight) -> { + float newRatio = newWidth / (float) newHeight; + Log.i(TAG, String.format(Locale.US, "Output size (%d, %d) (ratio %.2f)", newWidth, newHeight, newRatio)); + + editorModel.setFixedRatio(newRatio); + }); + } + + private void setBlurred(boolean blurred) { + imageEditor.getModel().clearFaceRenderers(); + + if (blurred) { + EditorElement mainImage = imageEditor.getModel().getMainImage(); + + if (mainImage != null) { + EditorElement element = new EditorElement(new FaceBlurRenderer(), EditorModel.Z_MASK); + + element.getFlags() + .setEditable(false) + .setSelectable(false) + .persist(); + + mainImage.addElement(element); + imageEditor.invalidate(); + } + } + } + + private static final class DynamicWallpaperTheme extends DynamicTheme { + protected @StyleRes int getTheme() { + return R.style.Signal_DayNight_WallpaperCropper; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropRepository.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropRepository.java new file mode 100644 index 0000000000..473ddac4a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropRepository.java @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.wallpaper.crop; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.thoughtcrime.securesms.wallpaper.WallpaperStorage; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +final class WallpaperCropRepository { + + private static final String TAG = Log.tag(WallpaperCropRepository.class); + + @Nullable private final RecipientId recipientId; + private final Context context; + + public WallpaperCropRepository(@Nullable RecipientId recipientId) { + this.context = ApplicationDependencies.getApplication(); + this.recipientId = recipientId; + } + + @WorkerThread + @NonNull ChatWallpaper setWallPaper(byte[] bytes) throws IOException { + try (InputStream inputStream = new ByteArrayInputStream(bytes)) { + ChatWallpaper wallpaper = WallpaperStorage.save(context, inputStream); + + if (recipientId != null) { + Log.i(TAG, "Setting image wallpaper for " + recipientId); + DatabaseFactory.getRecipientDatabase(context).setWallpaper(recipientId, wallpaper); + } else { + Log.i(TAG, "Setting image wallpaper for default"); + SignalStore.wallpaper().setWallpaper(context, wallpaper); + } + + return wallpaper; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropViewModel.java new file mode 100644 index 0000000000..656b514adb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperCropViewModel.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.wallpaper.crop; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Point; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.AsynchronousCallback; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; + +import java.io.IOException; +import java.util.Objects; + +final class WallpaperCropViewModel extends ViewModel { + + private static final String TAG = Log.tag(WallpaperCropViewModel.class); + + private final @NonNull WallpaperCropRepository repository; + private final @NonNull MutableLiveData blur; + private final @NonNull LiveData recipient; + + public WallpaperCropViewModel(@Nullable RecipientId recipientId, + @NonNull WallpaperCropRepository repository) + { + this.repository = repository; + this.blur = new MutableLiveData<>(false); + this.recipient = recipientId != null ? Recipient.live(recipientId).getLiveData() : LiveDataUtil.just(Recipient.UNKNOWN); + } + + void render(@NonNull Context context, + @NonNull EditorModel model, + @NonNull Point size, + @NonNull AsynchronousCallback.WorkerThread callback) + { + SignalExecutors.BOUNDED.execute( + () -> { + Bitmap bitmap = model.render(context, size); + try { + ChatWallpaper chatWallpaper = repository.setWallPaper(BitmapUtil.toWebPByteArray(bitmap)); + callback.onComplete(chatWallpaper); + } catch (IOException e) { + Log.w(TAG, e); + callback.onError(Error.SAVING); + } finally { + bitmap.recycle(); + } + }); + } + + LiveData getBlur() { + return Transformations.distinctUntilChanged(blur); + } + + LiveData getRecipient() { + return recipient; + } + + @MainThread + void setBlur(boolean blur) { + this.blur.setValue(blur); + } + + public static class Factory implements ViewModelProvider.Factory { + + private final RecipientId recipientId; + + public Factory(@Nullable RecipientId recipientId) { + this.recipientId = recipientId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + + WallpaperCropRepository wallpaperCropRepository = new WallpaperCropRepository(recipientId); + + return Objects.requireNonNull(modelClass.cast(new WallpaperCropViewModel(recipientId, wallpaperCropRepository))); + } + } + + enum Error { + SAVING + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperImageSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperImageSelectionActivity.java new file mode 100644 index 0000000000..cc16c1f12c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/crop/WallpaperImageSelectionActivity.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.wallpaper.crop; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mediasend.MediaFolder; +import org.thoughtcrime.securesms.mediasend.MediaPickerFolderFragment; +import org.thoughtcrime.securesms.mediasend.MediaPickerItemFragment; +import org.thoughtcrime.securesms.recipients.RecipientId; + +public final class WallpaperImageSelectionActivity extends AppCompatActivity + implements MediaPickerFolderFragment.Controller, + MediaPickerItemFragment.Controller +{ + private static final String EXTRA_RECIPIENT_ID = "RECIPIENT_ID"; + private static final int CROP = 901; + + public static Intent getIntent(@NonNull Context context, + @Nullable RecipientId recipientId) + { + Intent intent = new Intent(context, WallpaperImageSelectionActivity.class); + intent.putExtra(EXTRA_RECIPIENT_ID, recipientId); + return intent; + } + + @Override + protected void attachBaseContext(@NonNull Context newBase) { + getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES); + super.attachBaseContext(newBase); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.wallpaper_image_selection_activity); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container, MediaPickerFolderFragment.newInstance(getString(R.string.WallpaperImageSelectionActivity__choose_wallpaper_image), true)) + .commit(); + } + + @Override + public void onFolderSelected(@NonNull MediaFolder folder) { + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_container, MediaPickerItemFragment.newInstance(folder.getBucketId(), folder.getTitle(), 1, false, true)) + .addToBackStack(null) + .commit(); + } + + @Override + public void onCameraSelected() { + throw new AssertionError("Unexpected, Camera disabled"); + } + + @Override + public void onMediaSelected(@NonNull Media media) { + startActivityForResult(WallpaperCropActivity.newIntent(this, getRecipientId(), media.getUri()), CROP); + } + + private RecipientId getRecipientId() { + return getIntent().getParcelableExtra(EXTRA_RECIPIENT_ID); + } + + @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == CROP && resultCode == RESULT_OK) { + setResult(RESULT_OK, data); + finish(); + } + } +} diff --git a/app/src/main/res/layout/chat_wallpaper_crop_activity.xml b/app/src/main/res/layout/chat_wallpaper_crop_activity.xml new file mode 100644 index 0000000000..5fa16c4854 --- /dev/null +++ b/app/src/main/res/layout/chat_wallpaper_crop_activity.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/wallpaper_image_selection_activity.xml b/app/src/main/res/layout/wallpaper_image_selection_activity.xml new file mode 100644 index 0000000000..1d249e3e93 --- /dev/null +++ b/app/src/main/res/layout/wallpaper_image_selection_activity.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/values-night/dark_colors.xml b/app/src/main/res/values-night/dark_colors.xml index 741bee1a3e..720bfd392b 100644 --- a/app/src/main/res/values-night/dark_colors.xml +++ b/app/src/main/res/values-night/dark_colors.xml @@ -117,4 +117,6 @@ @color/core_grey_25 @color/core_grey_75 + + @color/transparent_black_60 diff --git a/app/src/main/res/values-night/dark_themes.xml b/app/src/main/res/values-night/dark_themes.xml index 69dba9a75c..89ae92def6 100644 --- a/app/src/main/res/values-night/dark_themes.xml +++ b/app/src/main/res/values-night/dark_themes.xml @@ -15,4 +15,6 @@ + + + +