diff --git a/res/drawable-hdpi/ic_brush_highlight_32.png b/res/drawable-hdpi/ic_brush_highlight_32.png new file mode 100755 index 000000000..96b0c5fbe Binary files /dev/null and b/res/drawable-hdpi/ic_brush_highlight_32.png differ diff --git a/res/drawable-hdpi/ic_brush_marker_32.png b/res/drawable-hdpi/ic_brush_marker_32.png new file mode 100755 index 000000000..8c01ce886 Binary files /dev/null and b/res/drawable-hdpi/ic_brush_marker_32.png differ diff --git a/res/drawable-hdpi/ic_camera_emoji_36.png b/res/drawable-hdpi/ic_camera_emoji_36.png deleted file mode 100644 index 2be395fcc..000000000 Binary files a/res/drawable-hdpi/ic_camera_emoji_36.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_check_circle_32.png b/res/drawable-hdpi/ic_check_circle_32.png new file mode 100755 index 000000000..92516b8d1 Binary files /dev/null and b/res/drawable-hdpi/ic_check_circle_32.png differ diff --git a/res/drawable-hdpi/ic_check_circle_filled_36.png b/res/drawable-hdpi/ic_check_circle_filled_36.png deleted file mode 100644 index e00b6b567..000000000 Binary files a/res/drawable-hdpi/ic_check_circle_filled_36.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_crop_32.png b/res/drawable-hdpi/ic_crop_32.png new file mode 100755 index 000000000..f0b4e36b6 Binary files /dev/null and b/res/drawable-hdpi/ic_crop_32.png differ diff --git a/res/drawable-hdpi/ic_crop_lock_32.png b/res/drawable-hdpi/ic_crop_lock_32.png new file mode 100755 index 000000000..fe2918906 Binary files /dev/null and b/res/drawable-hdpi/ic_crop_lock_32.png differ diff --git a/res/drawable-hdpi/ic_crop_unlock_32.png b/res/drawable-hdpi/ic_crop_unlock_32.png new file mode 100755 index 000000000..574f40cf7 Binary files /dev/null and b/res/drawable-hdpi/ic_crop_unlock_32.png differ diff --git a/res/drawable-hdpi/ic_emoji_32.png b/res/drawable-hdpi/ic_emoji_32.png new file mode 100755 index 000000000..03d760f73 Binary files /dev/null and b/res/drawable-hdpi/ic_emoji_32.png differ diff --git a/res/drawable-hdpi/ic_flip_32.png b/res/drawable-hdpi/ic_flip_32.png new file mode 100755 index 000000000..dec80d998 Binary files /dev/null and b/res/drawable-hdpi/ic_flip_32.png differ diff --git a/res/drawable-hdpi/ic_highlighter_36.png b/res/drawable-hdpi/ic_highlighter_36.png deleted file mode 100644 index 586aa32d0..000000000 Binary files a/res/drawable-hdpi/ic_highlighter_36.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_marker_36.png b/res/drawable-hdpi/ic_marker_36.png deleted file mode 100644 index dde1357bc..000000000 Binary files a/res/drawable-hdpi/ic_marker_36.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_rotate_32.png b/res/drawable-hdpi/ic_rotate_32.png new file mode 100755 index 000000000..e01fe3696 Binary files /dev/null and b/res/drawable-hdpi/ic_rotate_32.png differ diff --git a/res/drawable-hdpi/ic_text_32.png b/res/drawable-hdpi/ic_text_32.png new file mode 100755 index 000000000..cc8bffca7 Binary files /dev/null and b/res/drawable-hdpi/ic_text_32.png differ diff --git a/res/drawable-hdpi/ic_text_36.png b/res/drawable-hdpi/ic_text_36.png deleted file mode 100644 index 5ad62dace..000000000 Binary files a/res/drawable-hdpi/ic_text_36.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_trash_filled_32.png b/res/drawable-hdpi/ic_trash_filled_32.png new file mode 100755 index 000000000..586af53d0 Binary files /dev/null and b/res/drawable-hdpi/ic_trash_filled_32.png differ diff --git a/res/drawable-hdpi/ic_trash_outline_36.png b/res/drawable-hdpi/ic_trash_outline_36.png deleted file mode 100644 index 0cc7d2bd5..000000000 Binary files a/res/drawable-hdpi/ic_trash_outline_36.png and /dev/null differ diff --git a/res/drawable-hdpi/ic_undo_32.png b/res/drawable-hdpi/ic_undo_32.png new file mode 100755 index 000000000..8843cd0ab Binary files /dev/null and b/res/drawable-hdpi/ic_undo_32.png differ diff --git a/res/drawable-hdpi/ic_undo_36.png b/res/drawable-hdpi/ic_undo_36.png deleted file mode 100644 index 6b5ff3ee6..000000000 Binary files a/res/drawable-hdpi/ic_undo_36.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_brush_highlight_32.png b/res/drawable-mdpi/ic_brush_highlight_32.png new file mode 100755 index 000000000..723199fb1 Binary files /dev/null and b/res/drawable-mdpi/ic_brush_highlight_32.png differ diff --git a/res/drawable-mdpi/ic_brush_marker_32.png b/res/drawable-mdpi/ic_brush_marker_32.png new file mode 100755 index 000000000..168381447 Binary files /dev/null and b/res/drawable-mdpi/ic_brush_marker_32.png differ diff --git a/res/drawable-mdpi/ic_camera_emoji_36.png b/res/drawable-mdpi/ic_camera_emoji_36.png deleted file mode 100644 index 05d680eee..000000000 Binary files a/res/drawable-mdpi/ic_camera_emoji_36.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_check_circle_32.png b/res/drawable-mdpi/ic_check_circle_32.png new file mode 100755 index 000000000..bfb3eb033 Binary files /dev/null and b/res/drawable-mdpi/ic_check_circle_32.png differ diff --git a/res/drawable-mdpi/ic_check_circle_filled_36.png b/res/drawable-mdpi/ic_check_circle_filled_36.png deleted file mode 100644 index 0063fcdf6..000000000 Binary files a/res/drawable-mdpi/ic_check_circle_filled_36.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_crop_32.png b/res/drawable-mdpi/ic_crop_32.png new file mode 100755 index 000000000..1b57301a1 Binary files /dev/null and b/res/drawable-mdpi/ic_crop_32.png differ diff --git a/res/drawable-mdpi/ic_crop_lock_32.png b/res/drawable-mdpi/ic_crop_lock_32.png new file mode 100755 index 000000000..4170c434e Binary files /dev/null and b/res/drawable-mdpi/ic_crop_lock_32.png differ diff --git a/res/drawable-mdpi/ic_crop_unlock_32.png b/res/drawable-mdpi/ic_crop_unlock_32.png new file mode 100755 index 000000000..4422a0d14 Binary files /dev/null and b/res/drawable-mdpi/ic_crop_unlock_32.png differ diff --git a/res/drawable-mdpi/ic_emoji_32.png b/res/drawable-mdpi/ic_emoji_32.png new file mode 100755 index 000000000..34df7aee8 Binary files /dev/null and b/res/drawable-mdpi/ic_emoji_32.png differ diff --git a/res/drawable-mdpi/ic_flip_32.png b/res/drawable-mdpi/ic_flip_32.png new file mode 100755 index 000000000..53d441235 Binary files /dev/null and b/res/drawable-mdpi/ic_flip_32.png differ diff --git a/res/drawable-mdpi/ic_highlighter_36.png b/res/drawable-mdpi/ic_highlighter_36.png deleted file mode 100644 index 1cef04196..000000000 Binary files a/res/drawable-mdpi/ic_highlighter_36.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_marker_36.png b/res/drawable-mdpi/ic_marker_36.png deleted file mode 100644 index 89fe303da..000000000 Binary files a/res/drawable-mdpi/ic_marker_36.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_rotate_32.png b/res/drawable-mdpi/ic_rotate_32.png new file mode 100755 index 000000000..8c92376b0 Binary files /dev/null and b/res/drawable-mdpi/ic_rotate_32.png differ diff --git a/res/drawable-mdpi/ic_text_32.png b/res/drawable-mdpi/ic_text_32.png new file mode 100755 index 000000000..17b40ed2c Binary files /dev/null and b/res/drawable-mdpi/ic_text_32.png differ diff --git a/res/drawable-mdpi/ic_text_36.png b/res/drawable-mdpi/ic_text_36.png deleted file mode 100644 index 5f04230da..000000000 Binary files a/res/drawable-mdpi/ic_text_36.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_trash_filled_32.png b/res/drawable-mdpi/ic_trash_filled_32.png new file mode 100755 index 000000000..0b1351e1c Binary files /dev/null and b/res/drawable-mdpi/ic_trash_filled_32.png differ diff --git a/res/drawable-mdpi/ic_trash_outline_36.png b/res/drawable-mdpi/ic_trash_outline_36.png deleted file mode 100644 index e14b9b2ee..000000000 Binary files a/res/drawable-mdpi/ic_trash_outline_36.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_undo_32.png b/res/drawable-mdpi/ic_undo_32.png new file mode 100755 index 000000000..badf4d708 Binary files /dev/null and b/res/drawable-mdpi/ic_undo_32.png differ diff --git a/res/drawable-mdpi/ic_undo_36.png b/res/drawable-mdpi/ic_undo_36.png deleted file mode 100644 index 5e76ac033..000000000 Binary files a/res/drawable-mdpi/ic_undo_36.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_brush_highlight_32.png b/res/drawable-xhdpi/ic_brush_highlight_32.png new file mode 100755 index 000000000..decc74514 Binary files /dev/null and b/res/drawable-xhdpi/ic_brush_highlight_32.png differ diff --git a/res/drawable-xhdpi/ic_brush_marker_32.png b/res/drawable-xhdpi/ic_brush_marker_32.png new file mode 100755 index 000000000..77a8d596b Binary files /dev/null and b/res/drawable-xhdpi/ic_brush_marker_32.png differ diff --git a/res/drawable-xhdpi/ic_camera_emoji_36.png b/res/drawable-xhdpi/ic_camera_emoji_36.png deleted file mode 100644 index 7ab20d07a..000000000 Binary files a/res/drawable-xhdpi/ic_camera_emoji_36.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_check_circle_32.png b/res/drawable-xhdpi/ic_check_circle_32.png new file mode 100755 index 000000000..cb4f9bd36 Binary files /dev/null and b/res/drawable-xhdpi/ic_check_circle_32.png differ diff --git a/res/drawable-xhdpi/ic_check_circle_filled_36.png b/res/drawable-xhdpi/ic_check_circle_filled_36.png deleted file mode 100644 index 625c7e901..000000000 Binary files a/res/drawable-xhdpi/ic_check_circle_filled_36.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_crop_32.png b/res/drawable-xhdpi/ic_crop_32.png new file mode 100755 index 000000000..ebddc2d9b Binary files /dev/null and b/res/drawable-xhdpi/ic_crop_32.png differ diff --git a/res/drawable-xhdpi/ic_crop_lock_32.png b/res/drawable-xhdpi/ic_crop_lock_32.png new file mode 100755 index 000000000..c0f738053 Binary files /dev/null and b/res/drawable-xhdpi/ic_crop_lock_32.png differ diff --git a/res/drawable-xhdpi/ic_crop_unlock_32.png b/res/drawable-xhdpi/ic_crop_unlock_32.png new file mode 100755 index 000000000..476a76868 Binary files /dev/null and b/res/drawable-xhdpi/ic_crop_unlock_32.png differ diff --git a/res/drawable-xhdpi/ic_emoji_32.png b/res/drawable-xhdpi/ic_emoji_32.png new file mode 100755 index 000000000..04536082e Binary files /dev/null and b/res/drawable-xhdpi/ic_emoji_32.png differ diff --git a/res/drawable-xhdpi/ic_flip_32.png b/res/drawable-xhdpi/ic_flip_32.png new file mode 100755 index 000000000..ad44000f3 Binary files /dev/null and b/res/drawable-xhdpi/ic_flip_32.png differ diff --git a/res/drawable-xhdpi/ic_highlighter_36.png b/res/drawable-xhdpi/ic_highlighter_36.png deleted file mode 100644 index 40bc10a95..000000000 Binary files a/res/drawable-xhdpi/ic_highlighter_36.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_marker_36.png b/res/drawable-xhdpi/ic_marker_36.png deleted file mode 100644 index cff310723..000000000 Binary files a/res/drawable-xhdpi/ic_marker_36.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_rotate_32.png b/res/drawable-xhdpi/ic_rotate_32.png new file mode 100755 index 000000000..319ab8605 Binary files /dev/null and b/res/drawable-xhdpi/ic_rotate_32.png differ diff --git a/res/drawable-xhdpi/ic_text_32.png b/res/drawable-xhdpi/ic_text_32.png new file mode 100755 index 000000000..368f7282a Binary files /dev/null and b/res/drawable-xhdpi/ic_text_32.png differ diff --git a/res/drawable-xhdpi/ic_text_36.png b/res/drawable-xhdpi/ic_text_36.png deleted file mode 100644 index b9bbe7fdd..000000000 Binary files a/res/drawable-xhdpi/ic_text_36.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_trash_filled_32.png b/res/drawable-xhdpi/ic_trash_filled_32.png new file mode 100755 index 000000000..3d9e02a1f Binary files /dev/null and b/res/drawable-xhdpi/ic_trash_filled_32.png differ diff --git a/res/drawable-xhdpi/ic_trash_outline_36.png b/res/drawable-xhdpi/ic_trash_outline_36.png deleted file mode 100644 index 1721c329a..000000000 Binary files a/res/drawable-xhdpi/ic_trash_outline_36.png and /dev/null differ diff --git a/res/drawable-xhdpi/ic_undo_32.png b/res/drawable-xhdpi/ic_undo_32.png new file mode 100755 index 000000000..3674cff17 Binary files /dev/null and b/res/drawable-xhdpi/ic_undo_32.png differ diff --git a/res/drawable-xhdpi/ic_undo_36.png b/res/drawable-xhdpi/ic_undo_36.png deleted file mode 100644 index 8d173d16e..000000000 Binary files a/res/drawable-xhdpi/ic_undo_36.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_brush_highlight_32.png b/res/drawable-xxhdpi/ic_brush_highlight_32.png new file mode 100755 index 000000000..3b21d3b32 Binary files /dev/null and b/res/drawable-xxhdpi/ic_brush_highlight_32.png differ diff --git a/res/drawable-xxhdpi/ic_brush_marker_32.png b/res/drawable-xxhdpi/ic_brush_marker_32.png new file mode 100755 index 000000000..8e90632c3 Binary files /dev/null and b/res/drawable-xxhdpi/ic_brush_marker_32.png differ diff --git a/res/drawable-xxhdpi/ic_camera_emoji_36.png b/res/drawable-xxhdpi/ic_camera_emoji_36.png deleted file mode 100644 index 34d9c6a4a..000000000 Binary files a/res/drawable-xxhdpi/ic_camera_emoji_36.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_check_circle_32.png b/res/drawable-xxhdpi/ic_check_circle_32.png new file mode 100755 index 000000000..9e669e931 Binary files /dev/null and b/res/drawable-xxhdpi/ic_check_circle_32.png differ diff --git a/res/drawable-xxhdpi/ic_check_circle_filled_36.png b/res/drawable-xxhdpi/ic_check_circle_filled_36.png deleted file mode 100644 index 69c728f05..000000000 Binary files a/res/drawable-xxhdpi/ic_check_circle_filled_36.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_crop_32.png b/res/drawable-xxhdpi/ic_crop_32.png new file mode 100755 index 000000000..c69c5c049 Binary files /dev/null and b/res/drawable-xxhdpi/ic_crop_32.png differ diff --git a/res/drawable-xxhdpi/ic_crop_lock_32.png b/res/drawable-xxhdpi/ic_crop_lock_32.png new file mode 100755 index 000000000..a86488e13 Binary files /dev/null and b/res/drawable-xxhdpi/ic_crop_lock_32.png differ diff --git a/res/drawable-xxhdpi/ic_crop_unlock_32.png b/res/drawable-xxhdpi/ic_crop_unlock_32.png new file mode 100755 index 000000000..8a5cc3d8c Binary files /dev/null and b/res/drawable-xxhdpi/ic_crop_unlock_32.png differ diff --git a/res/drawable-xxhdpi/ic_emoji_32.png b/res/drawable-xxhdpi/ic_emoji_32.png new file mode 100755 index 000000000..d9dba7da9 Binary files /dev/null and b/res/drawable-xxhdpi/ic_emoji_32.png differ diff --git a/res/drawable-xxhdpi/ic_flip_32.png b/res/drawable-xxhdpi/ic_flip_32.png new file mode 100755 index 000000000..79bb2b30b Binary files /dev/null and b/res/drawable-xxhdpi/ic_flip_32.png differ diff --git a/res/drawable-xxhdpi/ic_highlighter_36.png b/res/drawable-xxhdpi/ic_highlighter_36.png deleted file mode 100644 index c67056bc4..000000000 Binary files a/res/drawable-xxhdpi/ic_highlighter_36.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_marker_36.png b/res/drawable-xxhdpi/ic_marker_36.png deleted file mode 100644 index 388479091..000000000 Binary files a/res/drawable-xxhdpi/ic_marker_36.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_rotate_32.png b/res/drawable-xxhdpi/ic_rotate_32.png new file mode 100755 index 000000000..d13035a14 Binary files /dev/null and b/res/drawable-xxhdpi/ic_rotate_32.png differ diff --git a/res/drawable-xxhdpi/ic_text_32.png b/res/drawable-xxhdpi/ic_text_32.png new file mode 100755 index 000000000..4dd242e40 Binary files /dev/null and b/res/drawable-xxhdpi/ic_text_32.png differ diff --git a/res/drawable-xxhdpi/ic_text_36.png b/res/drawable-xxhdpi/ic_text_36.png deleted file mode 100644 index 22cbfb82e..000000000 Binary files a/res/drawable-xxhdpi/ic_text_36.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_trash_filled_32.png b/res/drawable-xxhdpi/ic_trash_filled_32.png new file mode 100755 index 000000000..6a4ccab1b Binary files /dev/null and b/res/drawable-xxhdpi/ic_trash_filled_32.png differ diff --git a/res/drawable-xxhdpi/ic_trash_outline_36.png b/res/drawable-xxhdpi/ic_trash_outline_36.png deleted file mode 100644 index 2688b6cf5..000000000 Binary files a/res/drawable-xxhdpi/ic_trash_outline_36.png and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_undo_32.png b/res/drawable-xxhdpi/ic_undo_32.png new file mode 100755 index 000000000..372fe355d Binary files /dev/null and b/res/drawable-xxhdpi/ic_undo_32.png differ diff --git a/res/drawable-xxhdpi/ic_undo_36.png b/res/drawable-xxhdpi/ic_undo_36.png deleted file mode 100644 index b5877cfcb..000000000 Binary files a/res/drawable-xxhdpi/ic_undo_36.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_brush_highlight_32.png b/res/drawable-xxxhdpi/ic_brush_highlight_32.png new file mode 100755 index 000000000..331b5615d Binary files /dev/null and b/res/drawable-xxxhdpi/ic_brush_highlight_32.png differ diff --git a/res/drawable-xxxhdpi/ic_brush_marker_32.png b/res/drawable-xxxhdpi/ic_brush_marker_32.png new file mode 100755 index 000000000..9e52f0f45 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_brush_marker_32.png differ diff --git a/res/drawable-xxxhdpi/ic_camera_emoji_36.png b/res/drawable-xxxhdpi/ic_camera_emoji_36.png deleted file mode 100644 index 5bdb14b6e..000000000 Binary files a/res/drawable-xxxhdpi/ic_camera_emoji_36.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_check_circle_32.png b/res/drawable-xxxhdpi/ic_check_circle_32.png new file mode 100755 index 000000000..8241bd663 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_check_circle_32.png differ diff --git a/res/drawable-xxxhdpi/ic_check_circle_filled_36.png b/res/drawable-xxxhdpi/ic_check_circle_filled_36.png deleted file mode 100644 index 2edd336b3..000000000 Binary files a/res/drawable-xxxhdpi/ic_check_circle_filled_36.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_crop_32.png b/res/drawable-xxxhdpi/ic_crop_32.png new file mode 100755 index 000000000..80c85cc5e Binary files /dev/null and b/res/drawable-xxxhdpi/ic_crop_32.png differ diff --git a/res/drawable-xxxhdpi/ic_crop_lock_32.png b/res/drawable-xxxhdpi/ic_crop_lock_32.png new file mode 100755 index 000000000..934d2883f Binary files /dev/null and b/res/drawable-xxxhdpi/ic_crop_lock_32.png differ diff --git a/res/drawable-xxxhdpi/ic_crop_unlock_32.png b/res/drawable-xxxhdpi/ic_crop_unlock_32.png new file mode 100755 index 000000000..6a3e084b3 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_crop_unlock_32.png differ diff --git a/res/drawable-xxxhdpi/ic_emoji_32.png b/res/drawable-xxxhdpi/ic_emoji_32.png new file mode 100755 index 000000000..2d5d626fe Binary files /dev/null and b/res/drawable-xxxhdpi/ic_emoji_32.png differ diff --git a/res/drawable-xxxhdpi/ic_flip_32.png b/res/drawable-xxxhdpi/ic_flip_32.png new file mode 100755 index 000000000..8e86977d6 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_flip_32.png differ diff --git a/res/drawable-xxxhdpi/ic_highlighter_36.png b/res/drawable-xxxhdpi/ic_highlighter_36.png deleted file mode 100644 index 296e2b93e..000000000 Binary files a/res/drawable-xxxhdpi/ic_highlighter_36.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_marker_36.png b/res/drawable-xxxhdpi/ic_marker_36.png deleted file mode 100644 index 529b07b71..000000000 Binary files a/res/drawable-xxxhdpi/ic_marker_36.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_rotate_32.png b/res/drawable-xxxhdpi/ic_rotate_32.png new file mode 100755 index 000000000..c66cf2b23 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_rotate_32.png differ diff --git a/res/drawable-xxxhdpi/ic_text_32.png b/res/drawable-xxxhdpi/ic_text_32.png new file mode 100755 index 000000000..b4bbdebff Binary files /dev/null and b/res/drawable-xxxhdpi/ic_text_32.png differ diff --git a/res/drawable-xxxhdpi/ic_text_36.png b/res/drawable-xxxhdpi/ic_text_36.png deleted file mode 100644 index b1f4eed53..000000000 Binary files a/res/drawable-xxxhdpi/ic_text_36.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_trash_filled_32.png b/res/drawable-xxxhdpi/ic_trash_filled_32.png new file mode 100755 index 000000000..7ddfecdad Binary files /dev/null and b/res/drawable-xxxhdpi/ic_trash_filled_32.png differ diff --git a/res/drawable-xxxhdpi/ic_trash_outline_36.png b/res/drawable-xxxhdpi/ic_trash_outline_36.png deleted file mode 100644 index 2fccd17e7..000000000 Binary files a/res/drawable-xxxhdpi/ic_trash_outline_36.png and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_undo_32.png b/res/drawable-xxxhdpi/ic_undo_32.png new file mode 100755 index 000000000..656c45b39 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_undo_32.png differ diff --git a/res/drawable-xxxhdpi/ic_undo_36.png b/res/drawable-xxxhdpi/ic_undo_36.png deleted file mode 100644 index 44300d0f5..000000000 Binary files a/res/drawable-xxxhdpi/ic_undo_36.png and /dev/null differ diff --git a/res/layout/scribble_fragment.xml b/res/layout/image_editor_fragment.xml similarity index 50% rename from res/layout/scribble_fragment.xml rename to res/layout/image_editor_fragment.xml index 2f42b4893..f3935cadc 100644 --- a/res/layout/scribble_fragment.xml +++ b/res/layout/image_editor_fragment.xml @@ -1,20 +1,18 @@ + android:layout_height="match_parent"> - - diff --git a/res/layout/scribble_hud.xml b/res/layout/image_editor_hud.xml similarity index 68% rename from res/layout/scribble_hud.xml rename to res/layout/image_editor_hud.xml index e77a2f5e4..b522492a1 100644 --- a/res/layout/scribble_hud.xml +++ b/res/layout/image_editor_hud.xml @@ -34,7 +34,7 @@ android:layout_height="wrap_content" android:background="?attr/selectableItemBackgroundBorderless" android:padding="8dp" - android:src="@drawable/ic_trash_outline_36" /> + android:src="@drawable/ic_trash_filled_32" /> - - - - + android:src="@drawable/ic_undo_32" /> + android:src="@drawable/ic_text_32" /> + + + + + android:src="@drawable/ic_emoji_32" /> + + + android:src="@drawable/ic_check_circle_32" /> @@ -113,6 +121,36 @@ app:layout_constraintTop_toBottomOf="@+id/scribble_color_picker" app:layout_constraintVertical_bias="0.0" /> + + + + + + \ No newline at end of file diff --git a/res/layout/mediasend_fragment.xml b/res/layout/mediasend_fragment.xml index 7037fb199..97c63cb4b 100644 --- a/res/layout/mediasend_fragment.xml +++ b/res/layout/mediasend_fragment.xml @@ -25,6 +25,7 @@ android:layout_height="match_parent"> - - - diff --git a/res/layout/scribble_view.xml b/res/layout/scribble_view.xml deleted file mode 100644 index 29abcf442..000000000 --- a/res/layout/scribble_view.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/res/values/crop_area_renderer.xml b/res/values/crop_area_renderer.xml new file mode 100644 index 000000000..953c9c04c --- /dev/null +++ b/res/values/crop_area_renderer.xml @@ -0,0 +1,10 @@ + + + + 32dp + 2dp + + #ffffffff + #7f000000 + + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 19dc61e60..8ae1ebe47 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -623,6 +623,8 @@ Failed to save image changes + Signal + No results found for \'%s\' Conversations diff --git a/src/org/thoughtcrime/securesms/imageeditor/Bounds.java b/src/org/thoughtcrime/securesms/imageeditor/Bounds.java new file mode 100644 index 000000000..0b8faf663 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/Bounds.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.RectF; + +/** + * The local extent of a {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement}. + * i.e. all {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement}s have a bounding rectangle from: + *

+ * {@link #LEFT} to {@link #RIGHT} and from {@link #TOP} to {@link #BOTTOM}. + */ +public final class Bounds { + + public static final float LEFT = -1000f; + public static final float RIGHT = 1000f; + + public static final float TOP = -1000f; + public static final float BOTTOM = 1000f; + + public static final float CENTRE_X = (LEFT + RIGHT) / 2f; + public static final float CENTRE_Y = (TOP + BOTTOM) / 2f; + + public static final float[] CENTRE = new float[]{ CENTRE_X, CENTRE_Y }; + + static RectF newFullBounds() { + return new RectF(LEFT, TOP, RIGHT, BOTTOM); + } + + public static RectF FULL_BOUNDS = newFullBounds(); +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/CanvasMatrix.java b/src/org/thoughtcrime/securesms/imageeditor/CanvasMatrix.java new file mode 100644 index 000000000..b3fd5bbd9 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/CanvasMatrix.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.support.annotation.NonNull; + +/** + * Tracks the current matrix for a canvas. + *

+ * This is because you cannot reliably call {@link Canvas#setMatrix(Matrix)}. + * {@link Canvas#getMatrix()} provides this hint in its documentation: + * "track relevant transform state outside of the canvas." + *

+ * To achieve this, any changes to the canvas matrix must be done via this class, including save and + * restore operations where the matrix was altered in between. + */ +public final class CanvasMatrix { + + private final static int STACK_HEIGHT_LIMIT = 16; + + private final Canvas canvas; + private final Matrix canvasMatrix = new Matrix(); + private final Matrix temp = new Matrix(); + private final Matrix[] stack = new Matrix[STACK_HEIGHT_LIMIT]; + private int stackHeight; + + CanvasMatrix(Canvas canvas) { + this.canvas = canvas; + for (int i = 0; i < stack.length; i++) { + stack[i] = new Matrix(); + } + } + + public void concat(@NonNull Matrix matrix) { + canvas.concat(matrix); + canvasMatrix.preConcat(matrix); + } + + void save() { + canvas.save(); + if (stackHeight == STACK_HEIGHT_LIMIT) { + throw new AssertionError("Not enough space on stack"); + } + stack[stackHeight++].set(canvasMatrix); + } + + void restore() { + canvas.restore(); + canvasMatrix.set(stack[--stackHeight]); + } + + void getCurrent(@NonNull Matrix into) { + into.set(canvasMatrix); + } + + public void setToIdentity() { + if (canvasMatrix.invert(temp)) { + concat(temp); + } + } + + public void initial(Matrix viewMatrix) { + concat(viewMatrix); + } + + boolean mapRect(@NonNull RectF dst, @NonNull RectF src) { + return canvasMatrix.mapRect(dst, src); + } + + public void mapPoints(float[] dst, float[] src) { + canvasMatrix.mapPoints(dst, src); + } + + public void copyTo(@NonNull Matrix matrix) { + matrix.set(canvasMatrix); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/ColorableRenderer.java b/src/org/thoughtcrime/securesms/imageeditor/ColorableRenderer.java new file mode 100644 index 000000000..71a159711 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/ColorableRenderer.java @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.support.annotation.ColorInt; + +/** + * A renderer that can have its color changed. + *

+ * For example, Lines and Text can change color. + */ +public interface ColorableRenderer extends Renderer { + + @ColorInt + int getColor(); + + void setColor(@ColorInt int color); +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/DrawingSession.java b/src/org/thoughtcrime/securesms/imageeditor/DrawingSession.java new file mode 100644 index 000000000..72dcaa27b --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/DrawingSession.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; +import android.support.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.renderers.BezierDrawingRenderer; + +/** + * Passes touch events into a {@link BezierDrawingRenderer}. + */ +class DrawingSession extends ElementEditSession { + + private final BezierDrawingRenderer renderer; + + private DrawingSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix, @NonNull BezierDrawingRenderer renderer) { + super(selected, inverseMatrix); + this.renderer = renderer; + } + + public static EditSession start(EditorElement element, BezierDrawingRenderer renderer, Matrix inverseMatrix, PointF point) { + DrawingSession drawingSession = new DrawingSession(element, inverseMatrix, renderer); + drawingSession.setScreenStartPoint(0, point); + renderer.setFirstPoint(drawingSession.startPointElement[0]); + return drawingSession; + } + + @Override + public void movePoint(int p, @NonNull PointF point) { + if (p != 0) return; + setScreenEndPoint(p, point); + renderer.addNewPoint(endPointElement[0]); + } + + @Override + public EditSession newPoint(Matrix newInverse, PointF point, int p) { + return this; + } + + @Override + public EditSession removePoint(Matrix newInverse, int p) { + return this; + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/EditSession.java b/src/org/thoughtcrime/securesms/imageeditor/EditSession.java new file mode 100644 index 000000000..3c79c07b4 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/EditSession.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +/** + * Represents an underway edit of the image. + *

+ * Accepts new touch positions, new touch points, released touch points and when complete can commit the edit. + *

+ * Examples of edit session implementations are, Drag, Draw, Resize: + *

+ * {@link ElementDragEditSession} for dragging with a single finger. + * {@link ElementScaleEditSession} for resize/dragging with two fingers. + * {@link DrawingSession} for drawing with a single finger. + */ +interface EditSession { + + void movePoint(int p, PointF point); + + EditorElement getSelected(); + + EditSession newPoint(Matrix newInverse, PointF point, int p); + + EditSession removePoint(Matrix newInverse, int p); + + void commit(); +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/ElementDragEditSession.java b/src/org/thoughtcrime/securesms/imageeditor/ElementDragEditSession.java new file mode 100644 index 000000000..732175104 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/ElementDragEditSession.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; +import android.support.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +final class ElementDragEditSession extends ElementEditSession { + + private ElementDragEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) { + super(selected, inverseMatrix); + } + + static ElementDragEditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull PointF point) { + if (!selected.getFlags().isEditable()) return null; + + ElementDragEditSession elementDragEditSession = new ElementDragEditSession(selected, inverseViewModelMatrix); + elementDragEditSession.setScreenStartPoint(0, point); + elementDragEditSession.setScreenEndPoint(0, point); + + return elementDragEditSession; + } + + @Override + public void movePoint(int p, @NonNull PointF point) { + setScreenEndPoint(p, point); + + selected.getEditorMatrix() + .setTranslate(endPointElement[0].x - startPointElement[0].x, endPointElement[0].y - startPointElement[0].y); + } + + @Override + public EditSession newPoint(Matrix newInverse, PointF point, int p) { + return ElementScaleEditSession.startScale(this, newInverse, point, p); + } + + @Override + public EditSession removePoint(Matrix newInverse, int p) { + return this; + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/ElementEditSession.java b/src/org/thoughtcrime/securesms/imageeditor/ElementEditSession.java new file mode 100644 index 000000000..cc37ddb58 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/ElementEditSession.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; +import android.support.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +abstract class ElementEditSession implements EditSession { + + private final Matrix inverseMatrix; + + final EditorElement selected; + + final PointF[] startPointElement = newTwoPointArray(); + final PointF[] endPointElement = newTwoPointArray(); + final PointF[] startPointScreen = newTwoPointArray(); + final PointF[] endPointScreen = newTwoPointArray(); + + ElementEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) { + this.selected = selected; + this.inverseMatrix = inverseMatrix; + } + + void setScreenStartPoint(int p, @NonNull PointF point) { + startPointScreen[p] = point; + mapPoint(startPointElement[p], inverseMatrix, point); + } + + void setScreenEndPoint(int p, @NonNull PointF point) { + endPointScreen[p] = point; + mapPoint(endPointElement[p], inverseMatrix, point); + } + + @Override + public abstract void movePoint(int p, @NonNull PointF point); + + @Override + public void commit() { + selected.commitEditorMatrix(); + } + + @Override + public EditorElement getSelected() { + return selected; + } + + private static PointF[] newTwoPointArray() { + PointF[] array = new PointF[2]; + for (int i = 0; i < array.length; i++) { + array[i] = new PointF(); + } + return array; + } + + /** + * Map src to dst using the matrix. + * + * @param dst Output point. + * @param matrix Matrix to transform point with. + * @param src Input point. + */ + static void mapPoint(@NonNull PointF dst, @NonNull Matrix matrix, @NonNull PointF src) { + float[] in = { src.x, src.y }; + float[] out = new float[2]; + matrix.mapPoints(out, in); + dst.set(out[0], out[1]); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/ElementScaleEditSession.java b/src/org/thoughtcrime/securesms/imageeditor/ElementScaleEditSession.java new file mode 100644 index 000000000..95197c1fb --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/ElementScaleEditSession.java @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; +import android.support.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; + +final class ElementScaleEditSession extends ElementEditSession { + + private ElementScaleEditSession(EditorElement selected, Matrix inverseMatrix) { + super(selected, inverseMatrix); + } + + static ElementScaleEditSession startScale(@NonNull ElementDragEditSession session, @NonNull Matrix inverseMatrix, @NonNull PointF point, int p) { + session.commit(); + ElementScaleEditSession newSession = new ElementScaleEditSession(session.selected, inverseMatrix); + newSession.setScreenStartPoint(1 - p, session.endPointScreen[0]); + newSession.setScreenEndPoint(1 - p, session.endPointScreen[0]); + newSession.setScreenStartPoint(p, point); + newSession.setScreenEndPoint(p, point); + return newSession; + } + + @Override + public void movePoint(int p, @NonNull PointF point) { + setScreenEndPoint(p, point); + Matrix editorMatrix = selected.getEditorMatrix(); + + editorMatrix.reset(); + + if (selected.getFlags().isAspectLocked()) { + + float scale = (float) findScale(startPointElement, endPointElement); + + editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y); + editorMatrix.postScale(scale, scale); + + double angle = angle(endPointElement[0], endPointElement[1]) - angle(startPointElement[0], startPointElement[1]); + + if (!selected.getFlags().isRotateLocked()) { + editorMatrix.postRotate((float) Math.toDegrees(angle)); + } + + editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y); + } else { + editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y); + + float scaleX = (endPointElement[1].x - endPointElement[0].x) / (startPointElement[1].x - startPointElement[0].x); + float scaleY = (endPointElement[1].y - endPointElement[0].y) / (startPointElement[1].y - startPointElement[0].y); + + editorMatrix.postScale(scaleX, scaleY); + + editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y); + } + } + + @Override + public EditSession newPoint(Matrix newInverse, PointF point, int p) { + return this; + } + + @Override + public EditSession removePoint(Matrix newInverse, int p) { + return convertToDrag(p, newInverse); + } + + private static double angle(PointF a, PointF b) { + return Math.atan2(a.y - b.y, a.x - b.x); + } + + private ElementDragEditSession convertToDrag(int p, Matrix inverse) { + return ElementDragEditSession.startDrag(selected, inverse, endPointScreen[1 - p]); + } + + /** + * Find relative distance between an old and new set of Points. + * + * @param from Pair of points. + * @param to New pair of points. + * @return Scale + */ + private static double findScale(@NonNull PointF[] from, @NonNull PointF[] to) { + float originalD2 = getDistanceSquared(from[0], from[1]); + float newD2 = getDistanceSquared(to[0], to[1]); + return Math.sqrt(newD2 / originalD2); + } + + /** + * Distance between two points squared. + */ + private static float getDistanceSquared(@NonNull PointF a, @NonNull PointF b) { + float dx = a.x - b.x; + float dy = a.y - b.y; + return dx * dx + dy * dy; + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java b/src/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java new file mode 100644 index 000000000..03e252ab3 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java @@ -0,0 +1,136 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Color; +import android.graphics.Rect; +import android.support.annotation.Nullable; +import android.text.InputType; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.FrameLayout; + +import org.thoughtcrime.securesms.imageeditor.renderers.TextRenderer; + +/** + * Invisible {@link android.widget.EditText} that is used during in-image text editing. + */ +final class HiddenEditText extends android.support.v7.widget.AppCompatEditText { + + @SuppressLint("InlinedApi") + private static final int INCOGNITO_KEYBOARD_IME = EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING; + + @Nullable + private TextRenderer currentTextEntity; + + @Nullable + private Runnable onEndEdit; + + public HiddenEditText(Context context) { + super(context); + setAlpha(0); + setLayoutParams(new FrameLayout.LayoutParams(1, 1, Gravity.TOP | Gravity.START)); + setClickable(false); + setFocusable(true); + setFocusableInTouchMode(true); + setBackgroundColor(Color.TRANSPARENT); + setTextSize(TypedValue.COMPLEX_UNIT_SP, 1); + setInputType(InputType.TYPE_CLASS_TEXT); + setImeOptions(EditorInfo.IME_ACTION_DONE); + clearFocus(); + } + + @Override + protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) { + super.onTextChanged(text, start, lengthBefore, lengthAfter); + if (currentTextEntity != null) { + currentTextEntity.setText(text.toString()); + } + } + + @Override + public void onEditorAction(int actionCode) { + super.onEditorAction(actionCode); + if (actionCode == EditorInfo.IME_ACTION_DONE && currentTextEntity != null) { + currentTextEntity.setFocused(false); + endEdit(); + } + } + + @Override + protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { + super.onFocusChanged(focused, direction, previouslyFocusedRect); + if (currentTextEntity != null) { + currentTextEntity.setFocused(focused); + if (!focused) { + endEdit(); + } + } + } + + private void endEdit() { + if (onEndEdit != null) { + onEndEdit.run(); + } + } + + @Nullable TextRenderer getCurrentTextEntity() { + return currentTextEntity; + } + + void setCurrentTextEntity(@Nullable TextRenderer currentTextEntity) { + if (this.currentTextEntity != currentTextEntity) { + if (this.currentTextEntity != null) { + this.currentTextEntity.setFocused(false); + } + this.currentTextEntity = currentTextEntity; + if (currentTextEntity != null) { + String text = currentTextEntity.getText(); + setText(text); + setSelection(text.length()); + } else { + setText(""); + } + } + } + + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + super.onSelectionChanged(selStart, selEnd); + if (currentTextEntity != null) { + currentTextEntity.setSelection(selStart, selEnd); + } + } + + @Override + public boolean requestFocus(int direction, Rect previouslyFocusedRect) { + boolean focus = super.requestFocus(direction, previouslyFocusedRect); + + if (currentTextEntity != null && focus) { + currentTextEntity.setFocused(true); + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT); + if (!imm.isAcceptingText()) { + imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, InputMethodManager.HIDE_IMPLICIT_ONLY); + } + } + + return focus; + } + + public void hideKeyboard() { + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(getWindowToken(), InputMethodManager.HIDE_IMPLICIT_ONLY); + } + + public void setIncognitoKeyboardEnabled(boolean incognitoKeyboardEnabled) { + setImeOptions(incognitoKeyboardEnabled ? getImeOptions() | INCOGNITO_KEYBOARD_IME + : getImeOptions() & ~INCOGNITO_KEYBOARD_IME); + } + + public void setOnEndEdit(@Nullable Runnable onEndEdit) { + this.onEndEdit = onEndEdit; + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java b/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java new file mode 100644 index 000000000..7b746667f --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java @@ -0,0 +1,426 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.support.annotation.ColorInt; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.view.GestureDetectorCompat; +import android.util.AttributeSet; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.widget.FrameLayout; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.imageeditor.model.ThumbRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.BezierDrawingRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.TextRenderer; + +/** + * ImageEditorView + *

+ * Android {@link android.view.View} that allows manipulation of a base image, rotate/flip/crop and + * addition and manipulation of text/drawing/and other image layers that move with the base image. + *

+ * Drawing + *

+ * Drawing is achieved by setting the {@link #color} and putting the view in {@link Mode#Draw}. + * Touch events are then passed to a new {@link BezierDrawingRenderer} on a new {@link EditorElement}. + *

+ * New images + *

+ * To add new images to the base image add via the {@link EditorModel#addElementCentered(EditorElement, float)} + * which centers the new item in the current crop area. + */ +public final class ImageEditorView extends FrameLayout { + + private HiddenEditText editText; + + @NonNull + private Mode mode = Mode.MoveAndResize; + + @ColorInt + private int color = 0xff000000; + + private float thickness = 0.02f; + + @NonNull + private Paint.Cap cap = Paint.Cap.ROUND; + + private EditorModel model; + + private GestureDetectorCompat doubleTap; + + @Nullable + private DrawingChangedListener drawingChangedListener; + + private final Matrix viewMatrix = new Matrix(); + private final RectF viewPort = Bounds.newFullBounds(); + private final RectF visibleViewPort = Bounds.newFullBounds(); + private final RectF screen = new RectF(); + + private TapListener tapListener; + private RendererContext rendererContext; + + @Nullable + private EditSession editSession; + + public ImageEditorView(Context context) { + super(context); + init(); + } + + public ImageEditorView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ImageEditorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + setWillNotDraw(false); + setModel(new EditorModel()); + + editText = createAHiddenTextEntryField(); + + doubleTap = new GestureDetectorCompat(getContext(), new DoubleTapGestureListener()); + + setOnTouchListener((v, event) -> doubleTap.onTouchEvent(event)); + } + + private HiddenEditText createAHiddenTextEntryField() { + HiddenEditText editText = new HiddenEditText(getContext()); + addView(editText); + editText.clearFocus(); + editText.setOnEndEdit(this::doneTextEditing); + return editText; + } + + public void startTextEditing(@NonNull EditorElement editorElement, boolean incognitoKeyboardEnabled, boolean selectAll) { + Renderer renderer = editorElement.getRenderer(); + if (renderer instanceof TextRenderer) { + TextRenderer textRenderer = (TextRenderer) renderer; + + editText.setIncognitoKeyboardEnabled(incognitoKeyboardEnabled); + editText.setCurrentTextEntity(textRenderer); + if (selectAll) { + editText.selectAll(); + } + editText.requestFocus(); + + getModel().zoomTo(editorElement, Bounds.TOP / 2, true); + } + } + + public boolean isTextEditing() { + return editText.getCurrentTextEntity() != null; + } + + public void doneTextEditing() { + getModel().zoomOut(); + if (editText.getCurrentTextEntity() != null) { + editText.setCurrentTextEntity(null); + editText.hideKeyboard(); + if (tapListener != null) { + tapListener.onEntityDown(null); + } + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (rendererContext == null || rendererContext.canvas != canvas) { + rendererContext = new RendererContext(getContext(), canvas, rendererReady, rendererInvalidate); + } + rendererContext.save(); + try { + rendererContext.canvasMatrix.initial(viewMatrix); + model.draw(rendererContext); + } finally { + rendererContext.restore(); + } + } + + private final RendererContext.Ready rendererReady = new RendererContext.Ready() { + @Override + public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) { + model.onReady(renderer, cropMatrix, size); + invalidate(); + } + }; + + private final RendererContext.Invalidate rendererInvalidate = renderer -> invalidate(); + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + updateViewMatrix(); + } + + private void updateViewMatrix() { + screen.right = getWidth(); + screen.bottom = getHeight(); + + viewMatrix.setRectToRect(viewPort, screen, Matrix.ScaleToFit.FILL); + + float[] values = new float[9]; + viewMatrix.getValues(values); + + float scale = values[0] / values[4]; + + RectF tempViewPort = Bounds.newFullBounds(); + if (scale < 1) { + tempViewPort.top /= scale; + tempViewPort.bottom /= scale; + } else { + tempViewPort.left *= scale; + tempViewPort.right *= scale; + } + + visibleViewPort.set(tempViewPort); + + viewMatrix.setRectToRect(visibleViewPort, screen, Matrix.ScaleToFit.CENTER); + + model.setVisibleViewPort(visibleViewPort); + + invalidate(); + } + + public void setModel(@NonNull EditorModel model) { + if (this.model != model) { + if (this.model != null) { + this.model.setInvalidate(null); + } + this.model = model; + this.model.setInvalidate(this::invalidate); + this.model.setVisibleViewPort(visibleViewPort); + invalidate(); + } + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + Matrix inverse = new Matrix(); + PointF point = getPoint(event); + EditorElement selected = model.findElementAtPoint(point, viewMatrix, inverse); + + model.pushUndoPoint(); + editSession = startEdit(inverse, point, selected); + + if (tapListener != null && allowTaps()) { + if (editSession != null) { + tapListener.onEntityDown(editSession.getSelected()); + } else { + tapListener.onEntityDown(null); + } + } + + return true; + } + case MotionEvent.ACTION_MOVE: { + if (editSession != null) { + for (int p = 0; p < Math.min(2, event.getPointerCount()); p++) { + editSession.movePoint(p, getPoint(event, p)); + } + invalidate(); + return true; + } + break; + } + case MotionEvent.ACTION_POINTER_DOWN: { + if (editSession != null && event.getPointerCount() == 2) { + editSession.commit(); + model.pushUndoPoint(); + + Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix); + editSession = editSession.newPoint(newInverse, getPoint(event, event.getActionIndex()), event.getActionIndex()); + if (editSession == null) { + dragDropRelease(); + } + return true; + } + break; + } + case MotionEvent.ACTION_POINTER_UP: { + if (editSession != null && event.getActionIndex() < 2) { + editSession.commit(); + model.pushUndoPoint(); + dragDropRelease(); + + Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix); + editSession = editSession.removePoint(newInverse, event.getActionIndex()); + return true; + } + break; + } + case MotionEvent.ACTION_UP: { + if (editSession != null) { + editSession.commit(); + dragDropRelease(); + + editSession = null; + invalidate(); + return true; + } + break; + } + } + + return super.onTouchEvent(event); + } + + private @Nullable EditSession startEdit(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) { + if (mode == Mode.Draw) { + return startADrawingSession(point); + } else { + return startAMoveAndResizeSession(inverse, point, selected); + } + } + + private EditSession startADrawingSession(@NonNull PointF point) { + BezierDrawingRenderer renderer = new BezierDrawingRenderer(color, thickness * Bounds.FULL_BOUNDS.width(), cap, model.findCropRelativeToRoot()); + EditorElement element = new EditorElement(renderer); + model.addElementCentered(element, 1); + + Matrix elementInverseMatrix = model.findElementInverseMatrix(element, viewMatrix); + + return DrawingSession.start(element, renderer, elementInverseMatrix, point); + } + + private EditSession startAMoveAndResizeSession(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) { + Matrix elementInverseMatrix; + if (selected == null) return null; + + if (selected.getRenderer() instanceof ThumbRenderer) { + ThumbRenderer thumb = (ThumbRenderer) selected.getRenderer(); + + selected = getModel().findById(thumb.getElementToControl()); + + if (selected == null) return null; + + elementInverseMatrix = model.findElementInverseMatrix(selected, viewMatrix); + if (elementInverseMatrix != null) { + return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumb.getControlPoint(), point); + } else { + return null; + } + } + + return ElementDragEditSession.startDrag(selected, inverse, point); + } + + public void setMode(@NonNull Mode mode) { + this.mode = mode; + } + + public void startDrawing(float thickness, @NonNull Paint.Cap cap) { + this.thickness = thickness; + this.cap = cap; + setMode(Mode.Draw); + } + + public void setDrawingBrushColor(int color) { + this.color = color; + } + + private void dragDropRelease() { + model.dragDropRelease(); + if (drawingChangedListener != null) { + drawingChangedListener.onDrawingChanged(); + } + } + + private static PointF getPoint(MotionEvent event) { + return getPoint(event, 0); + } + + private static PointF getPoint(MotionEvent event, int p) { + return new PointF(event.getX(p), event.getY(p)); + } + + public EditorModel getModel() { + return model; + } + + public void setDrawingChangedListener(@Nullable DrawingChangedListener drawingChangedListener) { + this.drawingChangedListener = drawingChangedListener; + } + + public void setTapListener(TapListener tapListener) { + this.tapListener = tapListener; + } + + public void deleteElement(@Nullable EditorElement editorElement) { + if (editorElement != null) { + model.pushUndoPoint(); + model.delete(editorElement); + invalidate(); + } + } + + private final class DoubleTapGestureListener extends GestureDetector.SimpleOnGestureListener { + + @Override + public boolean onDoubleTap(MotionEvent e) { + if (tapListener != null && editSession != null && allowTaps()) { + tapListener.onEntityDoubleTap(editSession.getSelected()); + } + return true; + } + + @Override + public void onLongPress(MotionEvent e) {} + + @Override + public boolean onSingleTapUp(MotionEvent e) { + if (tapListener != null && allowTaps()) { + if (editSession != null) { + EditorElement selected = editSession.getSelected(); + model.indicateSelected(selected); + tapListener.onEntitySingleTap(selected); + } else { + tapListener.onEntitySingleTap(null); + } + } + return true; + } + + @Override + public boolean onDown(MotionEvent e) { + return false; + } + } + + private boolean allowTaps() { + return !model.isCropping() && mode != Mode.Draw; + } + + public enum Mode { + MoveAndResize, + Draw + } + + public interface DrawingChangedListener { + void onDrawingChanged(); + } + + public interface TapListener { + + void onEntityDown(@Nullable EditorElement editorElement); + + void onEntitySingleTap(@Nullable EditorElement editorElement); + + void onEntityDoubleTap(@NonNull EditorElement editorElement); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/Renderer.java b/src/org/thoughtcrime/securesms/imageeditor/Renderer.java new file mode 100644 index 000000000..0b4ff672b --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/Renderer.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.os.Parcelable; +import android.support.annotation.NonNull; + +/** + * Responsible for rendering a single {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement} to the canvas. + *

+ * Because it knows the most about the whereabouts of the image it is also responsible for hit detection. + */ +public interface Renderer extends Parcelable { + + /** + * Draw self to the context. + * + * @param rendererContext The context to draw to. + */ + void render(@NonNull RendererContext rendererContext); + + /** + * @param x Local coordinate X + * @param y Local coordinate Y + * @return true iff hit. + */ + boolean hitTest(float x, float y); +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/RendererContext.java b/src/org/thoughtcrime/securesms/imageeditor/RendererContext.java new file mode 100644 index 000000000..9ca600f39 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/RendererContext.java @@ -0,0 +1,114 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.RectF; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +/** + * Contains all of the information required for a {@link Renderer} to do its job. + *

+ * Includes a {@link #canvas}, preconfigured with the correct matrix. + *

+ * The {@link #canvasMatrix} should further matrix manipulation be required. + */ +public final class RendererContext { + + @NonNull + public final Context context; + + @NonNull + public final Canvas canvas; + + @NonNull + public final CanvasMatrix canvasMatrix; + + @NonNull + public final Ready rendererReady; + + @NonNull + public final Invalidate invalidate; + + private boolean blockingLoad; + + private float fade = 1f; + + private boolean isEditing = true; + + public RendererContext(@NonNull Context context, @NonNull Canvas canvas, @NonNull Ready rendererReady, @NonNull Invalidate invalidate) { + this.context = context; + this.canvas = canvas; + this.canvasMatrix = new CanvasMatrix(canvas); + this.rendererReady = rendererReady; + this.invalidate = invalidate; + } + + public void setBlockingLoad(boolean blockingLoad) { + this.blockingLoad = blockingLoad; + } + + /** + * {@link Renderer}s generally run in the foreground but can load any data they require in the background. + *

+ * If they do so, they can use the {@link #invalidate} callback when ready to inform the view it needs to be redrawn. + *

+ * However, when isBlockingLoad is true, the renderer is running in the background for the final render + * and must load the data immediately and block the render until done so. + */ + public boolean isBlockingLoad() { + return blockingLoad; + } + + public boolean mapRect(@NonNull RectF dst, @NonNull RectF src) { + return canvasMatrix.mapRect(dst, src); + } + + public void setIsEditing(boolean isEditing) { + this.isEditing = isEditing; + } + + public boolean isEditing() { + return isEditing; + } + + public void setFade(float fade) { + this.fade = fade; + } + + public int getAlpha(int alpha) { + return Math.max(0, Math.min(255, (int) (fade * alpha))); + } + + /** + * Persist the current state on to a stack, must be complimented by a call to {@link #restore()}. + */ + public void save() { + canvasMatrix.save(); + } + + /** + * Restore the current state from the stack, must match a call to {@link #save()}. + */ + public void restore() { + canvasMatrix.restore(); + } + + public interface Ready { + + Ready NULL = (renderer, cropMatrix, size) -> { + }; + + void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size); + } + + public interface Invalidate { + + Invalidate NULL = (renderer) -> { + }; + + void onInvalidate(@NonNull Renderer renderer); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/ThumbDragEditSession.java b/src/org/thoughtcrime/securesms/imageeditor/ThumbDragEditSession.java new file mode 100644 index 000000000..591e16afe --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/ThumbDragEditSession.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.imageeditor; + +import android.graphics.Matrix; +import android.graphics.PointF; +import android.support.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.ThumbRenderer; + +class ThumbDragEditSession extends ElementEditSession { + + @NonNull + private final ThumbRenderer.ControlPoint controlPoint; + + private ThumbDragEditSession(@NonNull EditorElement selected, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull Matrix inverseMatrix) { + super(selected, inverseMatrix); + this.controlPoint = controlPoint; + } + + static EditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull PointF point) { + if (!selected.getFlags().isEditable()) return null; + + ElementEditSession elementDragEditSession = new ThumbDragEditSession(selected, controlPoint, inverseViewModelMatrix); + elementDragEditSession.setScreenStartPoint(0, point); + elementDragEditSession.setScreenEndPoint(0, point); + return elementDragEditSession; + } + + @Override + public void movePoint(int p, @NonNull PointF point) { + setScreenEndPoint(p, point); + + Matrix editorMatrix = selected.getEditorMatrix(); + + editorMatrix.reset(); + + float x = controlPoint.opposite().getX(); + float y = controlPoint.opposite().getY(); + + editorMatrix.postTranslate(-x, -y); + + boolean aspectLocked = selected.getFlags().isAspectLocked(); + + float defaultScale = aspectLocked ? 2 : 1; + + float scaleX = controlPoint.isVerticalCenter() ? defaultScale : (endPointElement[0].x - x) / (startPointElement[0].x - x); + float scaleY = controlPoint.isHorizontalCenter() ? defaultScale : (endPointElement[0].y - y) / (startPointElement[0].y - y); + + if (aspectLocked) { + float minScale = Math.min(scaleX, scaleY); + editorMatrix.postScale(minScale, minScale); + } else { + editorMatrix.postScale(scaleX, scaleY); + } + + editorMatrix.postTranslate(x, y); + } + + @Override + public EditSession newPoint(Matrix newInverse, PointF point, int p) { + return null; + } + + @Override + public EditSession removePoint(Matrix newInverse, int p) { + return null; + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/AlphaAnimation.java b/src/org/thoughtcrime/securesms/imageeditor/model/AlphaAnimation.java new file mode 100644 index 000000000..0a2c24d32 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/model/AlphaAnimation.java @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.animation.ValueAnimator; +import android.support.annotation.Nullable; +import android.view.animation.Interpolator; +import android.view.animation.LinearInterpolator; + +final class AlphaAnimation { + + private final static Interpolator interpolator = new LinearInterpolator(); + + final static AlphaAnimation NULL_1 = new AlphaAnimation(1); + + private final float from; + private final float to; + private final Runnable invalidate; + private final boolean canAnimate; + private float animatedFraction; + + private AlphaAnimation(float from, float to, @Nullable Runnable invalidate) { + this.from = from; + this.to = to; + this.invalidate = invalidate; + this.canAnimate = invalidate != null; + } + + private AlphaAnimation(float fixed) { + this(fixed, fixed, null); + } + + static AlphaAnimation animate(float from, float to, @Nullable Runnable invalidate) { + if (invalidate == null) { + return new AlphaAnimation(to); + } + + if (from != to) { + AlphaAnimation animationMatrix = new AlphaAnimation(from, to, invalidate); + animationMatrix.start(); + return animationMatrix; + } else { + return new AlphaAnimation(to); + } + } + + private void start() { + if (canAnimate && invalidate != null) { + ValueAnimator animator = ValueAnimator.ofFloat(from, to); + animator.setDuration(200); + animator.setInterpolator(interpolator); + animator.addUpdateListener(animation -> { + animatedFraction = (float) animation.getAnimatedValue(); + invalidate.run(); + }); + animator.start(); + } + } + + float getValue() { + if (!canAnimate) return to; + + return animatedFraction; + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/AnimationMatrix.java b/src/org/thoughtcrime/securesms/imageeditor/model/AnimationMatrix.java new file mode 100644 index 000000000..712f52d11 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/model/AnimationMatrix.java @@ -0,0 +1,136 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.animation.ValueAnimator; +import android.graphics.Matrix; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.animation.CycleInterpolator; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; + +import org.thoughtcrime.securesms.imageeditor.CanvasMatrix; + +/** + * Animation Matrix provides a matrix that animates over time down to the identity matrix. + */ +final class AnimationMatrix { + + private final static float[] iValues = new float[9]; + private final static Interpolator interpolator = new DecelerateInterpolator(); + private final static Interpolator pulseInterpolator = inverse(new CycleInterpolator(0.5f)); + + static AnimationMatrix NULL = new AnimationMatrix(); + + static { + new Matrix().getValues(iValues); + } + + private final Runnable invalidate; + private final boolean canAnimate; + private final float[] undoValues = new float[9]; + + private final Matrix temp = new Matrix(); + private final float[] tempValues = new float[9]; + + private ValueAnimator animator; + private float animatedFraction; + + private AnimationMatrix(@NonNull Matrix undo, @NonNull Runnable invalidate) { + this.invalidate = invalidate; + this.canAnimate = true; + undo.getValues(undoValues); + } + + private AnimationMatrix() { + canAnimate = false; + invalidate = null; + } + + static @NonNull AnimationMatrix animate(@NonNull Matrix from, @NonNull Matrix to, @Nullable Runnable invalidate) { + if (invalidate == null) { + return NULL; + } + + Matrix undo = new Matrix(); + boolean inverted = to.invert(undo); + if (inverted) { + undo.preConcat(from); + } + if (inverted && !undo.isIdentity()) { + AnimationMatrix animationMatrix = new AnimationMatrix(undo, invalidate); + animationMatrix.start(interpolator); + return animationMatrix; + } else { + return NULL; + } + } + + /** + * Animate applying a matrix and then animate removing. + */ + static @NonNull AnimationMatrix singlePulse(@NonNull Matrix pulse, @Nullable Runnable invalidate) { + if (invalidate == null) { + return NULL; + } + + AnimationMatrix animationMatrix = new AnimationMatrix(pulse, invalidate); + animationMatrix.start(pulseInterpolator); + + return animationMatrix; + } + + private void start(@NonNull Interpolator interpolator) { + if (canAnimate) { + animator = ValueAnimator.ofFloat(1, 0); + animator.setDuration(250); + animator.setInterpolator(interpolator); + animator.addUpdateListener(animation -> { + animatedFraction = (float) animation.getAnimatedValue(); + invalidate.run(); + }); + animator.start(); + } + } + + void stop() { + ValueAnimator animator = this.animator; + if (animator != null) animator.cancel(); + } + + /** + * Append the current animation value. + */ + void preConcatValueTo(@NonNull Matrix onTo) { + if (!canAnimate) return; + + onTo.preConcat(buildTemp()); + } + + /** + * Append the current animation value. + */ + void preConcatValueTo(@NonNull CanvasMatrix canvasMatrix) { + if (!canAnimate) return; + + canvasMatrix.concat(buildTemp()); + } + + private Matrix buildTemp() { + if (!canAnimate) { + temp.reset(); + return temp; + } + + final float fractionCompliment = 1f - animatedFraction; + for (int i = 0; i < 9; i++) { + tempValues[i] = fractionCompliment * iValues[i] + animatedFraction * undoValues[i]; + } + + temp.setValues(tempValues); + return temp; + } + + private static Interpolator inverse(@NonNull Interpolator interpolator) { + return input -> 1f - interpolator.getInterpolation(input); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/CropThumbRenderer.java b/src/org/thoughtcrime/securesms/imageeditor/model/CropThumbRenderer.java new file mode 100644 index 000000000..ffac880e8 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/model/CropThumbRenderer.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import android.os.Parcel; +import android.support.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.util.UUID; + +/** + * Hit tests a circle that is {@link R.dimen#crop_area_renderer_edge_size} in radius on the screen. + *

+ * Does not draw anything. + */ +class CropThumbRenderer implements Renderer, ThumbRenderer { + + private final ControlPoint controlPoint; + private final UUID toControl; + + private final float[] centreOnScreen = new float[2]; + private final Matrix matrix = new Matrix(); + private int size; + + CropThumbRenderer(@NonNull ControlPoint controlPoint, @NonNull UUID toControl) { + this.controlPoint = controlPoint; + this.toControl = toControl; + } + + @Override + public ControlPoint getControlPoint() { + return controlPoint; + } + + @Override + public UUID getElementToControl() { + return toControl; + } + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.canvasMatrix.mapPoints(centreOnScreen, Bounds.CENTRE); + rendererContext.canvasMatrix.copyTo(matrix); + size = rendererContext.context.getResources().getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size); + } + + @Override + public boolean hitTest(float x, float y) { + float[] hitPointOnScreen = new float[2]; + matrix.mapPoints(hitPointOnScreen, new float[]{ x, y }); + + float dx = centreOnScreen[0] - hitPointOnScreen[0]; + float dy = centreOnScreen[1] - hitPointOnScreen[1]; + + return dx * dx + dy * dy < size * size; + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public CropThumbRenderer createFromParcel(Parcel in) { + return new CropThumbRenderer(ControlPoint.values()[in.readInt()], ParcelUtils.readUUID(in)); + } + + @Override + public CropThumbRenderer[] newArray(int size) { + return new CropThumbRenderer[size]; + } + }; + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(controlPoint.ordinal()); + ParcelUtils.writeUUID(dest, toControl); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java b/src/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java new file mode 100644 index 000000000..f8b7f3de2 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java @@ -0,0 +1,333 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * An image consists of a tree of {@link EditorElement}s. + *

+ * Each element has some persisted state: + * - An optional {@link Renderer} so that it can draw itself. + * - A list of child elements that make the tree possible. + * - Its own transformation matrix, which applies to itself and all its children. + * - A set of flags controlling visibility, selectablity etc. + *

+ * Then some temporary state. + * - A editor matrix for displaying as yet uncommitted edits. + * - An animation matrix for animating from one matrix to another. + * - Deleted children to allow them to fade out on delete. + * - Temporary flags, for temporary visibility, selectablity etc. + */ +public final class EditorElement implements Parcelable { + + private final UUID id; + private final EditorFlags flags; + private final Matrix localMatrix = new Matrix(); + private final Matrix editorMatrix = new Matrix(); + + @Nullable + private final Renderer renderer; + + private final Matrix temp = new Matrix(); + + private final Matrix tempMatrix = new Matrix(); + + private final List children = new LinkedList<>(); + private final List deletedChildren = new LinkedList<>(); + + @NonNull + private AnimationMatrix animationMatrix = AnimationMatrix.NULL; + + @NonNull + private AlphaAnimation alphaAnimation = AlphaAnimation.NULL_1; + + public EditorElement(@Nullable Renderer renderer) { + this.id = UUID.randomUUID(); + this.flags = new EditorFlags(); + this.renderer = renderer; + } + + private EditorElement(Parcel in) { + id = ParcelUtils.readUUID(in); + flags = new EditorFlags(in.readInt()); + ParcelUtils.readMatrix(localMatrix, in); + renderer = in.readParcelable(Renderer.class.getClassLoader()); + in.readTypedList(children, EditorElement.CREATOR); + } + + UUID getId() { + return id; + } + + public @Nullable Renderer getRenderer() { + return renderer; + } + + /** + * Iff Visible, + * Renders tree with the following localMatrix: + *

+ * viewModelMatrix * localMatrix * editorMatrix * animationMatrix + *

+ * Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * localMatrix * editorMatrix * animationMatrix + * + * @param rendererContext Canvas to draw on to. + */ + void draw(@NonNull RendererContext rendererContext) { + if (!flags.isVisible() && !flags.isChildrenVisible()) return; + + rendererContext.save(); + + rendererContext.canvasMatrix.concat(localMatrix); + + if (rendererContext.isEditing()) { + rendererContext.canvasMatrix.concat(editorMatrix); + animationMatrix.preConcatValueTo(rendererContext.canvasMatrix); + } + + if (flags.isVisible()) { + float alpha = alphaAnimation.getValue(); + if (alpha > 0) { + rendererContext.setFade(alpha); + drawSelf(rendererContext); + rendererContext.setFade(1f); + } + } + + if (flags.isChildrenVisible()) { + drawChildren(children, rendererContext); + drawChildren(deletedChildren, rendererContext); + } + + rendererContext.restore(); + } + + private void drawSelf(@NonNull RendererContext rendererContext) { + if (renderer == null) return; + renderer.render(rendererContext); + } + + private static void drawChildren(@NonNull List children, @NonNull RendererContext rendererContext) { + for (EditorElement element : children) { + element.draw(rendererContext); + } + } + + public void addElement(@NonNull EditorElement element) { + children.add(element); + } + + public Matrix getLocalMatrix() { + return localMatrix; + } + + public Matrix getEditorMatrix() { + return editorMatrix; + } + + EditorElement findElement(@NonNull EditorElement toFind, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) { + return findElement(viewMatrix, outInverseModelMatrix, (element, inverseMatrix) -> toFind == element); + } + + EditorElement findElementAt(float x, float y, @NonNull Matrix viewModelMatrix, @NonNull Matrix outInverseModelMatrix) { + final float[] dst = new float[2]; + final float[] src = { x, y }; + + return findElement(viewModelMatrix, outInverseModelMatrix, (element, inverseMatrix) -> { + Renderer renderer = element.renderer; + if (renderer == null) return false; + inverseMatrix.mapPoints(dst, src); + return element.flags.isSelectable() && renderer.hitTest(dst[0], dst[1]); + }); + } + + public EditorElement findElement(@NonNull Matrix viewModelMatrix, @NonNull Matrix outInverseModelMatrix, @NonNull FindElementPredicate predicate) { + temp.set(viewModelMatrix); + + temp.preConcat(localMatrix); + temp.preConcat(editorMatrix); + + if (temp.invert(tempMatrix)) { + + for (int i = children.size() - 1; i >= 0; i--) { + EditorElement elementAt = children.get(i).findElement(temp, outInverseModelMatrix, predicate); + if (elementAt != null) { + return elementAt; + } + } + + if (predicate.test(this, tempMatrix)) { + outInverseModelMatrix.set(tempMatrix); + return this; + } + } + + return null; + } + + public EditorFlags getFlags() { + return flags; + } + + int getChildCount() { + return children.size(); + } + + EditorElement getChild(int i) { + return children.get(i); + } + + void forAllInTree(@NonNull PerElementFunction function) { + function.apply(this); + for (EditorElement child : children) { + child.forAllInTree(function); + } + } + + void deleteChild(@NonNull EditorElement editorElement, @Nullable Runnable invalidate) { + Iterator iterator = children.iterator(); + while (iterator.hasNext()) { + if (iterator.next() == editorElement) { + iterator.remove(); + addDeletedChildFadingOut(editorElement, invalidate); + } + } + } + + void addDeletedChildFadingOut(@NonNull EditorElement fromElement, @Nullable Runnable invalidate) { + deletedChildren.add(fromElement); + fromElement.animateFadeOut(invalidate); + } + + private void animateFadeOut(@Nullable Runnable invalidate) { + alphaAnimation = AlphaAnimation.animate(1, 0, invalidate); + } + + void animateFadeIn(@Nullable Runnable invalidate) { + alphaAnimation = AlphaAnimation.animate(0, 1, invalidate); + } + + @Nullable EditorElement parentOf(@NonNull EditorElement element) { + if (children.contains(element)) { + return this; + } + for (EditorElement child : children) { + EditorElement parent = child.parentOf(element); + if (parent != null) { + return parent; + } + } + return null; + } + + public void singleScalePulse(@Nullable Runnable invalidate) { + Matrix scale = new Matrix(); + scale.setScale(1.2f, 1.2f); + + animationMatrix = AnimationMatrix.singlePulse(scale, invalidate); + } + + public interface PerElementFunction { + void apply(EditorElement element); + } + + public interface FindElementPredicate { + boolean test(EditorElement element, Matrix inverseMatrix); + } + + public void commitEditorMatrix() { + if (flags.isEditable()) { + localMatrix.preConcat(editorMatrix); + editorMatrix.reset(); + } else { + rollbackEditorMatrix(null); + } + } + + void rollbackEditorMatrix(@Nullable Runnable invalidate) { + animateEditorTo(new Matrix(), invalidate); + } + + void buildMap(Map map) { + map.put(id, this); + for (EditorElement child : children) { + child.buildMap(map); + } + } + + void animateFrom(@NonNull Matrix oldMatrix, @Nullable Runnable invalidate) { + Matrix oldMatrixCopy = new Matrix(oldMatrix); + animationMatrix.stop(); + animationMatrix.preConcatValueTo(oldMatrixCopy); + animationMatrix = AnimationMatrix.animate(oldMatrixCopy, localMatrix, invalidate); + } + + void animateEditorTo(@NonNull Matrix newEditorMatrix, @Nullable Runnable invalidate) { + setMatrixWithAnimation(editorMatrix, newEditorMatrix, invalidate); + } + + void animateLocalTo(@NonNull Matrix newLocalMatrix, @Nullable Runnable invalidate) { + setMatrixWithAnimation(localMatrix, newLocalMatrix, invalidate); + } + + /** + * @param destination Matrix to change + * @param source Matrix value to set + * @param invalidate Callback to allow animation + */ + private void setMatrixWithAnimation(@NonNull Matrix destination, @NonNull Matrix source, @Nullable Runnable invalidate) { + Matrix old = new Matrix(destination); + animationMatrix.stop(); + animationMatrix.preConcatValueTo(old); + destination.set(source); + animationMatrix = AnimationMatrix.animate(old, destination, invalidate); + } + + Matrix getLocalMatrixAnimating() { + Matrix matrix = new Matrix(localMatrix); + animationMatrix.preConcatValueTo(matrix); + return matrix; + } + + void stopAnimation() { + animationMatrix.stop(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public EditorElement createFromParcel(Parcel in) { + return new EditorElement(in); + } + + @Override + public EditorElement[] newArray(int size) { + return new EditorElement[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + ParcelUtils.writeUUID(dest, id); + dest.writeInt(this.flags.asInt()); + ParcelUtils.writeMatrix(dest, localMatrix); + dest.writeParcelable(renderer, flags); + dest.writeTypedList(children); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java b/src/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java new file mode 100644 index 000000000..d16c677bf --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java @@ -0,0 +1,325 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.renderers.CropAreaRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.InverseFillRenderer; + +/** + * Creates and handles a strict EditorElement Hierarchy. + *

+ * root - always square, contains only temporary zooms for editing. e.g. when the whole editor zooms out for cropping + * | + * |- view - contains persisted adjustments for crops + * | | + * | |- flipRotate - contains persisted adjustments for flip and rotate operations, ensures operations are centered within the current view + * | | + * | |- imageRoot + * | | |- mainImage + * | | |- stickers/drawings/text + * | | + * | |- overlay - always square + * | | |- imageCrop - a crop to match the aspect of the main image + * | | | |- cropEditorElement - user crop, not always square, but upright, the area of the view + * | | | | | All children do not move/scale or rotate. + * | | | | |- blackout + * | | | | |- thumbs + * | | | | | |- Center left thumb + * | | | | | |- Center right thumb + * | | | | | |- Top center thumb + * | | | | | |- Bottom center thumb + * | | | | | |- Top left thumb + * | | | | | |- Top right thumb + * | | | | | |- Bottom left thumb + * | | | | | |- Bottom right thumb + */ +final class EditorElementHierarchy { + + static @NonNull EditorElementHierarchy create() { + return new EditorElementHierarchy(createRoot()); + } + + static @NonNull EditorElementHierarchy create(@Nullable EditorElement root) { + if (root == null) { + return create(); + } else { + return new EditorElementHierarchy(root); + } + } + + private final EditorElement root; + private final EditorElement view; + private final EditorElement flipRotate; + private final EditorElement imageRoot; + private final EditorElement overlay; + private final EditorElement imageCrop; + private final EditorElement cropEditorElement; + private final EditorElement blackout; + private final EditorElement thumbs; + + private EditorElementHierarchy(@NonNull EditorElement root) { + this.root = root; + this.view = this.root.getChild(0); + this.flipRotate = this.view.getChild(0); + this.imageRoot = this.flipRotate.getChild(0); + this.overlay = this.flipRotate.getChild(1); + this.imageCrop = this.overlay.getChild(0); + this.cropEditorElement = this.imageCrop.getChild(0); + this.blackout = this.cropEditorElement.getChild(0); + this.thumbs = this.cropEditorElement.getChild(1); + } + + private static @NonNull EditorElement createRoot() { + EditorElement root = new EditorElement(null); + + EditorElement imageRoot = new EditorElement(null); + root.addElement(imageRoot); + + EditorElement flipRotate = new EditorElement(null); + imageRoot.addElement(flipRotate); + + EditorElement image = new EditorElement(null); + flipRotate.addElement(image); + + EditorElement overlay = new EditorElement(null); + flipRotate.addElement(overlay); + + EditorElement imageCrop = new EditorElement(null); + overlay.addElement(imageCrop); + + EditorElement cropEditorElement = new EditorElement(new CropAreaRenderer(R.color.crop_area_renderer_outer_color)); + + cropEditorElement.getFlags() + .setRotateLocked(true) + .setAspectLocked(true) + .setSelectable(false) + .setVisible(false) + .persist(); + + imageCrop.addElement(cropEditorElement); + + EditorElement blackout = new EditorElement(new InverseFillRenderer(0xff000000)); + + blackout.getFlags() + .setSelectable(false) + .setEditable(false) + .persist(); + + cropEditorElement.addElement(blackout); + + cropEditorElement.addElement(createThumbs(cropEditorElement)); + return root; + } + + private static @NonNull EditorElement createThumbs(EditorElement cropEditorElement) { + EditorElement thumbs = new EditorElement(null); + + thumbs.getFlags() + .setChildrenVisible(false) + .setSelectable(false) + .setVisible(false) + .persist(); + + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_LEFT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_RIGHT)); + + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_CENTER)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_CENTER)); + + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_LEFT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_RIGHT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_LEFT)); + thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_RIGHT)); + + return thumbs; + } + + private static @NonNull EditorElement newThumb(@NonNull EditorElement toControl, @NonNull ThumbRenderer.ControlPoint controlPoint) { + EditorElement element = new EditorElement(new CropThumbRenderer(controlPoint, toControl.getId())); + + element.getFlags() + .setSelectable(false) + .persist(); + + element.getLocalMatrix().preTranslate(controlPoint.getX(), controlPoint.getY()); + + return element; + } + + EditorElement getRoot() { + return root; + } + + EditorElement getImageRoot() { + return imageRoot; + } + + /** + * The main image, null if not yet set. + */ + @Nullable EditorElement getMainImage() { + return imageRoot.getChildCount() > 0 ? imageRoot.getChild(0) : null; + } + + EditorElement getCropEditorElement() { + return cropEditorElement; + } + + EditorElement getImageCrop() { + return imageCrop; + } + + EditorElement getOverlay() { + return overlay; + } + + EditorElement getFlipRotate() { + return flipRotate; + } + + void startCrop(@NonNull Runnable invalidate) { + Matrix editor = new Matrix(); + float scaleInForCrop = 0.8f; + + editor.postScale(scaleInForCrop, scaleInForCrop); + root.animateEditorTo(editor, invalidate); + + cropEditorElement.getFlags() + .setVisible(true); + + blackout.getFlags() + .setVisible(false); + + thumbs.getFlags() + .setChildrenVisible(true); + + thumbs.forAllInTree(element -> element.getFlags().setSelectable(true)); + + imageRoot.forAllInTree(element -> element.getFlags().setSelectable(false)); + + EditorElement mainImage = getMainImage(); + if (mainImage != null) { + mainImage.getFlags().setSelectable(true); + } + + invalidate.run(); + } + + void doneCrop(@NonNull RectF visibleViewPort, @Nullable Runnable invalidate) { + updateViewToCrop(visibleViewPort, invalidate); + + root.rollbackEditorMatrix(invalidate); + + root.forAllInTree(element -> element.getFlags().reset()); + } + + void updateViewToCrop(@NonNull RectF visibleViewPort, @Nullable Runnable invalidate) { + RectF dst = new RectF(); + + getCropFinalMatrix().mapRect(dst, Bounds.FULL_BOUNDS); + + Matrix temp = new Matrix(); + temp.setRectToRect(dst, visibleViewPort, Matrix.ScaleToFit.CENTER); + view.animateLocalTo(temp, invalidate); + } + + private @NonNull Matrix getCropFinalMatrix() { + Matrix matrix = new Matrix(flipRotate.getLocalMatrix()); + matrix.preConcat(imageCrop.getLocalMatrix()); + matrix.preConcat(cropEditorElement.getLocalMatrix()); + return matrix; + } + + void dragDropRelease(@NonNull RectF visibleViewPort, @NonNull Runnable invalidate) { + if (cropEditorElement.getFlags().isVisible()) { + updateViewToCrop(visibleViewPort, invalidate); + } + } + + RectF getCropRect() { + RectF dst = new RectF(); + getCropFinalMatrix().mapRect(dst, Bounds.FULL_BOUNDS); + return dst; + } + + void flipRotate(int degrees, int scaleX, int scaleY, @NonNull RectF visibleViewPort, @Nullable Runnable invalidate) { + Matrix newLocal = new Matrix(flipRotate.getLocalMatrix()); + if (degrees != 0) { + newLocal.postRotate(degrees); + } + newLocal.postScale(scaleX, scaleY); + flipRotate.animateLocalTo(newLocal, invalidate); + updateViewToCrop(visibleViewPort, invalidate); + } + + /** + * The full matrix for the {@link #getMainImage()} from {@link #root} down. + */ + Matrix getMainImageFullMatrix() { + Matrix matrix = new Matrix(); + + matrix.preConcat(view.getLocalMatrix()); + matrix.preConcat(getMainImageFullMatrixFromFlipRotate()); + + return matrix; + } + + /** + * The full matrix for the {@link #getMainImage()} from {@link #flipRotate} down. + */ + Matrix getMainImageFullMatrixFromFlipRotate() { + Matrix matrix = new Matrix(); + + matrix.preConcat(flipRotate.getLocalMatrix()); + matrix.preConcat(imageRoot.getLocalMatrix()); + + EditorElement mainImage = getMainImage(); + if (mainImage != null) { + matrix.preConcat(mainImage.getLocalMatrix()); + } + + return matrix; + } + + /** + * Calculates the exact output size based upon the crops/rotates and zooms in the hierarchy. + * + * @param inputSize Main image size + * @return Size after applying all zooms/rotates and crops + */ + PointF getOutputSize(@NonNull Point inputSize) { + Matrix matrix = new Matrix(); + + matrix.preConcat(flipRotate.getLocalMatrix()); + matrix.preConcat(cropEditorElement.getLocalMatrix()); + EditorElement mainImage = getMainImage(); + if (mainImage != null) { + float xScale = 1f / xScale(mainImage.getLocalMatrix()); + matrix.preScale(xScale, xScale); + } + + float[] dst = new float[4]; + matrix.mapPoints(dst, new float[]{ 0, 0, inputSize.x, inputSize.y }); + + float widthF = Math.abs(dst[0] - dst[2]); + float heightF = Math.abs(dst[1] - dst[3]); + + return new PointF(widthF, heightF); + } + + /** + * Extract the x scale from a matrix, which is the length of the first column. + */ + static float xScale(@NonNull Matrix matrix) { + float[] values = new float[9]; + matrix.getValues(values); + return (float) Math.sqrt(values[0] * values[0] + values[3] * values[3]); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java b/src/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java new file mode 100644 index 000000000..700ddaa86 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +/** + * Flags for an {@link EditorElement}. + *

+ * Values you set are not persisted unless you call {@link #persist()}. + *

+ * This allows temporary state for editing and an easy way to revert to the persisted state via {@link #reset()}. + */ +public final class EditorFlags { + + private static final int ASPECT_LOCK = 1; + private static final int ROTATE_LOCK = 2; + private static final int SELECTABLE = 4; + private static final int VISIBLE = 8; + private static final int CHILDREN_VISIBLE = 16; + private static final int EDITABLE = 32; + + private int flags; + private int persistedFlags; + + EditorFlags() { + this(ASPECT_LOCK | SELECTABLE | VISIBLE | CHILDREN_VISIBLE | EDITABLE); + } + + EditorFlags(int flags) { + this.flags = flags; + this.persistedFlags = flags; + } + + public EditorFlags setRotateLocked(boolean rotateLocked) { + setFlag(ROTATE_LOCK, rotateLocked); + return this; + } + + public boolean isRotateLocked() { + return isFlagSet(ROTATE_LOCK); + } + + public EditorFlags setAspectLocked(boolean aspectLocked) { + setFlag(ASPECT_LOCK, aspectLocked); + return this; + } + + public boolean isAspectLocked() { + return isFlagSet(ASPECT_LOCK); + } + + public EditorFlags setSelectable(boolean selectable) { + setFlag(SELECTABLE, selectable); + return this; + } + + public boolean isSelectable() { + return isFlagSet(SELECTABLE); + } + + public EditorFlags setEditable(boolean canEdit) { + setFlag(EDITABLE, canEdit); + return this; + } + + public boolean isEditable() { + return isFlagSet(EDITABLE); + } + + public EditorFlags setVisible(boolean visible) { + setFlag(VISIBLE, visible); + return this; + } + + public boolean isVisible() { + return isFlagSet(VISIBLE); + } + + public EditorFlags setChildrenVisible(boolean childrenVisible) { + setFlag(CHILDREN_VISIBLE, childrenVisible); + return this; + } + + public boolean isChildrenVisible() { + return isFlagSet(CHILDREN_VISIBLE); + } + + private void setFlag(int flag, boolean set) { + if (set) { + this.flags |= flag; + } else { + this.flags &= ~flag; + } + } + + private boolean isFlagSet(int flag) { + return (flags & flag) != 0; + } + + int asInt() { + return persistedFlags; + } + + int getCurrentState() { + return flags; + } + + public void persist() { + persistedFlags = flags; + } + + void reset() { + restoreState(persistedFlags); + } + + void restoreState(int flags) { + this.flags = flags; + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java b/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java new file mode 100644 index 000000000..1a1b6644d --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java @@ -0,0 +1,435 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Contains a reference to the root {@link EditorElement}, maintains undo and redo stacks and has a + * reference to the {@link EditorElementHierarchy}. + *

+ * As such it is the entry point for all operations that change the image. + */ +public final class EditorModel implements Parcelable, RendererContext.Ready { + + private static final Runnable NULL_RUNNABLE = () -> { + }; + + private static final int MINIMUM_OUTPUT_WIDTH = 0; + + @NonNull + private Runnable invalidate = NULL_RUNNABLE; + + private final ElementStack undoStack; + private final ElementStack redoStack; + + private EditorElementHierarchy editorElementHierarchy; + + private final RectF visibleViewPort = new RectF(); + private final Point size; + + public EditorModel() { + this.size = new Point(1024, 1024); + this.editorElementHierarchy = EditorElementHierarchy.create(); + this.undoStack = new ElementStack(50); + this.redoStack = new ElementStack(50); + } + + private EditorModel(Parcel in) { + ClassLoader classLoader = getClass().getClassLoader(); + this.size = new Point(in.readInt(), in.readInt()); + this.editorElementHierarchy = EditorElementHierarchy.create(in.readParcelable(classLoader)); + this.undoStack = in.readParcelable(classLoader); + this.redoStack = in.readParcelable(classLoader); + } + + public void setInvalidate(@Nullable Runnable invalidate) { + this.invalidate = invalidate != null ? invalidate : NULL_RUNNABLE; + } + + /** + * Renders tree with the following matrix: + *

+ * viewModelMatrix * matrix * editorMatrix + *

+ * Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * matrix * editorMatrix + * + * @param rendererContext Canvas to draw on to. + */ + public void draw(@NonNull RendererContext rendererContext) { + editorElementHierarchy.getRoot().draw(rendererContext); + } + + public @Nullable Matrix findElementInverseMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) { + Matrix inverse = new Matrix(); + if (findElement(element, viewMatrix, inverse)) { + return inverse; + } + return null; + } + + private @Nullable Matrix findElementMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) { + Matrix inverse = findElementInverseMatrix(element, viewMatrix); + if (inverse != null) { + Matrix regular = new Matrix(); + inverse.invert(regular); + return regular; + } + return null; + } + + public EditorElement findElementAtPoint(@NonNull PointF point, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) { + return editorElementHierarchy.getRoot().findElementAt(point.x, point.y, viewMatrix, outInverseModelMatrix); + } + + private boolean findElement(@NonNull EditorElement element, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) { + return editorElementHierarchy.getRoot().findElement(element, viewMatrix, outInverseModelMatrix) == element; + } + + public void pushUndoPoint() { + if (undoStack.tryPush(editorElementHierarchy.getRoot())) { + redoStack.clear(); + } + } + + public void undo() { + undoRedo(undoStack, redoStack); + } + + public void redo() { + undoRedo(redoStack, undoStack); + } + + private void undoRedo(@NonNull ElementStack fromStack, @NonNull ElementStack toStack) { + final EditorElement popped = fromStack.pop(); + + if (popped != null) { + EditorElement oldRootElement = editorElementHierarchy.getRoot(); + editorElementHierarchy = EditorElementHierarchy.create(popped); + toStack.tryPush(oldRootElement); + + restoreStateWithAnimations(oldRootElement, editorElementHierarchy.getRoot(), invalidate); + invalidate.run(); + + // re-zoom image root as the view port might be different now + editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate); + } + } + + private static void restoreStateWithAnimations(@NonNull EditorElement fromRootElement, @NonNull EditorElement toRootElement, @NonNull Runnable onInvalidate) { + Map fromMap = getElementMap(fromRootElement); + Map toMap = getElementMap(toRootElement); + + for (EditorElement fromElement : fromMap.values()) { + fromElement.stopAnimation(); + EditorElement toElement = toMap.get(fromElement.getId()); + if (toElement != null) { + toElement.animateFrom(fromElement.getLocalMatrixAnimating(), onInvalidate); + } else { + // element is removed + EditorElement parentFrom = fromRootElement.parentOf(fromElement); + if (parentFrom != null) { + EditorElement toParent = toMap.get(parentFrom.getId()); + if (toParent != null) { + toParent.addDeletedChildFadingOut(fromElement, onInvalidate); + } + } + } + } + + for (EditorElement toElement : toMap.values()) { + if (!fromMap.containsKey(toElement.getId())) { + // new item + toElement.animateFadeIn(onInvalidate); + } + } + } + + private static Map getElementMap(@NonNull EditorElement element) { + final Map result = new HashMap<>(); + element.buildMap(result); + return result; + } + + public void startCrop() { + pushUndoPoint(); + editorElementHierarchy.startCrop(invalidate); + } + + public void doneCrop() { + editorElementHierarchy.doneCrop(visibleViewPort, invalidate); + } + + public void setCropAspectLock(boolean locked) { + EditorFlags flags = editorElementHierarchy.getCropEditorElement().getFlags(); + int currentState = flags.setAspectLocked(locked).getCurrentState(); + flags.reset(); + flags.setAspectLocked(locked).persist(); + flags.restoreState(currentState); + } + + public boolean isCropAspectLocked() { + return editorElementHierarchy.getCropEditorElement().getFlags().isAspectLocked(); + } + + public void dragDropRelease() { + editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate); + } + + public void setVisibleViewPort(@NonNull RectF visibleViewPort) { + this.visibleViewPort.set(visibleViewPort); + this.editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate); + } + + public Set getUniqueColorsIgnoringAlpha() { + final Set colors = new LinkedHashSet<>(); + + editorElementHierarchy.getRoot().forAllInTree(element -> { + Renderer renderer = element.getRenderer(); + if (renderer instanceof ColorableRenderer) { + colors.add(((ColorableRenderer) renderer).getColor() | 0xff000000); + } + }); + + return colors; + } + + public static final Creator CREATOR = new Creator() { + @Override + public EditorModel createFromParcel(Parcel in) { + return new EditorModel(in); + } + + @Override + public EditorModel[] newArray(int size) { + return new EditorModel[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(size.x); + dest.writeInt(size.y); + dest.writeParcelable(editorElementHierarchy.getRoot(), flags); + dest.writeParcelable(undoStack, flags); + dest.writeParcelable(redoStack, flags); + } + + /** + * Blocking render of the model. + */ + @WorkerThread + public Bitmap render(@NonNull Context context) { + EditorElement image = editorElementHierarchy.getFlipRotate(); + RectF cropRect = editorElementHierarchy.getCropRect(); + Point outputSize = getOutputSize(); + + Bitmap bitmap = Bitmap.createBitmap(outputSize.x, outputSize.y, Bitmap.Config.ARGB_8888); + try { + Canvas canvas = new Canvas(bitmap); + RendererContext rendererContext = new RendererContext(context, canvas, RendererContext.Ready.NULL, RendererContext.Invalidate.NULL); + + RectF bitmapArea = new RectF(); + bitmapArea.right = bitmap.getWidth(); + bitmapArea.bottom = bitmap.getHeight(); + + Matrix viewMatrix = new Matrix(); + viewMatrix.setRectToRect(cropRect, bitmapArea, Matrix.ScaleToFit.FILL); + + rendererContext.setIsEditing(false); + rendererContext.setBlockingLoad(true); + + EditorElement overlay = editorElementHierarchy.getOverlay(); + overlay.getFlags().setVisible(false).setChildrenVisible(false); + + try { + rendererContext.canvasMatrix.initial(viewMatrix); + image.draw(rendererContext); + } finally { + overlay.getFlags().reset(); + } + } catch (Exception e) { + bitmap.recycle(); + throw e; + } + return bitmap; + } + + @NonNull + private Point getOutputSize() { + PointF outputSize = editorElementHierarchy.getOutputSize(size); + + int width = (int) Math.max(MINIMUM_OUTPUT_WIDTH, outputSize.x); + int height = (int) (width * outputSize.y / outputSize.x); + + return new Point(width, height); + } + + @Override + public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) { + if (cropMatrix != null && size != null && isRendererOfMainImage(renderer)) { + Matrix imageCropMatrix = editorElementHierarchy.getImageCrop().getLocalMatrix(); + this.size.set(size.x, size.y); + if (imageCropMatrix.isIdentity()) { + imageCropMatrix.set(cropMatrix); + editorElementHierarchy.doneCrop(visibleViewPort, null); + } + } + } + + private boolean isRendererOfMainImage(@NonNull Renderer renderer) { + EditorElement mainImage = editorElementHierarchy.getMainImage(); + Renderer mainImageRenderer = mainImage != null ? mainImage.getRenderer() : null; + return mainImageRenderer == renderer; + } + + /** + * Add a new {@link EditorElement} centered in the current visible crop area. + * + * @param element New element to add. + * @param scale Initial scale for new element. + */ + public void addElementCentered(@NonNull EditorElement element, float scale) { + Matrix localMatrix = element.getLocalMatrix(); + + editorElementHierarchy.getMainImageFullMatrix().invert(localMatrix); + + localMatrix.preScale(scale, scale); + addElement(element); + } + + /** + * Add an element to the main image, or if there is no main image, make the new element the main image. + * + * @param element New element to add. + */ + public void addElement(@NonNull EditorElement element) { + pushUndoPoint(); + + EditorElement mainImage = editorElementHierarchy.getMainImage(); + EditorElement parent = mainImage != null ? mainImage : editorElementHierarchy.getImageRoot(); + + parent.addElement(element); + + if (parent != mainImage) { + undoStack.clear(); + } + } + + public boolean isChanged() { + return !undoStack.isEmpty() || undoStack.isOverflowed(); + } + + public RectF findCropRelativeToRoot() { + return findCropRelativeTo(editorElementHierarchy.getRoot()); + } + + private RectF findCropRelativeTo(EditorElement element) { + return findRelativeBounds(editorElementHierarchy.getCropEditorElement(), element); + } + + private RectF findRelativeBounds(EditorElement from, EditorElement to) { + Matrix matrix1 = findElementMatrix(from, new Matrix()); + Matrix matrix2 = findElementInverseMatrix(to, new Matrix()); + + RectF dst = new RectF(Bounds.FULL_BOUNDS); + if (matrix1 != null) { + matrix1.preConcat(matrix2); + + matrix1.mapRect(dst, Bounds.FULL_BOUNDS); + } + return dst; + } + + public void rotate90clockwise() { + pushUndoPoint(); + editorElementHierarchy.flipRotate(90, 1, 1, visibleViewPort, invalidate); + } + + public void rotate90anticlockwise() { + pushUndoPoint(); + editorElementHierarchy.flipRotate(-90, 1, 1, visibleViewPort, invalidate); + } + + public void flipHorizontal() { + pushUndoPoint(); + editorElementHierarchy.flipRotate(0, -1, 1, visibleViewPort, invalidate); + } + + public void flipVerticle() { + pushUndoPoint(); + editorElementHierarchy.flipRotate(0, 1, -1, visibleViewPort, invalidate); + } + + public EditorElement getRoot() { + return editorElementHierarchy.getRoot(); + } + + public void delete(@NonNull EditorElement editorElement) { + editorElementHierarchy.getImageRoot().forAllInTree(element -> element.deleteChild(editorElement, invalidate)); + } + + public @Nullable EditorElement findById(@NonNull UUID uuid) { + return getElementMap(getRoot()).get(uuid); + } + + /** + * Changes the temporary view so that the element is centered in it. + * + * @param entity Entity to center on. + * @param y An optional extra value to translate the view by to leave space for the keyboard for example. + * @param doNotZoomOut Iff true, undoes any zoom out + */ + public void zoomTo(@NonNull EditorElement entity, float y, boolean doNotZoomOut) { + Matrix elementInverseMatrix = findElementInverseMatrix(entity, new Matrix()); + if (elementInverseMatrix != null) { + elementInverseMatrix.preConcat(editorElementHierarchy.getRoot().getEditorMatrix()); + + float xScale = EditorElementHierarchy.xScale(elementInverseMatrix); + if (doNotZoomOut && xScale < 1) { + elementInverseMatrix.postScale(1 / xScale, 1 / xScale); + } + + elementInverseMatrix.postTranslate(0, y); + + editorElementHierarchy.getRoot().animateEditorTo(elementInverseMatrix, invalidate); + } + } + + public void zoomOut() { + editorElementHierarchy.getRoot().rollbackEditorMatrix(invalidate); + } + + public void indicateSelected(@NonNull EditorElement selected) { + selected.singleScalePulse(invalidate); + } + + public boolean isCropping() { + return editorElementHierarchy.getCropEditorElement().getFlags().isVisible(); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java b/src/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java new file mode 100644 index 000000000..3b0a1b588 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java @@ -0,0 +1,118 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.util.Arrays; +import java.util.Stack; + +/** + * Contains a stack of elements for undo and redo stacks. + *

+ * Elements are mutable, so this stack serializes the element and keeps a stack of serialized data. + *

+ * The stack has a {@link #limit} and if it exceeds that limit the {@link #overflowed} flag is set. + * So that when used as an undo stack, {@link #isEmpty()} and {@link #isOverflowed()} tell you if the image has ever changed. + */ +final class ElementStack implements Parcelable { + + private final int limit; + private final Stack stack = new Stack<>(); + private boolean overflowed; + + ElementStack(int limit) { + this.limit = limit; + } + + private ElementStack(@NonNull Parcel in) { + this(in.readInt()); + overflowed = in.readInt() != 0; + final int count = in.readInt(); + for (int i = 0; i < count; i++) { + stack.add(i, in.createByteArray()); + } + } + + /** + * Pushes an element to the stack iff the element's serialized value is different to any found at + * the top of the stack. + * + * @param element new editor element state. + * @return true iff the pushed item was different to the top item. + */ + boolean tryPush(@NonNull EditorElement element) { + Parcel parcel = Parcel.obtain(); + byte[] bytes; + try { + parcel.writeParcelable(element, 0); + bytes = parcel.marshall(); + } finally { + parcel.recycle(); + } + boolean push = stack.isEmpty() || !Arrays.equals(bytes, stack.peek()); + if (push) { + stack.push(bytes); + if (stack.size() > limit) { + stack.remove(0); + overflowed = true; + } + } + return push; + } + + @Nullable EditorElement pop() { + if (stack.empty()) return null; + + byte[] data = stack.pop(); + Parcel parcel = Parcel.obtain(); + try { + parcel.unmarshall(data, 0, data.length); + parcel.setDataPosition(0); + return parcel.readParcelable(EditorElement.class.getClassLoader()); + } finally { + parcel.recycle(); + } + } + + void clear() { + stack.clear(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public ElementStack createFromParcel(Parcel in) { + return new ElementStack(in); + } + + @Override + public ElementStack[] newArray(int size) { + return new ElementStack[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(limit); + dest.writeInt(overflowed ? 1 : 0); + final int count = stack.size(); + dest.writeInt(count); + for (int i = 0; i < count; i++) { + dest.writeByteArray(stack.get(i)); + } + } + + boolean isEmpty() { + return stack.isEmpty(); + } + + boolean isOverflowed() { + return overflowed; + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/ParcelUtils.java b/src/org/thoughtcrime/securesms/imageeditor/model/ParcelUtils.java new file mode 100644 index 000000000..8ef74329d --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/model/ParcelUtils.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import android.graphics.Matrix; +import android.os.Parcel; +import android.support.annotation.NonNull; + +import java.util.UUID; + +final class ParcelUtils { + + private ParcelUtils() { + } + + static void writeMatrix(@NonNull Parcel dest, @NonNull Matrix matrix) { + float[] values = new float[9]; + matrix.getValues(values); + dest.writeFloatArray(values); + } + + static void readMatrix(@NonNull Matrix matrix, @NonNull Parcel in) { + float[] values = new float[9]; + in.readFloatArray(values); + matrix.setValues(values); + } + + static UUID readUUID(@NonNull Parcel in) { + return new UUID(in.readLong(), in.readLong()); + } + + static void writeUUID(@NonNull Parcel dest, @NonNull UUID uuid) { + dest.writeLong(uuid.getMostSignificantBits()); + dest.writeLong(uuid.getLeastSignificantBits()); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/ThumbRenderer.java b/src/org/thoughtcrime/securesms/imageeditor/model/ThumbRenderer.java new file mode 100644 index 000000000..21020b525 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/model/ThumbRenderer.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.imageeditor.model; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; + +import java.util.UUID; + +/** + * A special {@link Renderer} that controls another {@link EditorElement}. + *

+ * It has a reference to the {@link EditorElement#getId()} and a {@link ControlPoint} which it is in control of. + *

+ * The presence of this interface on the selected element is used to launch a ThumbDragEditSession. + */ +public interface ThumbRenderer extends Renderer { + + enum ControlPoint { + + CENTER_LEFT (Bounds.LEFT, Bounds.CENTRE_Y), + CENTER_RIGHT (Bounds.RIGHT, Bounds.CENTRE_Y), + + TOP_CENTER (Bounds.CENTRE_X, Bounds.TOP), + BOTTOM_CENTER (Bounds.CENTRE_X, Bounds.BOTTOM), + + TOP_LEFT (Bounds.LEFT, Bounds.TOP), + TOP_RIGHT (Bounds.RIGHT, Bounds.TOP), + BOTTOM_LEFT (Bounds.LEFT, Bounds.BOTTOM), + BOTTOM_RIGHT (Bounds.RIGHT, Bounds.BOTTOM); + + private final float x; + private final float y; + + ControlPoint(float x, float y) { + this.x = x; + this.y = y; + } + + public float getX() { + return x; + } + + public float getY() { + return y; + } + + public ControlPoint opposite() { + switch (this) { + case CENTER_LEFT: return CENTER_RIGHT; + case CENTER_RIGHT: return CENTER_LEFT; + case TOP_CENTER: return BOTTOM_CENTER; + case BOTTOM_CENTER: return TOP_CENTER; + case TOP_LEFT: return BOTTOM_RIGHT; + case TOP_RIGHT: return BOTTOM_LEFT; + case BOTTOM_LEFT: return TOP_RIGHT; + case BOTTOM_RIGHT: return TOP_LEFT; + default: + throw new RuntimeException(); + } + } + + public boolean isHorizontalCenter() { + return this == ControlPoint.CENTER_LEFT || this == ControlPoint.CENTER_RIGHT; + } + + public boolean isVerticalCenter() { + return this == ControlPoint.TOP_CENTER || this == ControlPoint.BOTTOM_CENTER; + } + } + + ControlPoint getControlPoint(); + + UUID getElementToControl(); +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/renderers/AutomaticControlPointBezierLine.java b/src/org/thoughtcrime/securesms/imageeditor/renderers/AutomaticControlPointBezierLine.java new file mode 100644 index 000000000..22d5a4b8c --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/renderers/AutomaticControlPointBezierLine.java @@ -0,0 +1,224 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.util.Arrays; + +/** + * Given points for a line to go though, automatically finds control points. + *

+ * Based on http://www.particleincell.com/2012/bezier-splines/ + *

+ * Can then draw that line to a {@link Canvas} given a {@link Paint}. + *

+ * Allocation efficient so that adding new points does not result in lots of array allocations. + */ +final class AutomaticControlPointBezierLine implements Parcelable { + + private static final int INITIAL_CAPACITY = 256; + + private float[] x; + private float[] y; + + // control points + private float[] p1x; + private float[] p1y; + private float[] p2x; + private float[] p2y; + + private int count; + + private final Path path = new Path(); + + private AutomaticControlPointBezierLine(@Nullable float[] x, @Nullable float[] y, int count) { + this.count = count; + this.x = x != null ? x : new float[INITIAL_CAPACITY]; + this.y = y != null ? y : new float[INITIAL_CAPACITY]; + allocControlPointsAndWorkingMemory(this.x.length); + recalculateControlPoints(); + } + + AutomaticControlPointBezierLine() { + this(null, null, 0); + } + + void reset() { + count = 0; + path.reset(); + } + + /** + * Adds a new point to the end of the line but ignores points that are too close to the last. + * + * @param x new x point + * @param y new y point + * @param thickness the maximum distance to allow, line thickness is recommended. + */ + void addPointFiltered(float x, float y, float thickness) { + if (count > 0) { + float dx = this.x[count - 1] - x; + float dy = this.y[count - 1] - y; + if (dx * dx + dy * dy < thickness * thickness) { + return; + } + } + addPoint(x, y); + } + + /** + * Adds a new point to the end of the line. + * + * @param x new x point + * @param y new y point + */ + void addPoint(float x, float y) { + if (this.x == null || count == this.x.length) { + resize(this.x != null ? this.x.length << 1 : INITIAL_CAPACITY); + } + + this.x[count] = x; + this.y[count] = y; + count++; + + recalculateControlPoints(); + } + + private void resize(int newCapacity) { + x = Arrays.copyOf(x, newCapacity); + y = Arrays.copyOf(y, newCapacity); + allocControlPointsAndWorkingMemory(newCapacity - 1); + } + + private void allocControlPointsAndWorkingMemory(int max) { + p1x = new float[max]; + p1y = new float[max]; + p2x = new float[max]; + p2y = new float[max]; + + a = new float[max]; + b = new float[max]; + c = new float[max]; + r = new float[max]; + } + + private void recalculateControlPoints() { + path.reset(); + + if (count > 2) { + computeControlPoints(x, p1x, p2x, count); + computeControlPoints(y, p1y, p2y, count); + } + + path.moveTo(x[0], y[0]); + switch (count) { + case 1: + path.lineTo(x[0], y[0]); + break; + case 2: + path.lineTo(x[1], y[1]); + break; + default: + for (int i = 1; i < count - 1; i++) { + path.cubicTo(p1x[i], p1y[i], p2x[i], p2y[i], x[i + 1], y[i + 1]); + } + } + } + + /** + * Draw the line. + * + * @param canvas The canvas to draw on. + * @param paint The paint to use. + */ + void draw(@NonNull Canvas canvas, @NonNull Paint paint) { + canvas.drawPath(path, paint); + } + + // rhs vector for computeControlPoints method + private float[] a; + private float[] b; + private float[] c; + private float[] r; + + /** + * Based on http://www.particleincell.com/2012/bezier-splines/ + * + * @param k knots x or y, must be at least 2 entries + * @param p1 corresponding first control point x or y + * @param p2 corresponding second control point x or y + * @param count number of k to process + */ + private void computeControlPoints(float[] k, float[] p1, float[] p2, int count) { + final int n = count - 1; + + // left most segment + a[0] = 0; + b[0] = 2; + c[0] = 1; + r[0] = k[0] + 2 * k[1]; + + // internal segments + for (int i = 1; i < n - 1; i++) { + a[i] = 1; + b[i] = 4; + c[i] = 1; + r[i] = 4 * k[i] + 2 * k[i + 1]; + } + + // right segment + a[n - 1] = 2; + b[n - 1] = 7; + c[n - 1] = 0; + r[n - 1] = 8 * k[n - 1] + k[n]; + + // solves Ax=b with the Thomas algorithm + for (int i = 1; i < n; i++) { + float m = a[i] / b[i - 1]; + b[i] = b[i] - m * c[i - 1]; + r[i] = r[i] - m * r[i - 1]; + } + + p1[n - 1] = r[n - 1] / b[n - 1]; + for (int i = n - 2; i >= 0; --i) { + p1[i] = (r[i] - c[i] * p1[i + 1]) / b[i]; + } + + // we have p1, now compute p2 + for (int i = 0; i < n - 1; i++) { + p2[i] = 2 * k[i + 1] - p1[i + 1]; + } + + p2[n - 1] = 0.5f * (k[n] + p1[n - 1]); + } + + public static final Creator CREATOR = new Creator() { + @Override + public AutomaticControlPointBezierLine createFromParcel(Parcel in) { + float[] x = in.createFloatArray(); + float[] y = in.createFloatArray(); + return new AutomaticControlPointBezierLine(x, y, x != null ? x.length : 0); + } + + @Override + public AutomaticControlPointBezierLine[] newArray(int size) { + return new AutomaticControlPointBezierLine[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeFloatArray(Arrays.copyOfRange(x, 0, count)); + dest.writeFloatArray(Arrays.copyOfRange(y, 0, count)); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/renderers/BezierDrawingRenderer.java b/src/org/thoughtcrime/securesms/imageeditor/renderers/BezierDrawingRenderer.java new file mode 100644 index 000000000..6259e794b --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/renderers/BezierDrawingRenderer.java @@ -0,0 +1,144 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Parcel; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders a {@link AutomaticControlPointBezierLine} with {@link #thickness}, {@link #color} and {@link #cap} end type. + */ +public final class BezierDrawingRenderer extends InvalidateableRenderer implements ColorableRenderer { + + private final Paint paint; + private final AutomaticControlPointBezierLine bezierLine; + private final Paint.Cap cap; + + @Nullable + private final RectF clipRect; + + private int color; + private float thickness; + + private BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable AutomaticControlPointBezierLine bezierLine, @Nullable RectF clipRect) { + this.paint = new Paint(); + this.color = color; + this.thickness = thickness; + this.cap = cap; + this.clipRect = clipRect; + this.bezierLine = bezierLine != null ? bezierLine : new AutomaticControlPointBezierLine(); + + updatePaint(); + } + + public BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable RectF clipRect) { + this(color, thickness, cap,null, clipRect != null ? new RectF(clipRect) : null); + } + + @Override + public int getColor() { + return color; + } + + @Override + public void setColor(int color) { + if (this.color != color) { + this.color = color; + updatePaint(); + invalidate(); + } + } + + public void setThickness(float thickness) { + if (this.thickness != thickness) { + this.thickness = thickness; + updatePaint(); + invalidate(); + } + } + + private void updatePaint() { + paint.setColor(color); + paint.setStrokeWidth(thickness); + paint.setStyle(Paint.Style.STROKE); + paint.setAntiAlias(true); + paint.setStrokeCap(cap); + } + + public void setFirstPoint(PointF point) { + bezierLine.reset(); + bezierLine.addPoint(point.x, point.y); + invalidate(); + } + + public void addNewPoint(PointF point) { + if (cap != Paint.Cap.ROUND) { + bezierLine.addPointFiltered(point.x, point.y, thickness * 0.5f); + } else { + bezierLine.addPoint(point.x, point.y); + } + invalidate(); + } + + @Override + public void render(@NonNull RendererContext rendererContext) { + super.render(rendererContext); + Canvas canvas = rendererContext.canvas; + canvas.save(); + if (clipRect != null) { + canvas.clipRect(clipRect); + } + + int alpha = paint.getAlpha(); + paint.setAlpha(rendererContext.getAlpha(alpha)); + + bezierLine.draw(canvas, paint); + + paint.setAlpha(alpha); + rendererContext.canvas.restore(); + } + + @Override + public boolean hitTest(float x, float y) { + return false; + } + + public static final Creator CREATOR = new Creator() { + @Override + public BezierDrawingRenderer createFromParcel(Parcel in) { + int color = in.readInt(); + float thickness = in.readFloat(); + Paint.Cap cap = Paint.Cap.values()[in.readInt()]; + AutomaticControlPointBezierLine bezierLine = in.readParcelable(AutomaticControlPointBezierLine.class.getClassLoader()); + RectF clipRect = in.readParcelable(RectF.class.getClassLoader()); + + return new BezierDrawingRenderer(color, thickness, cap, bezierLine, clipRect); + } + + @Override + public BezierDrawingRenderer[] newArray(int size) { + return new BezierDrawingRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(color); + dest.writeFloat(thickness); + dest.writeInt(cap.ordinal()); + dest.writeParcelable(bezierLine, flags); + dest.writeParcelable(clipRect, flags); + } + +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java b/src/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java new file mode 100644 index 000000000..67f26354f --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/renderers/CropAreaRenderer.java @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.os.Parcel; +import android.support.annotation.ColorRes; +import android.support.annotation.NonNull; +import android.support.v4.content.res.ResourcesCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders a box outside of the current crop area using {@link R.color#crop_area_renderer_outer_color} + * and around the edge it renders the markers for the thumbs using {@link R.color#crop_area_renderer_edge_color}, + * {@link R.dimen#crop_area_renderer_edge_thickness} and {@link R.dimen#crop_area_renderer_edge_size}. + *

+ * Hit tests outside of the bounds. + */ +public final class CropAreaRenderer implements Renderer { + + @ColorRes + private final int color; + + private final Path cropClipPath = new Path(); + private final Path screenClipPath = new Path(); + + private final RectF dst = new RectF(); + private final Paint paint = new Paint(); + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.save(); + + Canvas canvas = rendererContext.canvas; + Resources resources = rendererContext.context.getResources(); + + canvas.clipPath(cropClipPath); + canvas.drawColor(ResourcesCompat.getColor(resources, color, null)); + + rendererContext.mapRect(dst, Bounds.FULL_BOUNDS); + + final int thickness = resources.getDimensionPixelSize(R.dimen.crop_area_renderer_edge_thickness); + final int size = (int) Math.min(resources.getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size), Math.min(dst.width(), dst.height()) / 3f - 10); + + paint.setColor(ResourcesCompat.getColor(resources, R.color.crop_area_renderer_edge_color, null)); + + rendererContext.canvasMatrix.setToIdentity(); + screenClipPath.reset(); + screenClipPath.moveTo(dst.left, dst.top); + screenClipPath.lineTo(dst.right, dst.top); + screenClipPath.lineTo(dst.right, dst.bottom); + screenClipPath.lineTo(dst.left, dst.bottom); + screenClipPath.close(); + canvas.clipPath(screenClipPath); + canvas.translate(dst.left, dst.top); + + float halfDx = (dst.right - dst.left - size + thickness) / 2; + float halfDy = (dst.bottom - dst.top - size + thickness) / 2; + + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(0, halfDy); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(0, halfDy); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(halfDx, 0); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(halfDx, 0); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(0, -halfDy); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(0, -halfDy); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + canvas.translate(-halfDx, 0); + canvas.drawRect(-thickness, -thickness, size, size, paint); + + rendererContext.restore(); + } + + public CropAreaRenderer(@ColorRes int color) { + this.color = color; + cropClipPath.toggleInverseFillType(); + cropClipPath.moveTo(Bounds.LEFT, Bounds.TOP); + cropClipPath.lineTo(Bounds.RIGHT, Bounds.TOP); + cropClipPath.lineTo(Bounds.RIGHT, Bounds.BOTTOM); + cropClipPath.lineTo(Bounds.LEFT, Bounds.BOTTOM); + cropClipPath.close(); + screenClipPath.toggleInverseFillType(); + } + + private CropAreaRenderer(Parcel in) { + this(in.readInt()); + } + + @Override + public boolean hitTest(float x, float y) { + return !Bounds.FULL_BOUNDS.contains(x, y); + } + + public static final Creator CREATOR = new Creator() { + @Override + public CropAreaRenderer createFromParcel(Parcel in) { + return new CropAreaRenderer(in); + } + + @Override + public CropAreaRenderer[] newArray(int size) { + return new CropAreaRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(color); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/renderers/InvalidateableRenderer.java b/src/org/thoughtcrime/securesms/imageeditor/renderers/InvalidateableRenderer.java new file mode 100644 index 000000000..88c5549d0 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/renderers/InvalidateableRenderer.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.support.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.lang.ref.WeakReference; + +/** + * Maintains a weak reference to the an invalidate callback allowing future invalidation without memory leak risk. + */ +abstract class InvalidateableRenderer implements Renderer { + + private WeakReference invalidate = new WeakReference<>(null); + + @Override + public void render(@NonNull RendererContext rendererContext) { + setInvalidate(rendererContext.invalidate); + } + + private void setInvalidate(RendererContext.Invalidate invalidate) { + if (invalidate != this.invalidate.get()) { + this.invalidate = new WeakReference<>(invalidate); + } + } + + protected void invalidate() { + RendererContext.Invalidate invalidate = this.invalidate.get(); + if (invalidate != null) { + invalidate.onInvalidate(this); + } + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/renderers/InverseFillRenderer.java b/src/org/thoughtcrime/securesms/imageeditor/renderers/InverseFillRenderer.java new file mode 100644 index 000000000..71f42c687 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/renderers/InverseFillRenderer.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.graphics.Path; +import android.os.Parcel; +import android.support.annotation.ColorInt; +import android.support.annotation.NonNull; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders the {@link color} outside of the {@link Bounds}. + *

+ * Hit tests outside of the bounds. + */ +public final class InverseFillRenderer implements Renderer { + + private final int color; + + private final Path path = new Path(); + + @Override + public void render(@NonNull RendererContext rendererContext) { + rendererContext.canvas.save(); + rendererContext.canvas.clipPath(path); + rendererContext.canvas.drawColor(color); + rendererContext.canvas.restore(); + } + + public InverseFillRenderer(@ColorInt int color) { + this.color = color; + path.toggleInverseFillType(); + path.moveTo(Bounds.LEFT, Bounds.TOP); + path.lineTo(Bounds.RIGHT, Bounds.TOP); + path.lineTo(Bounds.RIGHT, Bounds.BOTTOM); + path.lineTo(Bounds.LEFT, Bounds.BOTTOM); + path.close(); + } + + private InverseFillRenderer(Parcel in) { + this(in.readInt()); + } + + @Override + public boolean hitTest(float x, float y) { + return !Bounds.FULL_BOUNDS.contains(x, y); + } + + public static final Creator CREATOR = new Creator() { + @Override + public InverseFillRenderer createFromParcel(Parcel in) { + return new InverseFillRenderer(in); + } + + @Override + public InverseFillRenderer[] newArray(int size) { + return new InverseFillRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(color); + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/renderers/TextRenderer.java b/src/org/thoughtcrime/securesms/imageeditor/renderers/TextRenderer.java new file mode 100644 index 000000000..182f459c4 --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/renderers/TextRenderer.java @@ -0,0 +1,204 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.os.Parcel; +import android.support.annotation.ColorInt; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +/** + * Renders a single line of {@link #text} in ths specified {@link #color}. + *

+ * Scales down the text size to fit inside the {@link Bounds} width. + */ +public final class TextRenderer extends InvalidateableRenderer implements ColorableRenderer { + + @NonNull + private String text = ""; + + @ColorInt + private int color; + + private final Paint paint = new Paint(); + private final Paint selectionPaint = new Paint(); + private final RectF textBounds = new RectF(); + private final RectF selectionBounds = new RectF(); + private final RectF maxTextBounds = new RectF(); + private final Matrix projectionMatrix = new Matrix(); + private final Matrix inverseProjectionMatrix = new Matrix(); + + private final float textScale; + + private float xForCentre; + private int selStart; + private int selEnd; + private boolean hasFocus; + + public TextRenderer(@Nullable String text, @ColorInt int color) { + setColor(color); + float regularTextSize = paint.getTextSize(); + paint.setAntiAlias(true); + paint.setTextSize(100); + paint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); + textScale = paint.getTextSize() / regularTextSize; + selectionPaint.setAntiAlias(true); + setText(text != null ? text : ""); + } + + private TextRenderer(Parcel in) { + this(in.readString(), in.readInt()); + } + + public static final Creator CREATOR = new Creator() { + @Override + public TextRenderer createFromParcel(Parcel in) { + return new TextRenderer(in); + } + + @Override + public TextRenderer[] newArray(int size) { + return new TextRenderer[size]; + } + }; + + @Override + public void render(@NonNull RendererContext rendererContext) { + super.render(rendererContext); + rendererContext.save(); + Canvas canvas = rendererContext.canvas; + + rendererContext.canvasMatrix.concat(projectionMatrix); + + canvas.clipRect(textBounds); + + if (hasFocus) { + canvas.drawRect(selectionBounds, selectionPaint); + } + + int alpha = paint.getAlpha(); + paint.setAlpha(rendererContext.getAlpha(alpha)); + + canvas.drawText(text, xForCentre, 0, paint); + + paint.setAlpha(alpha); + + rendererContext.restore(); + } + + @NonNull + public String getText() { + return text; + } + + public void setText(@NonNull String text) { + if (!this.text.equals(text)) { + this.text = text; + recalculate(); + } + } + + private void recalculate() { + Rect temp = new Rect(); + + getTextBoundsWithoutTrim(text, 0, text.length(), temp); + textBounds.set(temp); + + maxTextBounds.set(textBounds); + maxTextBounds.right = Math.max(150 * textScale, maxTextBounds.right); + + xForCentre = maxTextBounds.centerX() - textBounds.centerX(); + + textBounds.left += xForCentre; + textBounds.right += xForCentre; + + if (selStart != selEnd) { + getTextBoundsWithoutTrim(text, Math.min(text.length(), selStart), Math.min(text.length(), selEnd), temp); + } else { + Rect startTemp = new Rect(); + int start = Math.min(text.length(), selStart); + String text = this.text.substring(0, start); + + getTextBoundsWithoutTrim(text, 0, start, startTemp); + paint.getTextBounds("|", 0, 1, temp); + + int width = temp.width(); + + temp.left -= width; + temp.right -= width; + temp.left += startTemp.right; + temp.right += startTemp.right; + } + selectionBounds.set(temp); + selectionBounds.left += xForCentre; + selectionBounds.right += xForCentre; + + projectionMatrix.setRectToRect(new RectF(maxTextBounds), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER); + projectionMatrix.invert(inverseProjectionMatrix); + invalidate(); + } + + private void getTextBoundsWithoutTrim(String text, int start, int end, Rect result) { + Rect extra = new Rect(); + Rect xBounds = new Rect(); + String cannotBeTrimmed = "x" + text.substring(start, end) + "x"; + paint.getTextBounds(cannotBeTrimmed, 0, cannotBeTrimmed.length(), extra); + paint.getTextBounds("x", 0, 1, xBounds); + result.set(extra); + result.right -= 2 * xBounds.width(); + } + + @Override + public int getColor() { + return color; + } + + @Override + public void setColor(@ColorInt int color) { + if (this.color != color) { + this.color = color; + paint.setColor(color); + selectionPaint.setColor(color & ~0xff000000 | 0x7f000000); + invalidate(); + } + } + + @Override + public boolean hitTest(float x, float y) { + float[] dst = new float[2]; + inverseProjectionMatrix.mapPoints(dst, new float[]{ x, y }); + return textBounds.contains(dst[0], dst[1]); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(text); + dest.writeInt(color); + } + + public void setSelection(int selStart, int selEnd) { + this.selStart = selStart; + this.selEnd = selEnd; + recalculate(); + } + + public void setFocused(boolean hasFocus) { + if (this.hasFocus != hasFocus) { + this.hasFocus = hasFocus; + invalidate(); + } + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index 289fd2332..41edfe10e 100644 --- a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -27,7 +27,7 @@ import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.scribbles.ScribbleFragment; +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.Util; @@ -49,7 +49,7 @@ import java.util.Locale; public class MediaSendActivity extends PassphraseRequiredActionBarActivity implements MediaPickerFolderFragment.Controller, MediaPickerItemFragment.Controller, MediaSendFragment.Controller, - ScribbleFragment.Controller, + ImageEditorFragment.Controller, Camera1Fragment.Controller { private static final String TAG = MediaSendActivity.class.getSimpleName(); @@ -446,4 +446,12 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple button.startAnimation(grow); } + + @Override + public void onRequestFullScreen(boolean fullScreen) { + MediaSendFragment sendFragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND); + if (sendFragment != null && sendFragment.isVisible()) { + sendFragment.onRequestFullScreen(fullScreen); + } + } } diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java index 496158ac3..071771b9f 100644 --- a/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java @@ -28,6 +28,7 @@ import android.view.WindowManager; import android.view.inputmethod.EditorInfo; import android.widget.TextView; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.components.ComposeText; @@ -43,7 +44,7 @@ import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.scribbles.widget.ScribbleView; +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.Stopwatch; @@ -51,6 +52,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.concurrent.SettableFuture; import org.thoughtcrime.securesms.util.views.Stub; import org.whispersystems.libsignal.util.guava.Optional; @@ -79,6 +81,7 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl private static final String KEY_LOCALE = "locale"; private InputAwareLayout hud; + private View captionAndRail; private SendButton sendButton; private ComposeText composeText; private ViewGroup composeContainer; @@ -140,6 +143,7 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl @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); @@ -313,7 +317,9 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl } public void onTouchEventsNeeded(boolean needed) { - fragmentPager.setEnabled(!needed); + if (fragmentPager != null) { + fragmentPager.setEnabled(!needed); + } } public boolean handleBackPress() { @@ -423,8 +429,11 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl for (Media media : mediaList) { Object state = savedState.get(media.getUri()); - if (state instanceof ScribbleView.SavedState && !((ScribbleView.SavedState) state).isEmpty()) { - futures.put(media, ScribbleView.renderImage(requireContext(), media.getUri(), (ScribbleView.SavedState) state, GlideApp.with(this))); + if (state instanceof ImageEditorFragment.Data) { + EditorModel model = ((ImageEditorFragment.Data) state).readModel(); + if (model != null && model.isChanged()) { + futures.put(media, render(requireContext(), model)); + } } } @@ -493,6 +502,18 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl }.execute(); } + private static ListenableFuture render(@NonNull Context context, @NonNull EditorModel model) { + SettableFuture 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) { diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java index 8830027c7..31e718d12 100644 --- a/src/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java @@ -9,15 +9,12 @@ import android.support.v4.app.FragmentStatePagerAdapter; import android.view.View; import android.view.ViewGroup; -import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.scribbles.ScribbleFragment; +import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; import org.thoughtcrime.securesms.util.MediaUtil; -import org.whispersystems.libsignal.util.guava.Optional; import java.util.ArrayList; import java.util.HashMap; import java.util.List; -import java.util.Locale; import java.util.Map; class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter { @@ -40,7 +37,7 @@ class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter { if (MediaUtil.isGif(mediaItem.getMimeType())) { return MediaSendGifFragment.newInstance(mediaItem.getUri()); } else if (MediaUtil.isImageType(mediaItem.getMimeType())) { - return ScribbleFragment.newInstance(mediaItem.getUri()); + return ImageEditorFragment.newInstance(mediaItem.getUri()); } else if (MediaUtil.isVideoType(mediaItem.getMimeType())) { return MediaSendVideoFragment.newInstance(mediaItem.getUri()); } else { diff --git a/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java new file mode 100644 index 000000000..2c2e37e45 --- /dev/null +++ b/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -0,0 +1,376 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.content.Intent; +import android.graphics.Paint; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.ImageEditorView; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.model.EditorModel; +import org.thoughtcrime.securesms.imageeditor.renderers.TextRenderer; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mediasend.MediaSendPageFragment; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.mms.PushMediaConstraints; +import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker; +import org.thoughtcrime.securesms.util.ParcelUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import static android.app.Activity.RESULT_OK; + +public final class ImageEditorFragment extends Fragment implements ImageEditorHud.EventListener, + VerticalSlideColorPicker.OnColorChangeListener, + MediaSendPageFragment { + + private static final String TAG = Log.tag(ImageEditorFragment.class); + + private static final String KEY_IMAGE_URI = "image_uri"; + + public static final int SELECT_STICKER_REQUEST_CODE = 123; + + private EditorModel restoredModel; + + @Nullable + private EditorElement currentSelection; + private int imageMaxHeight; + private int imageMaxWidth; + + public static class Data { + private final Bundle bundle; + + Data(Bundle bundle) { + this.bundle = bundle; + } + + public Data() { + this(new Bundle()); + } + + void writeModel(@NonNull EditorModel model) { + byte[] bytes = ParcelUtil.serialize(model); + bundle.putByteArray("MODEL", bytes); + } + + @Nullable + public EditorModel readModel() { + byte[] bytes = bundle.getByteArray("MODEL"); + if (bytes == null) { + return null; + } + return ParcelUtil.deserialize(bytes, EditorModel.CREATOR); + } + } + + private Uri imageUri; + private Controller controller; + private ImageEditorHud imageEditorHud; + private ImageEditorView imageEditorView; + + public static ImageEditorFragment newInstance(@NonNull Uri imageUri) { + Bundle args = new Bundle(); + args.putParcelable(KEY_IMAGE_URI, imageUri); + + ImageEditorFragment fragment = new ImageEditorFragment(); + fragment.setArguments(args); + fragment.setUri(imageUri); + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (!(getActivity() instanceof Controller)) { + throw new IllegalStateException("Parent activity must implement Controller interface."); + } + controller = (Controller) getActivity(); + Bundle arguments = getArguments(); + if (arguments != null) { + imageUri = arguments.getParcelable(KEY_IMAGE_URI); + } + + if (imageUri == null) { + throw new AssertionError("No KEY_IMAGE_URI supplied"); + } + + MediaConstraints mediaConstraints = new PushMediaConstraints(); + + imageMaxWidth = mediaConstraints.getImageMaxWidth(requireContext()); + imageMaxHeight = mediaConstraints.getImageMaxHeight(requireContext()); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.image_editor_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + imageEditorHud = view.findViewById(R.id.scribble_hud); + imageEditorView = view.findViewById(R.id.image_editor_view); + + imageEditorHud.setEventListener(this); + + imageEditorView.setTapListener(selectionListener); + imageEditorView.setDrawingChangedListener(this::refreshUniqueColors); + + EditorModel editorModel = null; + + if (restoredModel != null) { + editorModel = restoredModel; + restoredModel = null; + } else if (savedInstanceState != null) { + editorModel = new Data(savedInstanceState).readModel(); + } + + if (editorModel == null) { + editorModel = new EditorModel(); + EditorElement image = new EditorElement(new UriGlideRenderer(imageUri, true, imageMaxWidth, imageMaxHeight)); + image.getFlags().setSelectable(false).persist(); + editorModel.addElement(image); + } + + imageEditorView.setModel(editorModel); + + refreshUniqueColors(); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + new Data(outState).writeModel(imageEditorView.getModel()); + } + + @Override + public void setUri(@NonNull Uri uri) { + this.imageUri = uri; + } + + @NonNull + @Override + public Uri getUri() { + return imageUri; + } + + @Nullable + @Override + public View getPlaybackControls() { + return null; + } + + @Override + public Object saveState() { + Data data = new Data(); + data.writeModel(imageEditorView.getModel()); + return data; + } + + @Override + public void restoreState(@NonNull Object state) { + if (state instanceof Data) { + + Data data = (Data) state; + EditorModel model = data.readModel(); + + if (model != null) { + if (imageEditorView != null) { + imageEditorView.setModel(model); + refreshUniqueColors(); + } else { + this.restoredModel = model; + } + } + } else { + Log.w(TAG, "Received a bad saved state. Received class: " + state.getClass().getName()); + } + } + + private void changeEntityColor(int selectedColor) { + if (currentSelection != null) { + Renderer renderer = currentSelection.getRenderer(); + if (renderer instanceof ColorableRenderer) { + ((ColorableRenderer) renderer).setColor(selectedColor); + refreshUniqueColors(); + } + } + } + + private void startTextEntityEditing(@NonNull EditorElement textElement, boolean selectAll) { + imageEditorView.startTextEditing(textElement, TextSecurePreferences.isIncognitoKeyboardEnabled(requireContext()), selectAll); + } + + protected void addText() { + String initialText = requireContext().getString(R.string.ImageEditorFragment_initial_text); + int color = imageEditorHud.getActiveColor(); + TextRenderer renderer = new TextRenderer(initialText, color); + EditorElement element = new EditorElement(renderer); + + imageEditorView.getModel().addElementCentered(element, 1); + imageEditorView.invalidate(); + + currentSelection = element; + + startTextEntityEditing(element, true); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == RESULT_OK && requestCode == SELECT_STICKER_REQUEST_CODE && data != null) { + final String stickerFile = data.getStringExtra(StickerSelectActivity.EXTRA_STICKER_FILE); + + UriGlideRenderer renderer = new UriGlideRenderer(Uri.parse("file:///android_asset/" + stickerFile), false, imageMaxWidth, imageMaxHeight); + EditorElement element = new EditorElement(renderer); + imageEditorView.getModel().addElementCentered(element, 0.2f); + currentSelection = element; + } + } + + @Override + public void onModeStarted(@NonNull ImageEditorHud.Mode mode) { + imageEditorView.setMode(ImageEditorView.Mode.MoveAndResize); + imageEditorView.doneTextEditing(); + + controller.onTouchEventsNeeded(mode != ImageEditorHud.Mode.NONE); + + switch (mode) { + case CROP: + imageEditorView.getModel().startCrop(); + break; + + case DRAW: + imageEditorView.startDrawing(0.01f, Paint.Cap.ROUND); + break; + + case HIGHLIGHT: + imageEditorView.startDrawing(0.03f, Paint.Cap.SQUARE); + break; + + case TEXT: + addText(); + break; + + case MOVE_DELETE: + Intent intent = new Intent(getContext(), StickerSelectActivity.class); + startActivityForResult(intent, SELECT_STICKER_REQUEST_CODE); + break; + + case NONE: + imageEditorView.getModel().doneCrop(); + currentSelection = null; + break; + } + } + + @Override + public void onColorChange(int color) { + imageEditorView.setDrawingBrushColor(color); + changeEntityColor(color); + } + + @Override + public void onUndo() { + imageEditorView.getModel().undo(); + refreshUniqueColors(); + } + + @Override + public void onDelete() { + imageEditorView.deleteElement(currentSelection); + refreshUniqueColors(); + } + + @Override + public void onFlipHorizontal() { + imageEditorView.getModel().flipHorizontal(); + } + + @Override + public void onRotate90AntiClockwise() { + imageEditorView.getModel().rotate90anticlockwise(); + } + + @Override + public void onCropAspectLock(boolean locked) { + imageEditorView.getModel().setCropAspectLock(locked); + } + + @Override + public boolean isCropAspectLocked() { + return imageEditorView.getModel().isCropAspectLocked(); + } + + @Override + public void onRequestFullScreen(boolean fullScreen) { + controller.onRequestFullScreen(fullScreen); + } + + private void refreshUniqueColors() { + imageEditorHud.setColorPalette(imageEditorView.getModel().getUniqueColorsIgnoringAlpha()); + } + + private final ImageEditorView.TapListener selectionListener = new ImageEditorView.TapListener() { + + @Override + public void onEntityDown(@Nullable EditorElement editorElement) { + if (editorElement != null) { + controller.onTouchEventsNeeded(true); + } else { + currentSelection = null; + controller.onTouchEventsNeeded(false); + imageEditorHud.enterMode(ImageEditorHud.Mode.NONE); + imageEditorView.doneTextEditing(); + } + } + + @Override + public void onEntitySingleTap(@Nullable EditorElement editorElement) { + currentSelection = editorElement; + if (currentSelection != null) { + if (editorElement.getRenderer() instanceof TextRenderer) { + setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), imageEditorView.isTextEditing()); + } else { + imageEditorHud.enterMode(ImageEditorHud.Mode.MOVE_DELETE); + } + } + } + + @Override + public void onEntityDoubleTap(@NonNull EditorElement editorElement) { + currentSelection = editorElement; + if (editorElement.getRenderer() instanceof TextRenderer) { + setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), true); + } + } + + private void setTextElement(@NonNull EditorElement editorElement, + @NonNull ColorableRenderer colorableRenderer, + boolean startEditing) + { + int color = colorableRenderer.getColor(); + imageEditorHud.enterMode(ImageEditorHud.Mode.TEXT); + imageEditorHud.setActiveColor(color); + if (startEditing) { + startTextEntityEditing(editorElement, false); + } + } + }; + + public interface Controller { + void onTouchEventsNeeded(boolean needed); + + void onRequestFullScreen(boolean fullScreen); + } +} diff --git a/src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java b/src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java new file mode 100644 index 000000000..7b36e82dd --- /dev/null +++ b/src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java @@ -0,0 +1,274 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.content.Context; +import android.graphics.Color; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.scribbles.widget.ColorPaletteAdapter; +import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * The HUD (heads-up display) that contains all of the tools for interacting with + * {@link org.thoughtcrime.securesms.imageeditor.ImageEditorView} + */ +public final class ImageEditorHud extends LinearLayout { + + private View cropButton; + private View cropFlipButton; + private View cropRotateButton; + private ImageView cropAspectLock; + private View drawButton; + private View highlightButton; + private View textButton; + private View stickerButton; + private View undoButton; + private View deleteButton; + private View confirmButton; + private VerticalSlideColorPicker colorPicker; + private RecyclerView colorPalette; + + @NonNull + private EventListener eventListener = NULL_EVENT_LISTENER; + @Nullable + private ColorPaletteAdapter colorPaletteAdapter; + + private final Map> visibilityModeMap = new HashMap<>(); + private final Set allViews = new HashSet<>(); + + public ImageEditorHud(@NonNull Context context) { + super(context); + initialize(); + } + + public ImageEditorHud(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public ImageEditorHud(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(); + } + + private void initialize() { + inflate(getContext(), R.layout.image_editor_hud, this); + setOrientation(VERTICAL); + + cropButton = findViewById(R.id.scribble_crop_button); + cropFlipButton = findViewById(R.id.scribble_crop_flip); + cropRotateButton = findViewById(R.id.scribble_crop_rotate); + cropAspectLock = findViewById(R.id.scribble_crop_aspect_lock); + colorPalette = findViewById(R.id.scribble_color_palette); + drawButton = findViewById(R.id.scribble_draw_button); + highlightButton = findViewById(R.id.scribble_highlight_button); + textButton = findViewById(R.id.scribble_text_button); + stickerButton = findViewById(R.id.scribble_sticker_button); + undoButton = findViewById(R.id.scribble_undo_button); + deleteButton = findViewById(R.id.scribble_delete_button); + confirmButton = findViewById(R.id.scribble_confirm_button); + colorPicker = findViewById(R.id.scribble_color_picker); + + cropAspectLock.setOnClickListener(v -> { + eventListener.onCropAspectLock(!eventListener.isCropAspectLocked()); + updateCropAspectLockImage(eventListener.isCropAspectLocked()); + }); + + initializeViews(); + initializeVisibilityMap(); + setMode(Mode.NONE); + } + + private void updateCropAspectLockImage(boolean cropAspectLocked) { + cropAspectLock.setImageDrawable(getResources().getDrawable(cropAspectLocked ? R.drawable.ic_crop_lock_32 : R.drawable.ic_crop_unlock_32)); + } + + private void initializeVisibilityMap() { + setVisibleViewsWhenInMode(Mode.NONE, drawButton, highlightButton, textButton, stickerButton, cropButton, undoButton); + + setVisibleViewsWhenInMode(Mode.DRAW, confirmButton, undoButton, colorPicker, colorPalette); + + setVisibleViewsWhenInMode(Mode.HIGHLIGHT, confirmButton, undoButton, colorPicker, colorPalette); + + setVisibleViewsWhenInMode(Mode.TEXT, confirmButton, deleteButton, colorPicker, colorPalette); + + setVisibleViewsWhenInMode(Mode.MOVE_DELETE, confirmButton, deleteButton); + + setVisibleViewsWhenInMode(Mode.CROP, confirmButton, cropFlipButton, cropRotateButton, cropAspectLock); + + for (Set views : visibilityModeMap.values()) { + allViews.addAll(views); + } + } + + private void setVisibleViewsWhenInMode(Mode mode, View... views) { + visibilityModeMap.put(mode, new HashSet<>(Arrays.asList(views))); + } + + private void initializeViews() { + undoButton.setOnClickListener(v -> eventListener.onUndo()); + + deleteButton.setOnClickListener(v -> { + eventListener.onDelete(); + setMode(Mode.NONE); + }); + + cropButton.setOnClickListener(v -> setMode(Mode.CROP)); + cropFlipButton.setOnClickListener(v -> eventListener.onFlipHorizontal()); + cropRotateButton.setOnClickListener(v -> eventListener.onRotate90AntiClockwise()); + + confirmButton.setOnClickListener(v -> setMode(Mode.NONE)); + + colorPaletteAdapter = new ColorPaletteAdapter(); + colorPaletteAdapter.setEventListener(colorPicker::setActiveColor); + + colorPalette.setLayoutManager(new LinearLayoutManager(getContext())); + colorPalette.setAdapter(colorPaletteAdapter); + + drawButton.setOnClickListener(v -> setMode(Mode.DRAW)); + highlightButton.setOnClickListener(v -> setMode(Mode.HIGHLIGHT)); + textButton.setOnClickListener(v -> setMode(Mode.TEXT)); + stickerButton.setOnClickListener(v -> setMode(Mode.MOVE_DELETE)); + } + + public void setColorPalette(@NonNull Set colors) { + if (colorPaletteAdapter != null) { + colorPaletteAdapter.setColors(colors); + } + } + + public int getActiveColor() { + return colorPicker.getActiveColor(); + } + + public void setActiveColor(int color) { + colorPicker.setActiveColor(color); + } + + public void setEventListener(@Nullable EventListener eventListener) { + this.eventListener = eventListener != null ? eventListener : NULL_EVENT_LISTENER; + } + + public void enterMode(@NonNull Mode mode) { + setMode(mode, false); + } + + private void setMode(@NonNull Mode mode) { + setMode(mode, true); + } + + private void setMode(@NonNull Mode mode, boolean notify) { + Set visibleButtons = visibilityModeMap.get(mode); + for (View button : allViews) { + button.setVisibility(visibleButtons != null && visibleButtons.contains(button) ? VISIBLE : GONE); + } + + switch (mode) { + case CROP: presentModeCrop(); break; + case DRAW: presentModeDraw(); break; + case HIGHLIGHT: presentModeHighlight(); break; + case TEXT: presentModeText(); break; + } + + if (notify) { + eventListener.onModeStarted(mode); + } + eventListener.onRequestFullScreen(mode != Mode.NONE); + } + + private void presentModeCrop() { + updateCropAspectLockImage(eventListener.isCropAspectLocked()); + } + + private void presentModeDraw() { + colorPicker.setOnColorChangeListener(standardOnColorChangeListener); + colorPicker.setActiveColor(Color.RED); + } + + private void presentModeHighlight() { + colorPicker.setOnColorChangeListener(highlightOnColorChangeListener); + colorPicker.setActiveColor(Color.YELLOW); + } + + private void presentModeText() { + colorPicker.setOnColorChangeListener(standardOnColorChangeListener); + colorPicker.setActiveColor(Color.WHITE); + } + + private final VerticalSlideColorPicker.OnColorChangeListener standardOnColorChangeListener = selectedColor -> eventListener.onColorChange(selectedColor); + + private final VerticalSlideColorPicker.OnColorChangeListener highlightOnColorChangeListener = selectedColor -> eventListener.onColorChange(replaceAlphaWith128(selectedColor)); + + private static int replaceAlphaWith128(int color) { + return color & ~0xff000000 | 0x80000000; + } + + public enum Mode { + NONE, DRAW, HIGHLIGHT, TEXT, MOVE_DELETE, CROP + } + + public interface EventListener { + void onModeStarted(@NonNull Mode mode); + void onColorChange(int color); + void onUndo(); + void onDelete(); + void onFlipHorizontal(); + void onRotate90AntiClockwise(); + void onCropAspectLock(boolean locked); + boolean isCropAspectLocked(); + void onRequestFullScreen(boolean fullScreen); + } + + private static final EventListener NULL_EVENT_LISTENER = new EventListener() { + + @Override + public void onModeStarted(@NonNull Mode mode) { + } + + @Override + public void onColorChange(int color) { + } + + @Override + public void onUndo() { + } + + @Override + public void onDelete() { + } + + @Override + public void onFlipHorizontal() { + } + + @Override + public void onRotate90AntiClockwise() { + } + + @Override + public void onCropAspectLock(boolean locked) { + } + + @Override + public boolean isCropAspectLocked() { + return false; + } + + @Override + public void onRequestFullScreen(boolean fullScreen) { + } + }; +} diff --git a/src/org/thoughtcrime/securesms/scribbles/ScribbleFragment.java b/src/org/thoughtcrime/securesms/scribbles/ScribbleFragment.java deleted file mode 100644 index f7dfa9e63..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/ScribbleFragment.java +++ /dev/null @@ -1,318 +0,0 @@ -package org.thoughtcrime.securesms.scribbles; - -import android.annotation.SuppressLint; -import android.annotation.TargetApi; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.PointF; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Build; -import android.os.Bundle; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.v4.app.Fragment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.TransportOption; -import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.mediasend.MediaSendPageFragment; -import org.thoughtcrime.securesms.mms.GlideApp; -import org.thoughtcrime.securesms.mms.GlideRequests; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.providers.DeprecatedPersistentBlobProvider; -import org.thoughtcrime.securesms.scribbles.viewmodel.Font; -import org.thoughtcrime.securesms.scribbles.viewmodel.Layer; -import org.thoughtcrime.securesms.scribbles.viewmodel.TextLayer; -import org.thoughtcrime.securesms.scribbles.widget.MotionView; -import org.thoughtcrime.securesms.scribbles.widget.ScribbleView; -import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker; -import org.thoughtcrime.securesms.scribbles.widget.entity.ImageEntity; -import org.thoughtcrime.securesms.scribbles.widget.entity.MotionEntity; -import org.thoughtcrime.securesms.scribbles.widget.entity.TextEntity; -import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.util.Util; -import org.thoughtcrime.securesms.util.concurrent.SimpleTask; -import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; -import org.whispersystems.libsignal.util.guava.Optional; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.Locale; -import java.util.concurrent.ExecutionException; - -import static android.app.Activity.RESULT_OK; - -@TargetApi(Build.VERSION_CODES.JELLY_BEAN) -public class ScribbleFragment extends Fragment implements ScribbleHud.EventListener, - VerticalSlideColorPicker.OnColorChangeListener, - MediaSendPageFragment -{ - - private static final String TAG = ScribbleFragment.class.getSimpleName(); - - private static final String KEY_IMAGE_URI = "image_uri"; - - public static final int SELECT_STICKER_REQUEST_CODE = 123; - - private Controller controller; - private ScribbleHud scribbleHud; - private ScribbleView scribbleView; - private GlideRequests glideRequests; - private Uri imageUri; - - private ScribbleView.SavedState savedState; - - public static ScribbleFragment newInstance(@NonNull Uri imageUri) { - Bundle args = new Bundle(); - args.putParcelable(KEY_IMAGE_URI, imageUri); - - ScribbleFragment fragment = new ScribbleFragment(); - fragment.setArguments(args); - fragment.setUri(imageUri); - return fragment; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (!(getActivity() instanceof Controller)) { - throw new IllegalStateException("Parent activity must implement Controller interface."); - } - controller = (Controller) getActivity(); - imageUri = getArguments().getParcelable(KEY_IMAGE_URI); - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.scribble_fragment, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - this.glideRequests = GlideApp.with(this); - this.scribbleHud = view.findViewById(R.id.scribble_hud); - this.scribbleView = view.findViewById(R.id.scribble_view); - - scribbleHud.setEventListener(this); - - scribbleView.setMotionViewCallback(motionViewCallback); - scribbleView.setDrawingChangedListener(() -> scribbleHud.setColorPalette(scribbleView.getUniqueColors())); - scribbleView.setDrawingMode(false); - scribbleView.setImage(glideRequests, imageUri); - - if (savedState != null) { - scribbleView.restoreState(savedState); - } - } - - @Override - public void setUri(@NonNull Uri uri) { - this.imageUri = uri; - } - - @Override - public @NonNull Uri getUri() { - return imageUri; - } - - @Override - public @Nullable View getPlaybackControls() { - return null; - } - - @Override - public @Nullable Object saveState() { - return scribbleView.saveState(); - } - - @Override - public void restoreState(@NonNull Object state) { - if (state instanceof ScribbleView.SavedState) { - savedState = (ScribbleView.SavedState) state; - - if (scribbleView != null) { - scribbleView.restoreState(savedState); - } - } else { - Log.w(TAG, "Received a bad saved state. Received class: " + state.getClass().getName()); - } - } - - private void addSticker(final Bitmap pica) { - Util.runOnMain(() -> { - Layer layer = new Layer(); - ImageEntity entity = new ImageEntity(layer, pica, scribbleView.getWidth(), scribbleView.getHeight()); - - scribbleView.addEntityAndPosition(entity); - }); - } - - private void changeTextEntityColor(int selectedColor) { - TextEntity textEntity = currentTextEntity(); - - if (textEntity == null) { - return; - } - - textEntity.getLayer().getFont().setColor(selectedColor); - textEntity.updateEntity(); - scribbleView.invalidate(); - scribbleHud.setColorPalette(scribbleView.getUniqueColors()); - } - - private void startTextEntityEditing() { - TextEntity textEntity = currentTextEntity(); - if (textEntity != null) { - scribbleView.startEditing(textEntity); - } - } - - @Nullable - private TextEntity currentTextEntity() { - if (scribbleView != null && scribbleView.getSelectedEntity() instanceof TextEntity) { - return ((TextEntity) scribbleView.getSelectedEntity()); - } else { - return null; - } - } - - protected void addTextSticker() { - TextLayer textLayer = createTextLayer(); - TextEntity textEntity = new TextEntity(textLayer, scribbleView.getWidth(), scribbleView.getHeight()); - scribbleView.addEntityAndPosition(textEntity); - - PointF center = textEntity.absoluteCenter(); - center.y = center.y * 0.5F; - textEntity.moveCenterTo(center); - - scribbleView.invalidate(); - - startTextEntityEditing(); - changeTextEntityColor(scribbleHud.getActiveColor()); - } - - private TextLayer createTextLayer() { - TextLayer textLayer = new TextLayer(); - Font font = new Font(); - - font.setColor(scribbleHud.getActiveColor()); - font.setSize(TextLayer.Limits.INITIAL_FONT_SIZE); - - textLayer.setFont(font); - - return textLayer; - } - - @SuppressLint("StaticFieldLeak") - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (resultCode == RESULT_OK && requestCode == SELECT_STICKER_REQUEST_CODE && data != null) { - final String stickerFile = data.getStringExtra(StickerSelectActivity.EXTRA_STICKER_FILE); - - SimpleTask.run(getLifecycle(), () -> { - try { - return BitmapFactory.decodeStream(getContext().getAssets().open(stickerFile)); - } catch (IOException e) { - Log.w(TAG, e); - return null; - } - }, bitmap -> { - if (bitmap != null) { - addSticker(bitmap); - } - }); - } - } - - @Override - public void onModeStarted(@NonNull ScribbleHud.Mode mode) { - switch (mode) { - case DRAW: - controller.onTouchEventsNeeded(true); - scribbleView.setDrawingMode(true); - scribbleView.setDrawingBrushWidth(ScribbleView.DEFAULT_BRUSH_WIDTH); - break; - - case HIGHLIGHT: - controller.onTouchEventsNeeded(true); - scribbleView.setDrawingMode(true); - scribbleView.setDrawingBrushWidth(ScribbleView.DEFAULT_BRUSH_WIDTH * 3); - break; - - case TEXT: - controller.onTouchEventsNeeded(true); - scribbleView.setDrawingMode(false); - addTextSticker(); - break; - - case STICKER: - controller.onTouchEventsNeeded(true); - scribbleView.setDrawingMode(false); - Intent intent = new Intent(getContext(), StickerSelectActivity.class); - startActivityForResult(intent, SELECT_STICKER_REQUEST_CODE); - break; - - case NONE: - controller.onTouchEventsNeeded(false); - scribbleView.clearSelection(); - scribbleView.setDrawingMode(false); - break; - } - } - - @Override - public void onColorChange(int color) { - scribbleView.setDrawingBrushColor(color); - changeTextEntityColor(color); - } - - @Override - public void onUndo() { - scribbleView.undoDrawing(); - scribbleHud.setColorPalette(scribbleView.getUniqueColors()); - } - - @Override - public void onDelete() { - scribbleView.deleteSelected(); - scribbleHud.setColorPalette(scribbleView.getUniqueColors()); - } - - private final MotionView.MotionViewCallback motionViewCallback = new MotionView.MotionViewCallback() { - @Override - public void onEntitySelected(@Nullable MotionEntity entity) { - if (entity == null) { - scribbleHud.enterMode(ScribbleHud.Mode.NONE); - controller.onTouchEventsNeeded(false); - } else if (entity instanceof TextEntity) { - int textColor = ((TextEntity) entity).getLayer().getFont().getColor(); - - scribbleHud.enterMode(ScribbleHud.Mode.TEXT); - scribbleHud.setActiveColor(textColor); - controller.onTouchEventsNeeded(true); - } else { - scribbleHud.enterMode(ScribbleHud.Mode.STICKER); - controller.onTouchEventsNeeded(true); - } - } - - @Override - public void onEntityDoubleTap(@NonNull MotionEntity entity) { - startTextEntityEditing(); - } - }; - - public interface Controller { - void onTouchEventsNeeded(boolean needed); - } -} diff --git a/src/org/thoughtcrime/securesms/scribbles/ScribbleHud.java b/src/org/thoughtcrime/securesms/scribbles/ScribbleHud.java deleted file mode 100644 index 64e4f17d2..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/ScribbleHud.java +++ /dev/null @@ -1,264 +0,0 @@ -package org.thoughtcrime.securesms.scribbles; - -import android.content.Context; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.Rect; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.v7.widget.LinearLayoutManager; -import android.support.v7.widget.RecyclerView; -import android.text.Editable; -import android.text.TextWatcher; -import android.util.AttributeSet; -import android.view.KeyEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.widget.LinearLayout; -import android.widget.TextView; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.TransportOption; -import org.thoughtcrime.securesms.components.ComposeText; -import org.thoughtcrime.securesms.components.InputAwareLayout; -import org.thoughtcrime.securesms.components.SendButton; -import org.thoughtcrime.securesms.components.emoji.EmojiDrawer; -import org.thoughtcrime.securesms.components.emoji.EmojiToggle; -import org.thoughtcrime.securesms.scribbles.widget.ColorPaletteAdapter; -import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker; -import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; -import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.views.Stub; -import org.whispersystems.libsignal.util.guava.Optional; - -import java.util.Locale; -import java.util.Set; - -/** - * The HUD (heads-up display) that contains all of the tools for interacting with - * {@link org.thoughtcrime.securesms.scribbles.widget.ScribbleView} - */ -public class ScribbleHud extends LinearLayout { - - private View drawButton; - private View highlightButton; - private View textButton; - private View stickerButton; - private View undoButton; - private View deleteButton; - private View confirmButton; - private VerticalSlideColorPicker colorPicker; - private RecyclerView colorPalette; - - private EventListener eventListener; - private ColorPaletteAdapter colorPaletteAdapter; - - public ScribbleHud(@NonNull Context context) { - super(context); - initialize(); - } - - public ScribbleHud(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public ScribbleHud(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(); - } - - private void initialize() { - inflate(getContext(), R.layout.scribble_hud, this); - setOrientation(VERTICAL); - - drawButton = findViewById(R.id.scribble_draw_button); - highlightButton = findViewById(R.id.scribble_highlight_button); - textButton = findViewById(R.id.scribble_text_button); - stickerButton = findViewById(R.id.scribble_sticker_button); - undoButton = findViewById(R.id.scribble_undo_button); - deleteButton = findViewById(R.id.scribble_delete_button); - confirmButton = findViewById(R.id.scribble_confirm_button); - colorPicker = findViewById(R.id.scribble_color_picker); - colorPalette = findViewById(R.id.scribble_color_palette); - - initializeViews(); - setMode(Mode.NONE); - } - - private void initializeViews() { - undoButton.setOnClickListener(v -> { - if (eventListener != null) { - eventListener.onUndo(); - } - }); - - deleteButton.setOnClickListener(v -> { - if (eventListener != null) { - eventListener.onDelete(); - } - setMode(Mode.NONE); - }); - - confirmButton.setOnClickListener(v -> setMode(Mode.NONE)); - - colorPaletteAdapter = new ColorPaletteAdapter(); - colorPaletteAdapter.setEventListener(colorPicker::setActiveColor); - - colorPalette.setLayoutManager(new LinearLayoutManager(getContext())); - colorPalette.setAdapter(colorPaletteAdapter); - } - - public void setColorPalette(@NonNull Set colors) { - colorPaletteAdapter.setColors(colors); - } - - public int getActiveColor() { - return colorPicker.getActiveColor(); - } - - public void setActiveColor(int color) { - colorPicker.setActiveColor(color); - } - - public void setEventListener(@Nullable EventListener eventListener) { - this.eventListener = eventListener; - } - - public void enterMode(@NonNull Mode mode) { - setMode(mode, false); - } - - private void setMode(@NonNull Mode mode) { - setMode(mode, true); - } - - private void setMode(@NonNull Mode mode, boolean notify) { - switch (mode) { - case NONE: presentModeNone(); break; - case DRAW: presentModeDraw(); break; - case HIGHLIGHT: presentModeHighlight(); break; - case TEXT: presentModeText(); break; - case STICKER: presentModeSticker(); break; - } - - if (notify && eventListener != null) { - eventListener.onModeStarted(mode); - } - } - - private void presentModeNone() { - drawButton.setVisibility(VISIBLE); - highlightButton.setVisibility(VISIBLE); - textButton.setVisibility(VISIBLE); - stickerButton.setVisibility(VISIBLE); - - undoButton.setVisibility(GONE); - deleteButton.setVisibility(GONE); - confirmButton.setVisibility(GONE); - colorPicker.setVisibility(GONE); - colorPalette.setVisibility(GONE); - - drawButton.setOnClickListener(v -> setMode(Mode.DRAW)); - highlightButton.setOnClickListener(v -> setMode(Mode.HIGHLIGHT)); - textButton.setOnClickListener(v -> setMode(Mode.TEXT)); - stickerButton.setOnClickListener(v -> setMode(Mode.STICKER)); - } - - private void presentModeDraw() { - confirmButton.setVisibility(VISIBLE); - undoButton.setVisibility(VISIBLE); - colorPicker.setVisibility(VISIBLE); - colorPalette.setVisibility(VISIBLE); - - drawButton.setVisibility(GONE); - highlightButton.setVisibility(GONE); - textButton.setVisibility(GONE); - stickerButton.setVisibility(GONE); - deleteButton.setVisibility(GONE); - - - colorPicker.setOnColorChangeListener(standardOnColorChangeListener); - colorPicker.setActiveColor(Color.RED); - } - - private void presentModeHighlight() { - confirmButton.setVisibility(VISIBLE); - undoButton.setVisibility(VISIBLE); - colorPicker.setVisibility(VISIBLE); - colorPalette.setVisibility(VISIBLE); - - drawButton.setVisibility(GONE); - highlightButton.setVisibility(GONE); - textButton.setVisibility(GONE); - deleteButton.setVisibility(GONE); - stickerButton.setVisibility(GONE); - - colorPicker.setOnColorChangeListener(highlightOnColorChangeListener); - colorPicker.setActiveColor(Color.YELLOW); - } - - private void presentModeText() { - confirmButton.setVisibility(VISIBLE); - deleteButton.setVisibility(VISIBLE); - colorPicker.setVisibility(VISIBLE); - colorPalette.setVisibility(VISIBLE); - - textButton.setVisibility(GONE); - drawButton.setVisibility(GONE); - highlightButton.setVisibility(GONE); - stickerButton.setVisibility(GONE); - undoButton.setVisibility(GONE); - - colorPicker.setOnColorChangeListener(standardOnColorChangeListener); - colorPicker.setActiveColor(Color.WHITE); - } - - private void presentModeSticker() { - deleteButton.setVisibility(VISIBLE); - confirmButton.setVisibility(VISIBLE); - - drawButton.setVisibility(GONE); - highlightButton.setVisibility(GONE); - textButton.setVisibility(GONE); - stickerButton.setVisibility(GONE); - undoButton.setVisibility(GONE); - colorPicker.setVisibility(GONE); - colorPalette.setVisibility(GONE); - } - - private final VerticalSlideColorPicker.OnColorChangeListener standardOnColorChangeListener = new VerticalSlideColorPicker.OnColorChangeListener() { - @Override - public void onColorChange(int selectedColor) { - if (eventListener != null) { - eventListener.onColorChange(selectedColor); - } - } - }; - - private final VerticalSlideColorPicker.OnColorChangeListener highlightOnColorChangeListener = new VerticalSlideColorPicker.OnColorChangeListener() { - @Override - public void onColorChange(int selectedColor) { - if (eventListener != null) { - int r = Color.red(selectedColor); - int g = Color.green(selectedColor); - int b = Color.blue(selectedColor); - int a = 128; - - eventListener.onColorChange(Color.argb(a, r, g, b)); - } - } - }; - - public enum Mode { - NONE, DRAW, HIGHLIGHT, TEXT, STICKER - } - - public interface EventListener { - void onModeStarted(@NonNull Mode mode); - void onColorChange(int color); - void onUndo(); - void onDelete(); - } -} diff --git a/src/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java b/src/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java new file mode 100644 index 000000000..01a897986 --- /dev/null +++ b/src/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java @@ -0,0 +1,186 @@ +package org.thoughtcrime.securesms.scribbles; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Point; +import android.graphics.RectF; +import android.net.Uri; +import android.os.Parcel; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.SimpleTarget; +import com.bumptech.glide.request.transition.Transition; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.Renderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.mms.GlideRequest; + +import java.util.concurrent.ExecutionException; + +/** + * Uses Glide to load an image and implements a {@link Renderer}. + * + * The image can be encrypted. + */ +final class UriGlideRenderer implements Renderer { + + private final Uri imageUri; + private final Paint paint = new Paint(); + private final Matrix imageProjectionMatrix = new Matrix(); + private final Matrix temp = new Matrix(); + private final boolean decryptable; + private final int maxWidth; + private final int maxHeight; + + @Nullable + private Bitmap bitmap; + + UriGlideRenderer(@NonNull Uri imageUri, boolean decryptable, int maxWidth, int maxHeight) { + this.imageUri = imageUri; + this.decryptable = decryptable; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + paint.setAntiAlias(true); + } + + @Override + public void render(@NonNull RendererContext rendererContext) { + if (getBitmap() == null) { + if (rendererContext.isBlockingLoad()) { + try { + Bitmap bitmap = getBitmapGlideRequest(rendererContext.context).submit().get(); + setBitmap(rendererContext, bitmap); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } else { + getBitmapGlideRequest(rendererContext.context).into(new SimpleTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { + setBitmap(rendererContext, resource); + } + }); + } + } + + final Bitmap bitmap = getBitmap(); + if (bitmap != null) { + rendererContext.save(); + + rendererContext.canvasMatrix.concat(imageProjectionMatrix); + + // Units are image level pixels at this point. + + int alpha = paint.getAlpha(); + paint.setAlpha(rendererContext.getAlpha(alpha)); + + rendererContext.canvas.drawBitmap(bitmap, 0, 0, paint); + + paint.setAlpha(alpha); + + rendererContext.restore(); + } else { + // If failed to load, we draw a black out, in case image was sticker positioned to cover private info. + rendererContext.canvas.drawRect(Bounds.FULL_BOUNDS, paint); + } + } + + private GlideRequest getBitmapGlideRequest(@NonNull Context context) { + return GlideApp.with(context) + .asBitmap() + .diskCacheStrategy(DiskCacheStrategy.NONE) + .override(maxWidth, maxHeight) + .centerInside() + .load(decryptable ? new DecryptableStreamUriLoader.DecryptableUri(imageUri) : imageUri); + } + + @Override + public boolean hitTest(float x, float y) { + return pixelAlphaNotZero(x, y); + } + + private boolean pixelAlphaNotZero(float x, float y) { + Bitmap bitmap = getBitmap(); + + if (bitmap == null) return false; + + imageProjectionMatrix.invert(temp); + + float[] onBmp = new float[2]; + temp.mapPoints(onBmp, new float[]{ x, y }); + + int xInt = (int) onBmp[0]; + int yInt = (int) onBmp[1]; + + if (xInt >= 0 && xInt < bitmap.getWidth() && yInt >= 0 && yInt < bitmap.getHeight()) { + return (bitmap.getPixel(xInt, yInt) & 0xff000000) != 0; + } else { + return false; + } + } + + @Nullable + private Bitmap getBitmap() { + if (bitmap != null && bitmap.isRecycled()) { + bitmap = null; + } + return bitmap; + } + + private void setBitmap(@NonNull RendererContext rendererContext, @Nullable Bitmap bitmap) { + this.bitmap = bitmap; + if (bitmap != null) { + RectF from = new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight()); + imageProjectionMatrix.setRectToRect(from, Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER); + rendererContext.rendererReady.onReady(UriGlideRenderer.this, cropMatrix(bitmap), new Point(bitmap.getWidth(), bitmap.getHeight())); + } + } + + private static Matrix cropMatrix(Bitmap bitmap) { + Matrix matrix = new Matrix(); + if (bitmap.getWidth() > bitmap.getHeight()) { + matrix.preScale(1, ((float) bitmap.getHeight()) / bitmap.getWidth()); + } else { + matrix.preScale(((float) bitmap.getWidth()) / bitmap.getHeight(), 1); + } + return matrix; + } + + public static final Creator CREATOR = new Creator() { + @Override + public UriGlideRenderer createFromParcel(Parcel in) { + return new UriGlideRenderer(Uri.parse(in.readString()), + in.readInt() == 1, + in.readInt(), + in.readInt() + ); + } + + @Override + public UriGlideRenderer[] newArray(int size) { + return new UriGlideRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(imageUri.toString()); + dest.writeInt(decryptable ? 1 : 0); + dest.writeInt(maxWidth); + dest.writeInt(maxHeight); + } +} diff --git a/src/org/thoughtcrime/securesms/scribbles/multitouch/BaseGestureDetector.java b/src/org/thoughtcrime/securesms/scribbles/multitouch/BaseGestureDetector.java deleted file mode 100644 index 0d3930b00..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/multitouch/BaseGestureDetector.java +++ /dev/null @@ -1,149 +0,0 @@ -package org.thoughtcrime.securesms.scribbles.multitouch; - -import android.content.Context; -import android.view.MotionEvent; - -/** - * @author Almer Thie (code.almeros.com) - * Copyright (c) 2013, Almer Thie (code.almeros.com) - *

- * All rights reserved. - *

- * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - *

- * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer - * in the documentation and/or other materials provided with the distribution. - *

- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, - * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. - * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, - * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY - * OF SUCH DAMAGE. - */ -public abstract class BaseGestureDetector { - /** - * This value is the threshold ratio between the previous combined pressure - * and the current combined pressure. When pressure decreases rapidly - * between events the position values can often be imprecise, as it usually - * indicates that the user is in the process of lifting a pointer off of the - * device. This value was tuned experimentally. - */ - protected static final float PRESSURE_THRESHOLD = 0.67f; - protected final Context mContext; - protected boolean mGestureInProgress; - protected MotionEvent mPrevEvent; - protected MotionEvent mCurrEvent; - protected float mCurrPressure; - protected float mPrevPressure; - protected long mTimeDelta; - - - public BaseGestureDetector(Context context) { - mContext = context; - } - - /** - * All gesture detectors need to be called through this method to be able to - * detect gestures. This method delegates work to handler methods - * (handleStartProgressEvent, handleInProgressEvent) implemented in - * extending classes. - * - * @param event - * @return - */ - public boolean onTouchEvent(MotionEvent event) { - final int actionCode = event.getAction() & MotionEvent.ACTION_MASK; - if (!mGestureInProgress) { - handleStartProgressEvent(actionCode, event); - } else { - handleInProgressEvent(actionCode, event); - } - return true; - } - - /** - * Called when the current event occurred when NO gesture is in progress - * yet. The handling in this implementation may set the gesture in progress - * (via mGestureInProgress) or out of progress - * - * @param actionCode - * @param event - */ - protected abstract void handleStartProgressEvent(int actionCode, MotionEvent event); - - /** - * Called when the current event occurred when a gesture IS in progress. The - * handling in this implementation may set the gesture out of progress (via - * mGestureInProgress). - * - * @param actionCode - * @param event - */ - protected abstract void handleInProgressEvent(int actionCode, MotionEvent event); - - - protected void updateStateByEvent(MotionEvent curr) { - final MotionEvent prev = mPrevEvent; - - // Reset mCurrEvent - if (mCurrEvent != null) { - mCurrEvent.recycle(); - mCurrEvent = null; - } - mCurrEvent = MotionEvent.obtain(curr); - - - // Delta time - mTimeDelta = curr.getEventTime() - prev.getEventTime(); - - // Pressure - mCurrPressure = curr.getPressure(curr.getActionIndex()); - mPrevPressure = prev.getPressure(prev.getActionIndex()); - } - - protected void resetState() { - if (mPrevEvent != null) { - mPrevEvent.recycle(); - mPrevEvent = null; - } - if (mCurrEvent != null) { - mCurrEvent.recycle(); - mCurrEvent = null; - } - mGestureInProgress = false; - } - - - /** - * Returns {@code true} if a gesture is currently in progress. - * - * @return {@code true} if a gesture is currently in progress, {@code false} otherwise. - */ - public boolean isInProgress() { - return mGestureInProgress; - } - - /** - * Return the time difference in milliseconds between the previous accepted - * GestureDetector event and the current GestureDetector event. - * - * @return Time difference since the last move event in milliseconds. - */ - public long getTimeDelta() { - return mTimeDelta; - } - - /** - * Return the event time of the current GestureDetector event being - * processed. - * - * @return Current GestureDetector event time in milliseconds. - */ - public long getEventTime() { - return mCurrEvent.getEventTime(); - } - -} diff --git a/src/org/thoughtcrime/securesms/scribbles/multitouch/MoveGestureDetector.java b/src/org/thoughtcrime/securesms/scribbles/multitouch/MoveGestureDetector.java deleted file mode 100644 index f623a202a..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/multitouch/MoveGestureDetector.java +++ /dev/null @@ -1,170 +0,0 @@ -package org.thoughtcrime.securesms.scribbles.multitouch; - -import android.content.Context; -import android.graphics.PointF; -import android.view.MotionEvent; - -/** - * @author Almer Thie (code.almeros.com) - * Copyright (c) 2013, Almer Thie (code.almeros.com) - *

- * All rights reserved. - *

- * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - *

- * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer - * in the documentation and/or other materials provided with the distribution. - *

- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, - * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. - * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, - * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY - * OF SUCH DAMAGE. - */ -public class MoveGestureDetector extends BaseGestureDetector { - - private static final PointF FOCUS_DELTA_ZERO = new PointF(); - private final OnMoveGestureListener mListener; - private PointF mCurrFocusInternal; - private PointF mPrevFocusInternal; - private PointF mFocusExternal = new PointF(); - private PointF mFocusDeltaExternal = new PointF(); - public MoveGestureDetector(Context context, OnMoveGestureListener listener) { - super(context); - mListener = listener; - } - - @Override - protected void handleStartProgressEvent(int actionCode, MotionEvent event) { - switch (actionCode) { - case MotionEvent.ACTION_DOWN: - resetState(); // In case we missed an UP/CANCEL event - - mPrevEvent = MotionEvent.obtain(event); - mTimeDelta = 0; - - updateStateByEvent(event); - break; - - case MotionEvent.ACTION_MOVE: - mGestureInProgress = mListener.onMoveBegin(this); - break; - } - } - - @Override - protected void handleInProgressEvent(int actionCode, MotionEvent event) { - switch (actionCode) { - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - mListener.onMoveEnd(this); - resetState(); - break; - - case MotionEvent.ACTION_MOVE: - updateStateByEvent(event); - - // Only accept the event if our relative pressure is within - // a certain limit. This can help filter shaky data as a - // finger is lifted. - if (mCurrPressure / mPrevPressure > PRESSURE_THRESHOLD) { - final boolean updatePrevious = mListener.onMove(this); - if (updatePrevious) { - mPrevEvent.recycle(); - mPrevEvent = MotionEvent.obtain(event); - } - } - break; - } - } - - protected void updateStateByEvent(MotionEvent curr) { - super.updateStateByEvent(curr); - - final MotionEvent prev = mPrevEvent; - - // Focus intenal - mCurrFocusInternal = determineFocalPoint(curr); - mPrevFocusInternal = determineFocalPoint(prev); - - // Focus external - // - Prevent skipping of focus delta when a finger is added or removed - boolean mSkipNextMoveEvent = prev.getPointerCount() != curr.getPointerCount(); - mFocusDeltaExternal = mSkipNextMoveEvent ? FOCUS_DELTA_ZERO : new PointF(mCurrFocusInternal.x - mPrevFocusInternal.x, mCurrFocusInternal.y - mPrevFocusInternal.y); - - // - Don't directly use mFocusInternal (or skipping will occur). Add - // unskipped delta values to mFocusExternal instead. - mFocusExternal.x += mFocusDeltaExternal.x; - mFocusExternal.y += mFocusDeltaExternal.y; - } - - /** - * Determine (multi)finger focal point (a.k.a. center point between all - * fingers) - * - * @return PointF focal point - */ - private PointF determineFocalPoint(MotionEvent e) { - // Number of fingers on screen - final int pCount = e.getPointerCount(); - float x = 0f; - float y = 0f; - - for (int i = 0; i < pCount; i++) { - x += e.getX(i); - y += e.getY(i); - } - - return new PointF(x / pCount, y / pCount); - } - - public float getFocusX() { - return mFocusExternal.x; - } - - public float getFocusY() { - return mFocusExternal.y; - } - - public PointF getFocusDelta() { - return mFocusDeltaExternal; - } - - /** - * Listener which must be implemented which is used by MoveGestureDetector - * to perform callbacks to any implementing class which is registered to a - * MoveGestureDetector via the constructor. - * - * @see MoveGestureDetector.SimpleOnMoveGestureListener - */ - public interface OnMoveGestureListener { - public boolean onMove(MoveGestureDetector detector); - - public boolean onMoveBegin(MoveGestureDetector detector); - - public void onMoveEnd(MoveGestureDetector detector); - } - - /** - * Helper class which may be extended and where the methods may be - * implemented. This way it is not necessary to implement all methods - * of OnMoveGestureListener. - */ - public static class SimpleOnMoveGestureListener implements OnMoveGestureListener { - public boolean onMove(MoveGestureDetector detector) { - return false; - } - - public boolean onMoveBegin(MoveGestureDetector detector) { - return true; - } - - public void onMoveEnd(MoveGestureDetector detector) { - // Do nothing, overridden implementation may be used - } - } - -} diff --git a/src/org/thoughtcrime/securesms/scribbles/multitouch/RotateGestureDetector.java b/src/org/thoughtcrime/securesms/scribbles/multitouch/RotateGestureDetector.java deleted file mode 100644 index 2032af2e5..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/multitouch/RotateGestureDetector.java +++ /dev/null @@ -1,170 +0,0 @@ -package org.thoughtcrime.securesms.scribbles.multitouch; - -import android.content.Context; -import android.view.MotionEvent; - -/** - * @author Almer Thie (code.almeros.com) - * Copyright (c) 2013, Almer Thie (code.almeros.com) - *

- * All rights reserved. - *

- * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - *

- * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer - * in the documentation and/or other materials provided with the distribution. - *

- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, - * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. - * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, - * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY - * OF SUCH DAMAGE. - */ -public class RotateGestureDetector extends TwoFingerGestureDetector { - - private static final String TAG = RotateGestureDetector.class.getSimpleName(); - private final OnRotateGestureListener mListener; - private boolean mSloppyGesture; - - - public RotateGestureDetector(Context context, OnRotateGestureListener listener) { - super(context); - mListener = listener; - } - - @Override - protected void handleStartProgressEvent(int actionCode, MotionEvent event) { - switch (actionCode) { - case MotionEvent.ACTION_POINTER_DOWN: - // At least the second finger is on screen now - - resetState(); // In case we missed an UP/CANCEL event - mPrevEvent = MotionEvent.obtain(event); - mTimeDelta = 0; - - updateStateByEvent(event); - - // See if we have a sloppy gesture - mSloppyGesture = isSloppyGesture(event); - if (!mSloppyGesture) { - // No, start gesture now - mGestureInProgress = mListener.onRotateBegin(this); - } - break; - - case MotionEvent.ACTION_MOVE: - if (!mSloppyGesture) { - break; - } - - // See if we still have a sloppy gesture - mSloppyGesture = isSloppyGesture(event); - if (!mSloppyGesture) { - // No, start normal gesture now - mGestureInProgress = mListener.onRotateBegin(this); - } - - break; - - case MotionEvent.ACTION_POINTER_UP: - if (!mSloppyGesture) { - break; - } - - break; - } - } - - @Override - protected void handleInProgressEvent(int actionCode, MotionEvent event) { - switch (actionCode) { - case MotionEvent.ACTION_POINTER_UP: - // Gesture ended but - updateStateByEvent(event); - - if (!mSloppyGesture) { - mListener.onRotateEnd(this); - } - - resetState(); - break; - - case MotionEvent.ACTION_CANCEL: - if (!mSloppyGesture) { - mListener.onRotateEnd(this); - } - - resetState(); - break; - - case MotionEvent.ACTION_MOVE: - updateStateByEvent(event); - - // Only accept the event if our relative pressure is within - // a certain limit. This can help filter shaky data as a - // finger is lifted. - if (mCurrPressure / mPrevPressure > PRESSURE_THRESHOLD) { - final boolean updatePrevious = mListener.onRotate(this); - if (updatePrevious) { - mPrevEvent.recycle(); - mPrevEvent = MotionEvent.obtain(event); - } - } - break; - } - } - - @Override - protected void resetState() { - super.resetState(); - mSloppyGesture = false; - } - - /** - * Return the rotation difference from the previous rotate event to the current - * event. - * - * @return The current rotation //difference in degrees. - */ - public float getRotationDegreesDelta() { - double diffRadians = Math.atan2(mPrevFingerDiffY, mPrevFingerDiffX) - Math.atan2(mCurrFingerDiffY, mCurrFingerDiffX); - return (float) (diffRadians * 180 / Math.PI); - } - - /** - * Listener which must be implemented which is used by RotateGestureDetector - * to perform callbacks to any implementing class which is registered to a - * RotateGestureDetector via the constructor. - * - * @see RotateGestureDetector.SimpleOnRotateGestureListener - */ - public interface OnRotateGestureListener { - public boolean onRotate(RotateGestureDetector detector); - - public boolean onRotateBegin(RotateGestureDetector detector); - - public void onRotateEnd(RotateGestureDetector detector); - } - - /** - * Helper class which may be extended and where the methods may be - * implemented. This way it is not necessary to implement all methods - * of OnRotateGestureListener. - */ - public static class SimpleOnRotateGestureListener implements OnRotateGestureListener { - public boolean onRotate(RotateGestureDetector detector) { - return false; - } - - public boolean onRotateBegin(RotateGestureDetector detector) { - return true; - } - - public void onRotateEnd(RotateGestureDetector detector) { - // Do nothing, overridden implementation may be used - } - } -} diff --git a/src/org/thoughtcrime/securesms/scribbles/multitouch/ShoveGestureDetector.java b/src/org/thoughtcrime/securesms/scribbles/multitouch/ShoveGestureDetector.java deleted file mode 100644 index cb4eb339e..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/multitouch/ShoveGestureDetector.java +++ /dev/null @@ -1,201 +0,0 @@ -package org.thoughtcrime.securesms.scribbles.multitouch; - -import android.content.Context; -import android.view.MotionEvent; - -/** - * @author Robert Nordan (robert.nordan@norkart.no) - *

- * Copyright (c) 2013, Norkart AS - *

- * All rights reserved. - *

- * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - *

- * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer - * in the documentation and/or other materials provided with the distribution. - *

- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, - * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. - * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, - * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY - * OF SUCH DAMAGE. - */ -public class ShoveGestureDetector extends TwoFingerGestureDetector { - - private final OnShoveGestureListener mListener; - private float mPrevAverageY; - private float mCurrAverageY; - private boolean mSloppyGesture; - - public ShoveGestureDetector(Context context, OnShoveGestureListener listener) { - super(context); - mListener = listener; - } - - @Override - protected void handleStartProgressEvent(int actionCode, MotionEvent event) { - switch (actionCode) { - case MotionEvent.ACTION_POINTER_DOWN: - // At least the second finger is on screen now - - resetState(); // In case we missed an UP/CANCEL event - mPrevEvent = MotionEvent.obtain(event); - mTimeDelta = 0; - - updateStateByEvent(event); - - // See if we have a sloppy gesture - mSloppyGesture = isSloppyGesture(event); - if (!mSloppyGesture) { - // No, start gesture now - mGestureInProgress = mListener.onShoveBegin(this); - } - break; - - case MotionEvent.ACTION_MOVE: - if (!mSloppyGesture) { - break; - } - - // See if we still have a sloppy gesture - mSloppyGesture = isSloppyGesture(event); - if (!mSloppyGesture) { - // No, start normal gesture now - mGestureInProgress = mListener.onShoveBegin(this); - } - - break; - - case MotionEvent.ACTION_POINTER_UP: - if (!mSloppyGesture) { - break; - } - - break; - } - } - - @Override - protected void handleInProgressEvent(int actionCode, MotionEvent event) { - switch (actionCode) { - case MotionEvent.ACTION_POINTER_UP: - // Gesture ended but - updateStateByEvent(event); - - if (!mSloppyGesture) { - mListener.onShoveEnd(this); - } - - resetState(); - break; - - case MotionEvent.ACTION_CANCEL: - if (!mSloppyGesture) { - mListener.onShoveEnd(this); - } - - resetState(); - break; - - case MotionEvent.ACTION_MOVE: - updateStateByEvent(event); - - // Only accept the event if our relative pressure is within - // a certain limit. This can help filter shaky data as a - // finger is lifted. Also check that shove is meaningful. - if (mCurrPressure / mPrevPressure > PRESSURE_THRESHOLD - && Math.abs(getShovePixelsDelta()) > 0.5f) { - final boolean updatePrevious = mListener.onShove(this); - if (updatePrevious) { - mPrevEvent.recycle(); - mPrevEvent = MotionEvent.obtain(event); - } - } - break; - } - } - - @Override - protected void updateStateByEvent(MotionEvent curr) { - super.updateStateByEvent(curr); - - final MotionEvent prev = mPrevEvent; - float py0 = prev.getY(0); - float py1 = prev.getY(1); - mPrevAverageY = (py0 + py1) / 2.0f; - - float cy0 = curr.getY(0); - float cy1 = curr.getY(1); - mCurrAverageY = (cy0 + cy1) / 2.0f; - } - - @Override - protected boolean isSloppyGesture(MotionEvent event) { - boolean sloppy = super.isSloppyGesture(event); - if (sloppy) - return true; - - // If it's not traditionally sloppy, we check if the angle between fingers - // is acceptable. - double angle = Math.abs(Math.atan2(mCurrFingerDiffY, mCurrFingerDiffX)); - //about 20 degrees, left or right - return !((0.0f < angle && angle < 0.35f) - || 2.79f < angle && angle < Math.PI); - } - - /** - * Return the distance in pixels from the previous shove event to the current - * event. - * - * @return The current distance in pixels. - */ - public float getShovePixelsDelta() { - return mCurrAverageY - mPrevAverageY; - } - - @Override - protected void resetState() { - super.resetState(); - mSloppyGesture = false; - mPrevAverageY = 0.0f; - mCurrAverageY = 0.0f; - } - - /** - * Listener which must be implemented which is used by ShoveGestureDetector - * to perform callbacks to any implementing class which is registered to a - * ShoveGestureDetector via the constructor. - * - * @see ShoveGestureDetector.SimpleOnShoveGestureListener - */ - public interface OnShoveGestureListener { - public boolean onShove(ShoveGestureDetector detector); - - public boolean onShoveBegin(ShoveGestureDetector detector); - - public void onShoveEnd(ShoveGestureDetector detector); - } - - /** - * Helper class which may be extended and where the methods may be - * implemented. This way it is not necessary to implement all methods - * of OnShoveGestureListener. - */ - public static class SimpleOnShoveGestureListener implements OnShoveGestureListener { - public boolean onShove(ShoveGestureDetector detector) { - return false; - } - - public boolean onShoveBegin(ShoveGestureDetector detector) { - return true; - } - - public void onShoveEnd(ShoveGestureDetector detector) { - // Do nothing, overridden implementation may be used - } - } -} diff --git a/src/org/thoughtcrime/securesms/scribbles/multitouch/TwoFingerGestureDetector.java b/src/org/thoughtcrime/securesms/scribbles/multitouch/TwoFingerGestureDetector.java deleted file mode 100644 index 5ae6cd635..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/multitouch/TwoFingerGestureDetector.java +++ /dev/null @@ -1,189 +0,0 @@ -package org.thoughtcrime.securesms.scribbles.multitouch; - -import android.content.Context; -import android.util.DisplayMetrics; -import android.view.MotionEvent; -import android.view.ViewConfiguration; - -import org.thoughtcrime.securesms.logging.Log; - -/** - * @author Almer Thie (code.almeros.com) - * Copyright (c) 2013, Almer Thie (code.almeros.com) - *

- * All rights reserved. - *

- * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - *

- * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer - * in the documentation and/or other materials provided with the distribution. - *

- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, - * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. - * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, - * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY - * OF SUCH DAMAGE. - */ -public abstract class TwoFingerGestureDetector extends BaseGestureDetector { - - private static final String TAG = TwoFingerGestureDetector.class.getSimpleName(); - - private final float mEdgeSlop; - protected float mPrevFingerDiffX; - protected float mPrevFingerDiffY; - protected float mCurrFingerDiffX; - protected float mCurrFingerDiffY; - private float mRightSlopEdge; - private float mBottomSlopEdge; - private float mCurrLen; - private float mPrevLen; - - public TwoFingerGestureDetector(Context context) { - super(context); - - ViewConfiguration config = ViewConfiguration.get(context); - mEdgeSlop = config.getScaledEdgeSlop(); - } - - @Override - protected abstract void handleStartProgressEvent(int actionCode, MotionEvent event); - - @Override - protected abstract void handleInProgressEvent(int actionCode, MotionEvent event); - - protected void updateStateByEvent(MotionEvent curr) { - super.updateStateByEvent(curr); - - final MotionEvent prev = mPrevEvent; - - mCurrLen = -1; - mPrevLen = -1; - - // Previous - final float px0 = prev.getX(0); - final float py0 = prev.getY(0); - final float px1 = prev.getX(1); - final float py1 = prev.getY(1); - final float pvx = px1 - px0; - final float pvy = py1 - py0; - mPrevFingerDiffX = pvx; - mPrevFingerDiffY = pvy; - - // Current - final float cx0 = curr.getX(0); - final float cy0 = curr.getY(0); - final float cx1 = curr.getX(1); - final float cy1 = curr.getY(1); - final float cvx = cx1 - cx0; - final float cvy = cy1 - cy0; - mCurrFingerDiffX = cvx; - mCurrFingerDiffY = cvy; - } - - /** - * Return the current distance between the two pointers forming the - * gesture in progress. - * - * @return Distance between pointers in pixels. - */ - public float getCurrentSpan() { - if (mCurrLen == -1) { - final float cvx = mCurrFingerDiffX; - final float cvy = mCurrFingerDiffY; - mCurrLen = (float) Math.sqrt(cvx * cvx + cvy * cvy); - } - return mCurrLen; - } - - /** - * Return the previous distance between the two pointers forming the - * gesture in progress. - * - * @return Previous distance between pointers in pixels. - */ - public float getPreviousSpan() { - if (mPrevLen == -1) { - final float pvx = mPrevFingerDiffX; - final float pvy = mPrevFingerDiffY; - mPrevLen = (float) Math.sqrt(pvx * pvx + pvy * pvy); - } - return mPrevLen; - } - - /** - * Check if we have a sloppy gesture. Sloppy gestures can happen if the edge - * of the user's hand is touching the screen, for example. - * - * @param event - * @return - */ - protected boolean isSloppyGesture(MotionEvent event) { - // As orientation can change, query the metrics in touch down - DisplayMetrics metrics = mContext.getResources().getDisplayMetrics(); - mRightSlopEdge = metrics.widthPixels - mEdgeSlop; - mBottomSlopEdge = metrics.heightPixels - mEdgeSlop; - - final float edgeSlop = mEdgeSlop; - final float rightSlop = mRightSlopEdge; - final float bottomSlop = mBottomSlopEdge; - - final float x0 = event.getRawX(); - final float y0 = event.getRawY(); - final float x1 = getRawX(event, 1); - final float y1 = getRawY(event, 1); - - - Log.d(TAG, - String.format("x0: %f, y0: %f, x1: %f, y1: %f, EdgeSlop: %f, RightSlop: %f, BottomSlop: %f", - x0, y0, x1, y1, edgeSlop, rightSlop, bottomSlop)); - - - boolean p0sloppy = x0 < edgeSlop || y0 < edgeSlop - || x0 > rightSlop || y0 > bottomSlop; - boolean p1sloppy = x1 < edgeSlop || y1 < edgeSlop - || x1 > rightSlop || y1 > bottomSlop; - - if (p0sloppy && p1sloppy) { - return true; - } else if (p0sloppy) { - return true; - } else if (p1sloppy) { - return true; - } - return false; - } - - /** - * MotionEvent has no getRawX(int) method; simulate it pending future API approval. - * - * @param event - * @param pointerIndex - * @return - */ - protected static float getRawX(MotionEvent event, int pointerIndex) { - float offset = event.getX() - event.getRawX(); - if (pointerIndex < event.getPointerCount()) { - return event.getX(pointerIndex) + offset; - } - return 0f; - } - - /** - * MotionEvent has no getRawY(int) method; simulate it pending future API approval. - * - * @param event - * @param pointerIndex - * @return - */ - protected static float getRawY(MotionEvent event, int pointerIndex) { - float offset = Math.abs(event.getY() - event.getRawY()); - if (pointerIndex < event.getPointerCount()) { - return event.getY(pointerIndex) + offset; - } - return 0f; - } - -} diff --git a/src/org/thoughtcrime/securesms/scribbles/viewmodel/Font.java b/src/org/thoughtcrime/securesms/scribbles/viewmodel/Font.java deleted file mode 100644 index 2de6decf4..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/viewmodel/Font.java +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright (c) 2016 UPTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package org.thoughtcrime.securesms.scribbles.viewmodel; - - -public class Font { - - /** - * color value (ex: 0xFF00FF) - */ - private int color; - /** - * name of the font - */ - private String typeface; - /** - * size of the font, relative to parent - */ - private float size; - - public Font() { - } - - public void increaseSize(float diff) { - if (size + diff <= Limits.MAX_FONT_SIZE) { - size = size + diff; - } - } - - public void decreaseSize(float diff) { - if (size - diff >= Limits.MIN_FONT_SIZE) { - size = size - diff; - } - } - - public int getColor() { - return color; - } - - public void setColor(int color) { - this.color = color; - } - - public String getTypeface() { - return typeface; - } - - public void setTypeface(String typeface) { - this.typeface = typeface; - } - - public float getSize() { - return size; - } - - public void setSize(float size) { - this.size = size; - } - - private interface Limits { - float MIN_FONT_SIZE = 0.01F; - float MAX_FONT_SIZE = 0.46F; - } -} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/scribbles/viewmodel/Layer.java b/src/org/thoughtcrime/securesms/scribbles/viewmodel/Layer.java deleted file mode 100644 index 06d99941d..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/viewmodel/Layer.java +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Copyright (c) 2016 UPTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package org.thoughtcrime.securesms.scribbles.viewmodel; - -import android.support.annotation.FloatRange; -import org.thoughtcrime.securesms.logging.Log; - -public class Layer { - - /** - * rotation relative to the layer center, in degrees - */ - @FloatRange(from = 0.0F, to = 360.0F) - private float rotationInDegrees; - - private float scale; - /** - * top left X coordinate, relative to parent canvas - */ - private float x; - /** - * top left Y coordinate, relative to parent canvas - */ - private float y; - /** - * is layer flipped horizontally (by X-coordinate) - */ - private boolean isFlipped; - - public Layer() { - reset(); - } - - protected void reset() { - this.rotationInDegrees = 0.0F; - this.scale = 1.0F; - this.isFlipped = false; - this.x = 0.0F; - this.y = 0.0F; - } - - public void postScale(float scaleDiff) { - Log.i("Layer", "ScaleDiff: " + scaleDiff); - float newVal = scale + scaleDiff; - if (newVal >= getMinScale() && newVal <= getMaxScale()) { - scale = newVal; - } - } - - protected float getMaxScale() { - return Limits.MAX_SCALE; - } - - protected float getMinScale() { - return Limits.MIN_SCALE; - } - - public void postRotate(float rotationInDegreesDiff) { - this.rotationInDegrees += rotationInDegreesDiff; - this.rotationInDegrees %= 360.0F; - } - - public void postTranslate(float dx, float dy) { - this.x += dx; - this.y += dy; - } - - public void flip() { - this.isFlipped = !isFlipped; - } - - public float initialScale() { - return Limits.INITIAL_ENTITY_SCALE; - } - - public float getRotationInDegrees() { - return rotationInDegrees; - } - - public void setRotationInDegrees(@FloatRange(from = 0.0, to = 360.0) float rotationInDegrees) { - this.rotationInDegrees = rotationInDegrees; - } - - public float getScale() { - return scale; - } - - public void setScale(float scale) { - this.scale = scale; - } - - public float getX() { - return x; - } - - public void setX(float x) { - this.x = x; - } - - public float getY() { - return y; - } - - public void setY(float y) { - this.y = y; - } - - public boolean isFlipped() { - return isFlipped; - } - - public void setFlipped(boolean flipped) { - isFlipped = flipped; - } - - interface Limits { - float MIN_SCALE = 0.06F; - float MAX_SCALE = 4.0F; - float INITIAL_ENTITY_SCALE = 0.4F; - } -} diff --git a/src/org/thoughtcrime/securesms/scribbles/viewmodel/TextLayer.java b/src/org/thoughtcrime/securesms/scribbles/viewmodel/TextLayer.java deleted file mode 100644 index 4d9e77a3b..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/viewmodel/TextLayer.java +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Copyright (c) 2016 UPTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package org.thoughtcrime.securesms.scribbles.viewmodel; - -public class TextLayer extends Layer { - - private String text; - private Font font; - - public TextLayer() { - } - - @Override - protected void reset() { - super.reset(); - this.text = ""; - this.font = new Font(); - } - - @Override - protected float getMaxScale() { - return Limits.MAX_SCALE; - } - - @Override - protected float getMinScale() { - return Limits.MIN_SCALE; - } - - @Override - public float initialScale() { - return Limits.INITIAL_SCALE; - } - - public String getText() { - return text; - } - - public void setText(String text) { - this.text = text; - } - - public Font getFont() { - return font; - } - - public void setFont(Font font) { - this.font = font; - } - - @Override - public void postScale(float scaleDiff) { - if (scaleDiff > 0) font.increaseSize(scaleDiff); - else if (scaleDiff < 0) font.decreaseSize(Math.abs(scaleDiff)); - } - - public interface Limits { - /** - * limit text size to view bounds - * so that users don't put small font size and scale it 100+ times - */ - float MAX_SCALE = 1.0F; - float MIN_SCALE = 0.2F; - - float MIN_BITMAP_HEIGHT = 0.13F; - - float FONT_SIZE_STEP = 0.008F; - - float INITIAL_FONT_SIZE = 0.1F; - int INITIAL_FONT_COLOR = 0xff000000; - - float INITIAL_SCALE = 0.8F; // set the same to avoid text scaling - } -} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/scribbles/widget/CanvasView.java b/src/org/thoughtcrime/securesms/scribbles/widget/CanvasView.java deleted file mode 100644 index d58b7b919..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/widget/CanvasView.java +++ /dev/null @@ -1,881 +0,0 @@ -/** - * CanvasView.java - * - * Copyright (c) 2014 Tomohiro IKEDA (Korilakkuma) - * Released under the MIT license - */ - -package org.thoughtcrime.securesms.scribbles.widget; - - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Bitmap.CompressFormat; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Matrix; -import android.graphics.Paint; -import android.graphics.Path; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffXfermode; -import android.graphics.RectF; -import android.support.annotation.NonNull; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; - -import java.io.ByteArrayOutputStream; -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -/** - * This class defines fields and methods for drawing. - */ -public class CanvasView extends View { - - private static final String TAG = CanvasView.class.getSimpleName(); - - public static final int DEFAULT_STROKE_WIDTH = 15; - - // Enumeration for Mode - public enum Mode { - DRAW, - TEXT, - ERASER; - } - - // Enumeration for Drawer - public enum Drawer { - PEN, - LINE, - RECTANGLE, - CIRCLE, - ELLIPSE, - QUADRATIC_BEZIER, - QUBIC_BEZIER; - } - - private int initialWidth = 0; - private int initialHeight = 0; - private int canvasWidth = 1; - private int canvasHeight = 1; - private Bitmap bitmap = null; - - private List pathLists = new ArrayList(); - private List paintLists = new ArrayList(); - - // for Eraser -// private int baseColor = Color.WHITE; - private int baseColor = Color.TRANSPARENT; - - // for Undo, Redo - private int historyPointer = 0; - - // Flags - private Mode mode = Mode.DRAW; - private Drawer drawer = Drawer.PEN; - private boolean isDown = false; - - // for Paint - private Paint.Style paintStyle = Paint.Style.STROKE; - private int paintStrokeColor = Color.BLACK; - private int paintFillColor = Color.BLACK; - private float paintStrokeWidth = DEFAULT_STROKE_WIDTH; - private int opacity = 255; - private float blur = 0F; - private Paint.Cap lineCap = Paint.Cap.ROUND; - - // for Drawer - private float startX = 0F; - private float startY = 0F; - private float controlX = 0F; - private float controlY = 0F; - - private boolean active = false; - - /** - * Copy Constructor - * - * @param context - * @param attrs - * @param defStyle - */ - public CanvasView(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - this.setup(); - } - - /** - * Copy Constructor - * - * @param context - * @param attrs - */ - public CanvasView(Context context, AttributeSet attrs) { - super(context, attrs); - this.setup(); - } - - /** - * Copy Constructor - * - * @param context - */ - public CanvasView(Context context) { - super(context); - } - - - private void setup() { - this.pathLists.add(new Path()); - this.paintLists.add(this.createPaint()); - this.historyPointer++; - } - - /** - * This method creates the instance of Paint. - * In addition, this method sets styles for Paint. - * - * @return paint This is returned as the instance of Paint - */ - private Paint createPaint() { - Paint paint = new Paint(); - - paint.setAntiAlias(true); - paint.setStyle(this.paintStyle); - paint.setStrokeWidth(this.paintStrokeWidth); - paint.setStrokeCap(this.lineCap); - paint.setStrokeJoin(Paint.Join.ROUND); // fixed - - if (this.mode == Mode.ERASER) { - // Eraser - paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); - paint.setARGB(0, 0, 0, 0); - - // paint.setColor(this.baseColor); - // paint.setShadowLayer(this.blur, 0F, 0F, this.baseColor); - } else { - // Otherwise - paint.setColor(this.paintStrokeColor); - paint.setShadowLayer(this.blur, 0F, 0F, this.paintStrokeColor); - paint.setAlpha(this.opacity); - } - - return paint; - } - - /** - * This method initialize Path. - * Namely, this method creates the instance of Path, - * and moves current position. - * - * @param event This is argument of onTouchEvent method - * @return path This is returned as the instance of Path - */ - private Path createPath(MotionEvent event) { - Path path = new Path(); - - // Save for ACTION_MOVE - this.startX = scaleX(event.getX()); - this.startY = scaleY(event.getY()); - - path.moveTo(this.startX, this.startY); - - return path; - } - - /** - * This method updates the lists for the instance of Path and Paint. - * "Undo" and "Redo" are enabled by this method. - * - * @param path the instance of Path - */ - private void updateHistory(Path path) { - if (this.historyPointer == this.pathLists.size()) { - this.pathLists.add(path); - this.paintLists.add(this.createPaint()); - this.historyPointer++; - } else { - // On the way of Undo or Redo - this.pathLists.set(this.historyPointer, path); - this.paintLists.set(this.historyPointer, this.createPaint()); - this.historyPointer++; - - for (int i = this.historyPointer, size = this.paintLists.size(); i < size; i++) { - this.pathLists.remove(this.historyPointer); - this.paintLists.remove(this.historyPointer); - } - } - } - - /** - * This method gets the instance of Path that pointer indicates. - * - * @return the instance of Path - */ - private Path getCurrentPath() { - return this.pathLists.get(this.historyPointer - 1); - } - - /** - * This method defines processes on MotionEvent.ACTION_DOWN - * - * @param event This is argument of onTouchEvent method - */ - private void onActionDown(MotionEvent event) { - switch (this.mode) { - case DRAW : - case ERASER : - if ((this.drawer != Drawer.QUADRATIC_BEZIER) && (this.drawer != Drawer.QUBIC_BEZIER)) { - // Oherwise - this.updateHistory(this.createPath(event)); - this.isDown = true; - } else { - // Bezier - if ((this.startX == 0F) && (this.startY == 0F)) { - // The 1st tap - this.updateHistory(this.createPath(event)); - } else { - // The 2nd tap - this.controlX = event.getX(); - this.controlY = event.getY(); - - this.isDown = true; - } - } - - break; - case TEXT : - this.startX = event.getX(); - this.startY = event.getY(); - - break; - default : - break; - } - } - - /** - * This method defines processes on MotionEvent.ACTION_MOVE - * - * @param event This is argument of onTouchEvent method - */ - private void onActionMove(MotionEvent event) { - float x = event.getX(); - float y = event.getY(); - - switch (this.mode) { - case DRAW : - case ERASER : - - if ((this.drawer != Drawer.QUADRATIC_BEZIER) && (this.drawer != Drawer.QUBIC_BEZIER)) { - if (!isDown) { - return; - } - - Path path = this.getCurrentPath(); - - switch (this.drawer) { - case PEN : - for (int i = 0; i < event.getHistorySize(); i++) { - path.lineTo(scaleX(event.getHistoricalX(i)), scaleY(event.getHistoricalY(i))); - } - break; - case LINE : - path.reset(); - path.moveTo(this.startX, this.startY); - path.lineTo(x, y); - break; - case RECTANGLE : - path.reset(); - path.addRect(this.startX, this.startY, x, y, Path.Direction.CCW); - break; - case CIRCLE : - double distanceX = Math.abs((double)(this.startX - x)); - double distanceY = Math.abs((double)(this.startX - y)); - double radius = Math.sqrt(Math.pow(distanceX, 2.0) + Math.pow(distanceY, 2.0)); - - path.reset(); - path.addCircle(this.startX, this.startY, (float)radius, Path.Direction.CCW); - break; - case ELLIPSE : - RectF rect = new RectF(this.startX, this.startY, x, y); - - path.reset(); - path.addOval(rect, Path.Direction.CCW); - break; - default : - break; - } - } else { - if (!isDown) { - return; - } - - Path path = this.getCurrentPath(); - - path.reset(); - path.moveTo(scaleX(this.startX), scaleY(this.startY)); - path.quadTo(this.controlX, this.controlY, x, y); - } - - break; - case TEXT : - this.startX = x; - this.startY = y; - - break; - default : - break; - } - } - - /** - * This method defines processes on MotionEvent.ACTION_DOWN - * - * @param event This is argument of onTouchEvent method - */ - private void onActionUp(MotionEvent event) { - if (isDown) { - this.startX = 0F; - this.startY = 0F; - this.isDown = false; - } - } - - public SavedState saveState() { - return new SavedState(pathLists, paintLists, historyPointer, initialWidth, initialHeight, canvasWidth, canvasHeight); - } - - public void restoreState(@NonNull SavedState state) { - this.pathLists.clear(); - this.pathLists.addAll(state.getPaths()); - - this.paintLists.clear(); - this.paintLists.addAll(state.getPaints()); - - this.historyPointer = state.getHistoryPointer(); - - this.initialWidth = state.getInitialWidth(); - this.initialHeight = state.getInitialHeight(); - - postInvalidate(); - } - - public void setActive(boolean active) { - this.active = active; - } - - /** - * This method updates the instance of Canvas (View) - * - * @param canvas the new instance of Canvas - */ - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - - canvas.drawColor(this.baseColor); - render(canvas); - } - - @Override - protected void onSizeChanged(int w, int h, int oldw, int oldh) { - super.onSizeChanged(w, h, oldw, oldh); - this.canvasWidth = w; - this.canvasHeight = h; - - if (initialWidth == 0) { - initialWidth = canvasWidth; - } - - if (initialHeight == 0) { - initialHeight = canvasHeight; - } - } - - public void render(Canvas canvas) { - render(canvas, initialWidth, initialHeight, canvasWidth, canvasHeight, pathLists, paintLists, historyPointer); - } - - public static void render(Canvas canvas, int initialWidth, int initialHeight, int canvasWidth, int canvasHeight, List pathLists, List paintLists, int historyPointer) { - float scaleX = 1f; - float scaleY = 1f; - - if (initialWidth > 0) { - scaleX *= (float) canvasWidth / initialWidth; - } - - if (initialHeight > 0) { - scaleY *= (float) canvasHeight / initialHeight; - } - - scaleX *= (float) canvas.getWidth() / canvasWidth; - scaleY *= (float) canvas.getHeight() / canvasHeight; - - Matrix matrix = new Matrix(); - matrix.setScale(scaleX, scaleY); - - for (int i = 0; i < historyPointer; i++) { - Path path = pathLists.get(i); - Paint paint = paintLists.get(i); - - Path scaledPath = new Path(); - path.transform(matrix, scaledPath); - - Paint scaledPaint = new Paint(paint); - scaledPaint.setStrokeWidth(scaledPaint.getStrokeWidth() * scaleX); - - canvas.drawPath(scaledPath, scaledPaint); - } - } - - /** - * This method set event listener for drawing. - * - * @param event the instance of MotionEvent - * @return - */ - @Override - public boolean onTouchEvent(MotionEvent event) { - if (!active) return false; - - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - this.onActionDown(event); - break; - case MotionEvent.ACTION_MOVE : - this.onActionMove(event); - break; - case MotionEvent.ACTION_UP : - this.onActionUp(event); - break; - default : - break; - } - - // Re draw - this.invalidate(); - - return true; - } - - /** - * This method is getter for mode. - * - * @return - */ - public Mode getMode() { - return this.mode; - } - - /** - * This method is setter for mode. - * - * @param mode - */ - public void setMode(Mode mode) { - this.mode = mode; - } - - /** - * This method is getter for drawer. - * - * @return - */ - public Drawer getDrawer() { - return this.drawer; - } - - /** - * This method is setter for drawer. - * - * @param drawer - */ - public void setDrawer(Drawer drawer) { - this.drawer = drawer; - } - - /** - * This method draws canvas again for Undo. - * - * @return If Undo is enabled, this is returned as true. Otherwise, this is returned as false. - */ - public boolean undo() { - if (this.historyPointer > 1) { - this.historyPointer--; - this.invalidate(); - - return true; - } else { - return false; - } - } - - /** - * This method draws canvas again for Redo. - * - * @return If Redo is enabled, this is returned as true. Otherwise, this is returned as false. - */ - public boolean redo() { - if (this.historyPointer < this.pathLists.size()) { - this.historyPointer++; - this.invalidate(); - - return true; - } else { - return false; - } - } - - /** - * This method initializes canvas. - * - * @return - */ - public void clear() { - Path path = new Path(); - path.moveTo(0F, 0F); - path.addRect(0F, 0F, 1000F, 1000F, Path.Direction.CCW); - path.close(); - - Paint paint = new Paint(); - paint.setColor(Color.WHITE); - paint.setStyle(Paint.Style.FILL); - - if (this.historyPointer == this.pathLists.size()) { - this.pathLists.add(path); - this.paintLists.add(paint); - this.historyPointer++; - } else { - // On the way of Undo or Redo - this.pathLists.set(this.historyPointer, path); - this.paintLists.set(this.historyPointer, paint); - this.historyPointer++; - - for (int i = this.historyPointer, size = this.paintLists.size(); i < size; i++) { - this.pathLists.remove(this.historyPointer); - this.paintLists.remove(this.historyPointer); - } - } - - // Clear - this.invalidate(); - } - - /** - * This method is getter for canvas background color - * - * @return - */ - public int getBaseColor() { - return this.baseColor; - } - - /** - * This method is setter for canvas background color - * - * @param color - */ - public void setBaseColor(int color) { - this.baseColor = color; - } - - /** - * This method is getter for stroke or fill. - * - * @return - */ - public Paint.Style getPaintStyle() { - return this.paintStyle; - } - - /** - * This method is setter for stroke or fill. - * - * @param style - */ - public void setPaintStyle(Paint.Style style) { - this.paintStyle = style; - } - - /** - * This method is getter for stroke color. - * - * @return - */ - public int getPaintStrokeColor() { - return this.paintStrokeColor; - } - - /** - * This method is setter for stroke color. - * - * @param color - */ - public void setPaintStrokeColor(int color) { - this.paintStrokeColor = color; - } - - /** - * This method is getter for fill color. - * But, current Android API cannot set fill color (?). - * - * @return - */ - public int getPaintFillColor() { - return this.paintFillColor; - }; - - /** - * This method is setter for fill color. - * But, current Android API cannot set fill color (?). - * - * @param color - */ - public void setPaintFillColor(int color) { - this.paintFillColor = color; - } - - /** - * This method is getter for stroke width. - * - * @return - */ - public float getPaintStrokeWidth() { - return this.paintStrokeWidth; - } - - /** - * This method is setter for stroke width. - * - * @param width - */ - public void setPaintStrokeWidth(float width) { - if (width >= 0) { - this.paintStrokeWidth = width; - } else { - this.paintStrokeWidth = 3F; - } - } - - /** - * This method is getter for alpha. - * - * @return - */ - public int getOpacity() { - return this.opacity; - } - - /** - * This method is setter for alpha. - * The 1st argument must be between 0 and 255. - * - * @param opacity - */ - public void setOpacity(int opacity) { - if ((opacity >= 0) && (opacity <= 255)) { - this.opacity = opacity; - } else { - this.opacity= 255; - } - } - - /** - * This method is getter for amount of blur. - * - * @return - */ - public float getBlur() { - return this.blur; - } - - /** - * This method is setter for amount of blur. - * The 1st argument is greater than or equal to 0.0. - * - * @param blur - */ - public void setBlur(float blur) { - if (blur >= 0) { - this.blur = blur; - } else { - this.blur = 0F; - } - } - - /** - * This method is getter for line cap. - * - * @return - */ - public Paint.Cap getLineCap() { - return this.lineCap; - } - - /** - * This method is setter for line cap. - * - * @param cap - */ - public void setLineCap(Paint.Cap cap) { - this.lineCap = cap; - } - - /** - * This method gets current canvas as bitmap. - * - * @return This is returned as bitmap. - */ - public Bitmap getBitmap() { - this.setDrawingCacheEnabled(false); - this.setDrawingCacheEnabled(true); - - return Bitmap.createBitmap(this.getDrawingCache()); - } - - /** - * This method gets current canvas as scaled bitmap. - * - * @return This is returned as scaled bitmap. - */ - public Bitmap getScaleBitmap(int w, int h) { - this.setDrawingCacheEnabled(false); - this.setDrawingCacheEnabled(true); - - return Bitmap.createScaledBitmap(this.getDrawingCache(), w, h, true); - } - - /** - * This method draws the designated bitmap to canvas. - * - * @param bitmap - */ - public void drawBitmap(Bitmap bitmap) { - this.bitmap = bitmap; - this.invalidate(); - } - - /** - * This method draws the designated byte array of bitmap to canvas. - * - * @param byteArray This is returned as byte array of bitmap. - */ - public void drawBitmap(byte[] byteArray) { - this.drawBitmap(BitmapFactory.decodeByteArray(byteArray, 0, byteArray.length)); - } - - /** - * This static method gets the designated bitmap as byte array. - * - * @param bitmap - * @param format - * @param quality - * @return This is returned as byte array of bitmap. - */ - public static byte[] getBitmapAsByteArray(Bitmap bitmap, CompressFormat format, int quality) { - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - bitmap.compress(format, quality, byteArrayOutputStream); - - return byteArrayOutputStream.toByteArray(); - } - - /** - * This method gets the bitmap as byte array. - * - * @param format - * @param quality - * @return This is returned as byte array of bitmap. - */ - public byte[] getBitmapAsByteArray(CompressFormat format, int quality) { - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - this.getBitmap().compress(format, quality, byteArrayOutputStream); - - return byteArrayOutputStream.toByteArray(); - } - - /** - * This method gets the bitmap as byte array. - * Bitmap format is PNG, and quality is 100. - * - * @return This is returned as byte array of bitmap. - */ - public byte[] getBitmapAsByteArray() { - return this.getBitmapAsByteArray(CompressFormat.PNG, 100); - } - - public @NonNull Set getUniqueColors() { - Set colors = new LinkedHashSet<>(); - - for (int i = 1; i < paintLists.size() && i < historyPointer; i++) { - int color = paintLists.get(i).getColor(); - colors.add(Color.rgb(Color.red(color), Color.green(color), Color.blue(color))); - } - - return colors; - } - - private float scaleX(float x) { - return ((float) initialWidth / canvasWidth) * x; - } - - private float scaleY(float y) { - return ((float) initialWidth / canvasWidth) * y; - } - - static class SavedState { - private final List paths; - private final List paints; - private final int historyPointer; - private final int initialWidth; - private final int initialHeight; - private final int canvasWidth; - private final int canvasHeight; - - SavedState(List paths, List paints, int historyPointer, int initialWidth, int initialHeight, int canvasWidth, int canvasHeight) { - this.paths = new ArrayList<>(paths); - this.paints = new ArrayList<>(paints); - this.historyPointer = historyPointer; - this.initialWidth = initialWidth; - this.initialHeight = initialHeight; - this.canvasWidth = canvasWidth; - this.canvasHeight = canvasHeight; - } - - List getPaths() { - return paths; - } - - List getPaints() { - return paints; - } - - int getHistoryPointer() { - return historyPointer; - } - - int getInitialWidth() { - return initialWidth; - } - - int getInitialHeight() { - return initialHeight; - } - - int getCanvasWidth() { - return canvasWidth; - } - - int getCanvasHeight() { - return canvasHeight; - } - - boolean isEmpty() { - return paths.size() <= 1; - } - } -} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/scribbles/widget/MotionView.java b/src/org/thoughtcrime/securesms/scribbles/widget/MotionView.java deleted file mode 100644 index 6210eef39..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/widget/MotionView.java +++ /dev/null @@ -1,526 +0,0 @@ -/** - * Copyright (c) 2016 UPTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package org.thoughtcrime.securesms.scribbles.widget; - -import android.annotation.TargetApi; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PointF; -import android.os.Build; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.v4.view.GestureDetectorCompat; -import android.support.v4.view.ViewCompat; -import android.text.Editable; -import android.text.InputType; -import android.text.Selection; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.util.AttributeSet; -import android.util.TypedValue; -import android.view.GestureDetector; -import android.view.Gravity; -import android.view.MotionEvent; -import android.view.ScaleGestureDetector; -import android.view.View; -import android.view.inputmethod.EditorInfo; -import android.view.inputmethod.InputMethodManager; -import android.widget.EditText; -import android.widget.FrameLayout; - -import com.annimon.stream.Stream; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.scribbles.multitouch.MoveGestureDetector; -import org.thoughtcrime.securesms.scribbles.multitouch.RotateGestureDetector; -import org.thoughtcrime.securesms.scribbles.widget.entity.MotionEntity; -import org.thoughtcrime.securesms.scribbles.widget.entity.TextEntity; - -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -public class MotionView extends FrameLayout implements TextWatcher { - - private static final String TAG = MotionView.class.getSimpleName(); - - public interface Constants { - float SELECTED_LAYER_ALPHA = 0.15F; - } - - public interface MotionViewCallback { - void onEntitySelected(@Nullable MotionEntity entity); - void onEntityDoubleTap(@NonNull MotionEntity entity); - } - - // layers - private final List entities = new ArrayList<>(); - @Nullable - private MotionEntity selectedEntity; - - private Paint selectedLayerPaint; - - // callback - @Nullable - private MotionViewCallback motionViewCallback; - - private EditText editText; - - // gesture detection - private ScaleGestureDetector scaleGestureDetector; - private RotateGestureDetector rotateGestureDetector; - private MoveGestureDetector moveGestureDetector; - private GestureDetectorCompat gestureDetectorCompat; - - // constructors - public MotionView(Context context) { - super(context); - init(context, null); - } - - public MotionView(Context context, AttributeSet attrs) { - super(context, attrs); - init(context, attrs); - } - - public MotionView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - init(context, attrs); - } - - @SuppressWarnings("unused") - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - public MotionView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - init(context, attrs); - } - - private void init(@NonNull Context context, @Nullable AttributeSet attrs) { - // I fucking love Android - setWillNotDraw(false); - - selectedLayerPaint = new Paint(); - selectedLayerPaint.setAlpha((int) (255 * Constants.SELECTED_LAYER_ALPHA)); - selectedLayerPaint.setAntiAlias(true); - - this.editText = new EditText(context, attrs); - ViewCompat.setAlpha(this.editText, 0); - this.editText.setLayoutParams(new LayoutParams(1, 1, Gravity.TOP | Gravity.LEFT)); - this.editText.setClickable(false); - this.editText.setBackgroundColor(Color.TRANSPARENT); - this.editText.setTextSize(TypedValue.COMPLEX_UNIT_SP, 1); - this.editText.setInputType(InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD); - this.editText.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI); - this.addView(editText); - this.editText.clearFocus(); - this.editText.addTextChangedListener(this); - this.editText.setId(R.id.motion_view_edittext); - - // init listeners - this.scaleGestureDetector = new ScaleGestureDetector(context, new ScaleListener()); - this.rotateGestureDetector = new RotateGestureDetector(context, new RotateListener()); - this.moveGestureDetector = new MoveGestureDetector(context, new MoveListener()); - this.gestureDetectorCompat = new GestureDetectorCompat(context, new TapsListener()); - - setOnTouchListener(onTouchListener); - - updateUI(); - } - - public SavedState saveState() { - return new SavedState(entities); - } - - public void restoreState(@NonNull SavedState savedState) { - this.entities.clear(); - this.entities.addAll(savedState.getEntities()); - - postInvalidate(); - } - - public void startEditing(TextEntity entity) { - editText.setFocusableInTouchMode(true); - editText.setFocusable(true); - editText.requestFocus(); - editText.setText(entity.getLayer().getText()); - Selection.setSelection(editText.getText(), editText.length()); - - InputMethodManager ims = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - ims.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT); - } - - public MotionEntity getSelectedEntity() { - return selectedEntity; - } - - public List getEntities() { - return entities; - } - - public void setMotionViewCallback(@Nullable MotionViewCallback callback) { - this.motionViewCallback = callback; - } - - public void addEntity(@Nullable MotionEntity entity) { - if (entity != null) { - entities.add(entity); - selectEntity(entity, false); - } - } - - public void addEntityAndPosition(@Nullable MotionEntity entity) { - if (entity != null) { - initEntityBorder(entity); - initialTranslateAndScale(entity); - entities.add(entity); - selectEntity(entity, true); - } - } - - public @NonNull Set getUniqueColors() { - Set colors = new LinkedHashSet<>(); - - for (MotionEntity entity : entities) { - if (entity instanceof TextEntity) { - colors.add(((TextEntity) entity).getLayer().getFont().getColor()); - } - } - - return colors; - } - - private void initEntityBorder(@NonNull MotionEntity entity ) { - // init stroke - int strokeSize = getResources().getDimensionPixelSize(R.dimen.scribble_stroke_size); - Paint borderPaint = new Paint(); - borderPaint.setStrokeWidth(strokeSize); - borderPaint.setAntiAlias(true); - borderPaint.setColor(getContext().getResources().getColor(R.color.sticker_selected_color)); - - entity.setBorderPaint(borderPaint); - } - - @Override - protected void dispatchDraw(Canvas canvas) { - super.dispatchDraw(canvas); - - // dispatch draw is called after child views is drawn. - // the idea that is we draw background stickers, than child views (if any), and than selected item - // to draw on top of child views - do it in dispatchDraw(Canvas) - // to draw below that - do it in onDraw(Canvas) - if (selectedEntity != null) { - selectedEntity.draw(canvas, selectedLayerPaint); - } - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - render(canvas, entities); - } - - public void render(Canvas canvas) { - unselectEntity(); - draw(canvas); - } - - public static void render(Canvas canvas, List entities) { - for (int i = 0; i < entities.size(); i++) { - entities.get(i).draw(canvas, null); - } - } - - /** - * as a side effect - the method deselects Entity (if any selected) - * @return bitmap with all the Entities at their current positions - */ - public Bitmap getThumbnailImage() { - selectEntity(null, false); - - Bitmap bmp = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888); - // IMPORTANT: always create white background, cos if the image is saved in JPEG format, - // which doesn't have transparent pixels, the background will be black - bmp.eraseColor(Color.WHITE); - Canvas canvas = new Canvas(bmp); - render(canvas, entities); - - return bmp; - } - - private void updateUI() { - invalidate(); - } - - private void handleTranslate(PointF delta) { - if (selectedEntity != null) { - float newCenterX = selectedEntity.absoluteCenterX() + delta.x; - float newCenterY = selectedEntity.absoluteCenterY() + delta.y; - // limit entity center to screen bounds - boolean needUpdateUI = false; - if (newCenterX >= 0 && newCenterX <= getWidth()) { - selectedEntity.getLayer().postTranslate(delta.x / getWidth(), 0.0F); - needUpdateUI = true; - } - if (newCenterY >= 0 && newCenterY <= getHeight()) { - selectedEntity.getLayer().postTranslate(0.0F, delta.y / getHeight()); - needUpdateUI = true; - } - if (needUpdateUI) { - updateUI(); - } - } - } - - private void initialTranslateAndScale(@NonNull MotionEntity entity) { - entity.moveToCanvasCenter(); - entity.getLayer().setScale(entity.getLayer().initialScale()); - } - - private void selectEntity(@Nullable MotionEntity entity, boolean updateCallback) { - if (selectedEntity != null && entity != selectedEntity) { - selectedEntity.setIsSelected(false); - - if (selectedEntity instanceof TextEntity) { - if (TextUtils.isEmpty(((TextEntity) selectedEntity).getLayer().getText())) { - deletedSelectedEntity(); - } else { - editText.clearComposingText(); - editText.clearFocus(); - } - InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - imm.hideSoftInputFromWindow(editText.getWindowToken(), 0); - } - - } - if (entity != null) { - entity.setIsSelected(true); - } - selectedEntity = entity; - invalidate(); - if (updateCallback && motionViewCallback != null) { - motionViewCallback.onEntitySelected(entity); - } - } - - public void unselectEntity() { - if (selectedEntity != null) { - selectEntity(null, false); - } - } - - @Nullable - private MotionEntity findEntityAtPoint(float x, float y) { - MotionEntity selected = null; - PointF p = new PointF(x, y); - for (int i = entities.size() - 1; i >= 0; i--) { - if (entities.get(i).pointInLayerRect(p)) { - selected = entities.get(i); - break; - } - } - return selected; - } - - private void updateSelectionOnTap(MotionEvent e) { - MotionEntity entity = findEntityAtPoint(e.getX(), e.getY()); - selectEntity(entity, true); - } - - private void updateOnLongPress(MotionEvent e) { - // if layer is currently selected and point inside layer - move it to front - if (selectedEntity != null) { - PointF p = new PointF(e.getX(), e.getY()); - if (selectedEntity.pointInLayerRect(p)) { - bringLayerToFront(selectedEntity); - } - } - } - - private void bringLayerToFront(@NonNull MotionEntity entity) { - // removing and adding brings layer to front - if (entities.remove(entity)) { - entities.add(entity); - invalidate(); - } - } - - private void moveEntityToBack(@Nullable MotionEntity entity) { - if (entity == null) { - return; - } - if (entities.remove(entity)) { - entities.add(0, entity); - invalidate(); - } - } - - public void flipSelectedEntity() { - if (selectedEntity == null) { - return; - } - selectedEntity.getLayer().flip(); - invalidate(); - } - - public void moveSelectedBack() { - moveEntityToBack(selectedEntity); - } - - public void deletedSelectedEntity() { - if (selectedEntity == null) { - return; - } - if (entities.remove(selectedEntity)) { - selectedEntity.release(); - selectedEntity = null; - invalidate(); - } - } - - // memory - public void release() { - for (MotionEntity entity : entities) { - entity.release(); - } - } - - // gesture detectors - - private final View.OnTouchListener onTouchListener = new View.OnTouchListener() { - - @Override - public boolean onTouch(View v, MotionEvent event) { - if (scaleGestureDetector != null) { - scaleGestureDetector.onTouchEvent(event); - rotateGestureDetector.onTouchEvent(event); - moveGestureDetector.onTouchEvent(event); - gestureDetectorCompat.onTouchEvent(event); - } - return true; - } - }; - - private class TapsListener extends GestureDetector.SimpleOnGestureListener { - @Override - public boolean onDoubleTap(MotionEvent e) { - if (motionViewCallback != null && selectedEntity != null) { - motionViewCallback.onEntityDoubleTap(selectedEntity); - } - return true; - } - - @Override - public void onLongPress(MotionEvent e) { - updateOnLongPress(e); - } - - @Override - public boolean onSingleTapUp(MotionEvent e) { - updateSelectionOnTap(e); - return true; - } - - @Override - public boolean onDown(MotionEvent e) { - updateSelectionOnTap(e); - return false; - } - } - - private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener { - @Override - public boolean onScale(ScaleGestureDetector detector) { - if (selectedEntity != null) { - float scaleFactorDiff = detector.getScaleFactor(); - Log.d(TAG, "ScaleFactorDiff: " + scaleFactorDiff); - selectedEntity.getLayer().postScale(scaleFactorDiff - 1.0F); - selectedEntity.updateEntity(); - updateUI(); - } - return true; - } - } - - private class RotateListener extends RotateGestureDetector.SimpleOnRotateGestureListener { - @Override - public boolean onRotate(RotateGestureDetector detector) { - if (selectedEntity != null) { - selectedEntity.getLayer().postRotate(-detector.getRotationDegreesDelta()); - updateUI(); - } - return true; - } - } - - private class MoveListener extends MoveGestureDetector.SimpleOnMoveGestureListener { - @Override - public boolean onMove(MoveGestureDetector detector) { - handleTranslate(detector.getFocusDelta()); - return true; - } - } - - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) {} - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) {} - - @Override - public void afterTextChanged(Editable s) { - String text = s.toString(); - MotionEntity entity = getSelectedEntity(); - - if (entity != null && entity instanceof TextEntity) { - TextEntity textEntity = (TextEntity)entity; - - if (!textEntity.getLayer().getText().equals(text)) { - textEntity.getLayer().setText(text); - textEntity.updateEntity(); - MotionView.this.invalidate(); - } - } - } - - static class SavedState { - - private final List entities; - - SavedState(List entities) { - this.entities = new ArrayList<>(entities); - Stream.of(entities).forEach(e -> e.setIsSelected(false)); - } - - List getEntities() { - return entities; - } - - boolean isEmpty() { - return entities.isEmpty(); - } - } -} diff --git a/src/org/thoughtcrime/securesms/scribbles/widget/ScribbleView.java b/src/org/thoughtcrime/securesms/scribbles/widget/ScribbleView.java deleted file mode 100644 index a7453e24d..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/widget/ScribbleView.java +++ /dev/null @@ -1,270 +0,0 @@ -/* - * Copyright (C) 2016 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.scribbles.widget; - -import android.annotation.SuppressLint; -import android.annotation.TargetApi; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.os.Build; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.widget.FrameLayout; -import android.widget.ImageView; - -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; -import com.bumptech.glide.request.target.SimpleTarget; -import com.bumptech.glide.request.target.Target; -import com.bumptech.glide.request.transition.Transition; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; -import org.thoughtcrime.securesms.mms.GlideRequests; -import org.thoughtcrime.securesms.scribbles.widget.entity.MotionEntity; -import org.thoughtcrime.securesms.scribbles.widget.entity.TextEntity; -import org.thoughtcrime.securesms.util.Util; -import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; -import org.thoughtcrime.securesms.util.concurrent.SettableFuture; - -import java.util.LinkedHashSet; -import java.util.Set; - -public class ScribbleView extends FrameLayout { - - private static final String TAG = ScribbleView.class.getSimpleName(); - - public static final int DEFAULT_BRUSH_WIDTH = CanvasView.DEFAULT_STROKE_WIDTH; - - private ImageView imageView; - private MotionView motionView; - private CanvasView canvasView; - - private @Nullable Uri imageUri; - - public ScribbleView(Context context) { - super(context); - initialize(context); - } - - public ScribbleView(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(context); - } - - public ScribbleView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(context); - } - - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - public ScribbleView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - initialize(context); - } - - public void setImage(@NonNull GlideRequests glideRequests, @NonNull Uri uri) { - this.imageUri = uri; - - glideRequests.load(new DecryptableUri(uri)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transition(DrawableTransitionOptions.withCrossFade()) - .fitCenter() - .into(imageView); - } - - public @NonNull ListenableFuture getRenderedImage(@NonNull GlideRequests glideRequests) { - return renderImage(getContext(), imageUri, saveState(), glideRequests); - } - - public static @NonNull ListenableFuture renderImage(@NonNull Context context, - @Nullable Uri imageUri, - @NonNull SavedState savedState, - @NonNull GlideRequests glideRequests) - { - final SettableFuture future = new SettableFuture<>(); - final boolean isLowMemory = Util.isLowMemory(context); - - if (imageUri == null) { - future.setException(new IllegalStateException("No image URI.")); - return future; - } - - int width = Target.SIZE_ORIGINAL; - int height = Target.SIZE_ORIGINAL; - - if (isLowMemory) { - width = 768; - height = 768; - } - - glideRequests.asBitmap() - .load(new DecryptableUri(imageUri)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(true) - .override(width, height) - .into(new SimpleTarget() { - @Override - public void onResourceReady(@NonNull Bitmap bitmap, @Nullable Transition transition) { - Canvas canvas = new Canvas(bitmap); - MotionView.render(canvas, savedState.getMotionState().getEntities()); - CanvasView.render(canvas, - savedState.getCanvasState().getInitialWidth(), - savedState.getCanvasState().getInitialHeight(), - savedState.getCanvasState().getCanvasWidth(), - savedState.getCanvasState().getCanvasHeight(), - savedState.getCanvasState().getPaths(), - savedState.getCanvasState().getPaints(), - savedState.getCanvasState().getHistoryPointer()); - future.set(bitmap); - } - - @Override - public void onLoadFailed(@Nullable Drawable errorDrawable) { - future.setException(new Throwable("Failed to load image.")); - } - }); - - return future; - } - - public SavedState saveState() { - return new SavedState(canvasView.saveState(), motionView.saveState()); - } - - public void restoreState(@NonNull SavedState state) { - canvasView.restoreState(state.getCanvasState()); - motionView.restoreState(state.getMotionState()); - } - - private void initialize(@NonNull Context context) { - inflate(context, R.layout.scribble_view, this); - - this.imageView = findViewById(R.id.image_view); - this.motionView = findViewById(R.id.motion_view); - this.canvasView = findViewById(R.id.canvas_view); - } - - public void setMotionViewCallback(MotionView.MotionViewCallback callback) { - this.motionView.setMotionViewCallback(callback); - } - - @SuppressLint("ClickableViewAccessibility") - public void setDrawingChangedListener(@Nullable DrawingChangedListener listener) { - this.canvasView.setOnTouchListener((v, event) -> { - if (event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL) { - if (listener != null) { - listener.onDrawingChanged(); - } - } - return false; - }); - } - - public void setDrawingMode(boolean enabled) { - this.canvasView.setActive(enabled); - if (enabled) this.motionView.unselectEntity(); - } - - public void setDrawingBrushColor(int color) { - this.canvasView.setPaintFillColor(color); - this.canvasView.setPaintStrokeColor(color); - this.canvasView.setOpacity(Color.alpha(color)); - } - - public void setDrawingBrushWidth(int width) { - this.canvasView.setPaintStrokeWidth(width); - } - - public void addEntityAndPosition(MotionEntity entity) { - this.motionView.addEntityAndPosition(entity); - } - - public MotionEntity getSelectedEntity() { - return this.motionView.getSelectedEntity(); - } - - public void deleteSelected() { - this.motionView.deletedSelectedEntity(); - } - - public void clearSelection() { - this.motionView.unselectEntity(); - } - - public void undoDrawing() { - this.canvasView.undo(); - } - - public void startEditing(TextEntity entity) { - this.motionView.startEditing(entity); - } - - public @NonNull Set getUniqueColors() { - Set colors = new LinkedHashSet<>(); - - colors.addAll(motionView.getUniqueColors()); - colors.addAll(canvasView.getUniqueColors()); - - return colors; - } - - @Override - public void onMeasure(int width, int height) { - super.onMeasure(width, height); - - setMeasuredDimension(imageView.getMeasuredWidth(), imageView.getMeasuredHeight()); - - canvasView.measure(MeasureSpec.makeMeasureSpec(imageView.getMeasuredWidth(), MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(imageView.getMeasuredHeight(), MeasureSpec.EXACTLY)); - - motionView.measure(MeasureSpec.makeMeasureSpec(imageView.getMeasuredWidth(), MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(imageView.getMeasuredHeight(), MeasureSpec.EXACTLY)); - } - - public interface DrawingChangedListener { - void onDrawingChanged(); - } - - public static class SavedState { - private final CanvasView.SavedState canvasState; - private final MotionView.SavedState motionState; - - SavedState(@NonNull CanvasView.SavedState canvasState, @NonNull MotionView.SavedState motionState) { - this.canvasState = canvasState; - this.motionState = motionState; - } - - CanvasView.SavedState getCanvasState() { - return canvasState; - } - - MotionView.SavedState getMotionState() { - return motionState; - } - - public boolean isEmpty() { - return canvasState.isEmpty() && motionState.isEmpty(); - } - } -} diff --git a/src/org/thoughtcrime/securesms/scribbles/widget/VerticalSlideColorPicker.java b/src/org/thoughtcrime/securesms/scribbles/widget/VerticalSlideColorPicker.java index 27071eae7..878bd8bb4 100644 --- a/src/org/thoughtcrime/securesms/scribbles/widget/VerticalSlideColorPicker.java +++ b/src/org/thoughtcrime/securesms/scribbles/widget/VerticalSlideColorPicker.java @@ -172,6 +172,8 @@ public class VerticalSlideColorPicker extends View { viewWidth = w; viewHeight = h; + if (viewWidth <= 0 || viewHeight <= 0) return; + int barWidth = (int) (viewWidth * INDICATOR_TO_BAR_WIDTH_RATIO); centerX = viewWidth / 2; diff --git a/src/org/thoughtcrime/securesms/scribbles/widget/entity/ImageEntity.java b/src/org/thoughtcrime/securesms/scribbles/widget/entity/ImageEntity.java deleted file mode 100644 index 23bc3c619..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/widget/entity/ImageEntity.java +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright (c) 2016 UPTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package org.thoughtcrime.securesms.scribbles.widget.entity; - -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.support.annotation.IntRange; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; - -import org.thoughtcrime.securesms.scribbles.viewmodel.Layer; - - -public class ImageEntity extends MotionEntity { - - @NonNull - private final Bitmap bitmap; - - public ImageEntity(@NonNull Layer layer, - @NonNull Bitmap bitmap, - @IntRange(from = 1) int canvasWidth, - @IntRange(from = 1) int canvasHeight) { - super(layer, canvasWidth, canvasHeight); - - this.bitmap = bitmap; - float width = bitmap.getWidth(); - float height = bitmap.getHeight(); - - float widthAspect = 1.0F * canvasWidth / width; - float heightAspect = 1.0F * canvasHeight / height; - // fit the smallest size - holyScale = Math.min(widthAspect, heightAspect); - - // initial position of the entity - srcPoints[0] = 0; srcPoints[1] = 0; - srcPoints[2] = width; srcPoints[3] = 0; - srcPoints[4] = width; srcPoints[5] = height; - srcPoints[6] = 0; srcPoints[7] = height; - srcPoints[8] = 0; srcPoints[8] = 0; - } - - @Override - public void drawContent(@NonNull Canvas canvas, @Nullable Paint drawingPaint) { - canvas.drawBitmap(bitmap, matrix, drawingPaint); - } - - @Override - public int getWidth() { - return bitmap.getWidth(); - } - - @Override - public int getHeight() { - return bitmap.getHeight(); - } - - @Override - public void release() { - if (!bitmap.isRecycled()) { - bitmap.recycle(); - } - } -} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/scribbles/widget/entity/MotionEntity.java b/src/org/thoughtcrime/securesms/scribbles/widget/entity/MotionEntity.java deleted file mode 100644 index 8c72e922c..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/widget/entity/MotionEntity.java +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Copyright (c) 2016 UPTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package org.thoughtcrime.securesms.scribbles.widget.entity; - -import android.graphics.Canvas; -import android.graphics.Matrix; -import android.graphics.Paint; -import android.graphics.PointF; -import android.support.annotation.IntRange; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; - -import org.thoughtcrime.securesms.util.MathUtils; -import org.thoughtcrime.securesms.scribbles.viewmodel.Layer; - - -@SuppressWarnings({"WeakerAccess"}) -public abstract class MotionEntity { - - /** - * data - */ - @NonNull - protected final Layer layer; - - /** - * transformation matrix for the entity - */ - protected final Matrix matrix = new Matrix(); - /** - * true - entity is selected and need to draw it's border - * false - not selected, no need to draw it's border - */ - private boolean isSelected; - - /** - * maximum scale of the initial image, so that - * the entity still fits within the parent canvas - */ - protected float holyScale; - - /** - * width of canvas the entity is drawn in - */ - @IntRange(from = 0) - protected int canvasWidth; - /** - * height of canvas the entity is drawn in - */ - @IntRange(from = 0) - protected int canvasHeight; - - /** - * Destination points of the entity - * 5 points. Size of array - 10; Starting upper left corner, clockwise - * last point is the same as first to close the circle - * NOTE: saved as a field variable in order to avoid creating array in draw()-like methods - */ - private final float[] destPoints = new float[10]; // x0, y0, x1, y1, x2, y2, x3, y3, x0, y0 - /** - * Initial points of the entity - * @see #destPoints - */ - protected final float[] srcPoints = new float[10]; // x0, y0, x1, y1, x2, y2, x3, y3, x0, y0 - - @NonNull - private Paint borderPaint = new Paint(); - - public MotionEntity(@NonNull Layer layer, - @IntRange(from = 1) int canvasWidth, - @IntRange(from = 1) int canvasHeight) { - this.layer = layer; - this.canvasWidth = canvasWidth; - this.canvasHeight = canvasHeight; - } - - private boolean isSelected() { - return isSelected; - } - - public void setIsSelected(boolean isSelected) { - this.isSelected = isSelected; - } - - /** - * S - scale matrix, R - rotate matrix, T - translate matrix, - * L - result transformation matrix - *

- * The correct order of applying transformations is : L = S * R * T - *

- * See more info: Game Dev: Transform Matrix multiplication order - *

- * Preconcat works like M` = M * S, so we apply preScale -> preRotate -> preTranslate - * the result will be the same: L = S * R * T - *

- * NOTE: postconcat (postScale, etc.) works the other way : M` = S * M, in order to use it - * we'd need to reverse the order of applying - * transformations : post holy scale -> postTranslate -> postRotate -> postScale - */ - protected void updateMatrix() { - // init matrix to E - identity matrix - matrix.reset(); - - float widthAspect = 1.0F * canvasWidth / getWidth(); - float heightAspect = 1.0F * canvasHeight / getHeight(); - // fit the smallest size - holyScale = Math.min(widthAspect, heightAspect); - - float topLeftX = layer.getX() * canvasWidth; - float topLeftY = layer.getY() * canvasHeight; - - float centerX = topLeftX + getWidth() * holyScale * 0.5F; - float centerY = topLeftY + getHeight() * holyScale * 0.5F; - - // calculate params - float rotationInDegree = layer.getRotationInDegrees(); - float scaleX = layer.getScale(); - float scaleY = layer.getScale(); - if (layer.isFlipped()) { - // flip (by X-coordinate) if needed - rotationInDegree *= -1.0F; - scaleX *= -1.0F; - } - - // applying transformations : L = S * R * T - - // scale - matrix.preScale(scaleX, scaleY, centerX, centerY); - - // rotate - matrix.preRotate(rotationInDegree, centerX, centerY); - - // translate - matrix.preTranslate(topLeftX, topLeftY); - - // applying holy scale - S`, the result will be : L = S * R * T * S` - matrix.preScale(holyScale, holyScale); - } - - public float absoluteCenterX() { - float topLeftX = layer.getX() * canvasWidth; - return topLeftX + getWidth() * holyScale * 0.5F; - } - - public float absoluteCenterY() { - float topLeftY = layer.getY() * canvasHeight; - - return topLeftY + getHeight() * holyScale * 0.5F; - } - - public PointF absoluteCenter() { - float topLeftX = layer.getX() * canvasWidth; - float topLeftY = layer.getY() * canvasHeight; - - float centerX = topLeftX + getWidth() * holyScale * 0.5F; - float centerY = topLeftY + getHeight() * holyScale * 0.5F; - - return new PointF(centerX, centerY); - } - - public void moveToCanvasCenter() { - moveCenterTo(new PointF(canvasWidth * 0.5F, canvasHeight * 0.5F)); - } - - public void moveCenterTo(PointF moveToCenter) { - PointF currentCenter = absoluteCenter(); - layer.postTranslate(1.0F * (moveToCenter.x - currentCenter.x) / canvasWidth, - 1.0F * (moveToCenter.y - currentCenter.y) / canvasHeight); - } - - private final PointF pA = new PointF(); - private final PointF pB = new PointF(); - private final PointF pC = new PointF(); - private final PointF pD = new PointF(); - - /** - * For more info: - * StackOverflow: How to check point is in rectangle - *

NOTE: it's easier to apply the same transformation matrix (calculated before) to the original source points, rather than - * calculate the result points ourselves - * @param point point - * @return true if point (x, y) is inside the triangle - */ - public boolean pointInLayerRect(PointF point) { - - updateMatrix(); - // map rect vertices - matrix.mapPoints(destPoints, srcPoints); - - pA.x = destPoints[0]; - pA.y = destPoints[1]; - pB.x = destPoints[2]; - pB.y = destPoints[3]; - pC.x = destPoints[4]; - pC.y = destPoints[5]; - pD.x = destPoints[6]; - pD.y = destPoints[7]; - - return MathUtils.pointInTriangle(point, pA, pB, pC) || MathUtils.pointInTriangle(point, pA, pD, pC); - } - - /** - * http://judepereira.com/blog/calculate-the-real-scale-factor-and-the-angle-of-rotation-from-an-android-matrix/ - * - * @param canvas Canvas to draw - * @param drawingPaint Paint to use during drawing - */ - public final void draw(@NonNull Canvas canvas, @Nullable Paint drawingPaint) { - - this.canvasWidth = canvas.getWidth(); - this.canvasHeight = canvas.getHeight(); - - updateMatrix(); - - canvas.save(); - - drawContent(canvas, drawingPaint); - - if (isSelected()) { - // get alpha from drawingPaint - int storedAlpha = borderPaint.getAlpha(); - if (drawingPaint != null) { - borderPaint.setAlpha(drawingPaint.getAlpha()); - } - drawSelectedBg(canvas); - // restore border alpha - borderPaint.setAlpha(storedAlpha); - } - - canvas.restore(); - } - - private void drawSelectedBg(Canvas canvas) { - matrix.mapPoints(destPoints, srcPoints); - //noinspection Range - canvas.drawLines(destPoints, 0, 8, borderPaint); - //noinspection Range - canvas.drawLines(destPoints, 2, 8, borderPaint); - } - - @NonNull - public Layer getLayer() { - return layer; - } - - public void setBorderPaint(@NonNull Paint borderPaint) { - this.borderPaint = borderPaint; - } - - protected abstract void drawContent(@NonNull Canvas canvas, @Nullable Paint drawingPaint); - - public abstract int getWidth(); - - public abstract int getHeight(); - - public void release() { - // free resources here - } - - public void updateEntity() {} - - @Override - protected void finalize() throws Throwable { - try { - release(); - } finally { - //noinspection ThrowFromFinallyBlock - super.finalize(); - } - } -} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/scribbles/widget/entity/TextEntity.java b/src/org/thoughtcrime/securesms/scribbles/widget/entity/TextEntity.java deleted file mode 100644 index 73bfd315e..000000000 --- a/src/org/thoughtcrime/securesms/scribbles/widget/entity/TextEntity.java +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Copyright (c) 2016 UPTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package org.thoughtcrime.securesms.scribbles.widget.entity; - -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PointF; -import android.support.annotation.IntRange; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.text.Layout; -import android.text.StaticLayout; -import android.text.TextPaint; - -import org.thoughtcrime.securesms.scribbles.viewmodel.TextLayer; - - -public class TextEntity extends MotionEntity { - - private final TextPaint textPaint; - - @Nullable - private Bitmap bitmap; - - public TextEntity(@NonNull TextLayer textLayer, - @IntRange(from = 1) int canvasWidth, - @IntRange(from = 1) int canvasHeight) - { - super(textLayer, canvasWidth, canvasHeight); - this.textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG); - - updateEntity(false); - } - - private void updateEntity(boolean moveToPreviousCenter) { - // save previous center - PointF oldCenter = absoluteCenter(); - - Bitmap newBmp = createBitmap(getLayer(), bitmap); - - // recycle previous bitmap (if not reused) as soon as possible - if (bitmap != null && bitmap != newBmp && !bitmap.isRecycled()) { - bitmap.recycle(); - } - - this.bitmap = newBmp; - - float width = bitmap.getWidth(); - float height = bitmap.getHeight(); - - @SuppressWarnings("UnnecessaryLocalVariable") - float widthAspect = 1.0F * canvasWidth / width; - - // for text we always match text width with parent width - this.holyScale = widthAspect; - - // initial position of the entity - srcPoints[0] = 0; - srcPoints[1] = 0; - srcPoints[2] = width; - srcPoints[3] = 0; - srcPoints[4] = width; - srcPoints[5] = height; - srcPoints[6] = 0; - srcPoints[7] = height; - srcPoints[8] = 0; - srcPoints[8] = 0; - - if (moveToPreviousCenter) { - // move to previous center - moveCenterTo(oldCenter); - } - } - - /** - * If reuseBmp is not null, and size of the new bitmap matches the size of the reuseBmp, - * new bitmap won't be created, reuseBmp it will be reused instead - * - * @param textLayer text to draw - * @param reuseBmp the bitmap that will be reused - * @return bitmap with the text - */ - @NonNull - private Bitmap createBitmap(@NonNull TextLayer textLayer, @Nullable Bitmap reuseBmp) { - - int boundsWidth = canvasWidth; - - // init params - size, color, typeface - textPaint.setStyle(Paint.Style.FILL); - textPaint.setTextSize(textLayer.getFont().getSize() * canvasWidth); - textPaint.setColor(textLayer.getFont().getColor()); -// textPaint.setTypeface(fontProvider.getTypeface(textLayer.getFont().getTypeface())); - - // drawing text guide : http://ivankocijan.xyz/android-drawing-multiline-text-on-canvas/ - // Static layout which will be drawn on canvas - StaticLayout sl = new StaticLayout( - textLayer.getText(), // - text which will be drawn - textPaint, - boundsWidth, // - width of the layout - Layout.Alignment.ALIGN_CENTER, // - layout alignment - 1, // 1 - text spacing multiply - 1, // 1 - text spacing add - true); // true - include padding - - // calculate height for the entity, min - Limits.MIN_BITMAP_HEIGHT - int boundsHeight = sl.getHeight(); - - // create bitmap not smaller than TextLayer.Limits.MIN_BITMAP_HEIGHT - int bmpHeight = (int) (canvasHeight * Math.max(TextLayer.Limits.MIN_BITMAP_HEIGHT, - 1.0F * boundsHeight / canvasHeight)); - - // create bitmap where text will be drawn - Bitmap bmp; - if (reuseBmp != null && reuseBmp.getWidth() == boundsWidth - && reuseBmp.getHeight() == bmpHeight) { - // if previous bitmap exists, and it's width/height is the same - reuse it - bmp = reuseBmp; - bmp.eraseColor(Color.TRANSPARENT); // erase color when reusing - } else { - bmp = Bitmap.createBitmap(boundsWidth, bmpHeight, Bitmap.Config.ARGB_8888); - } - - Canvas canvas = new Canvas(bmp); - canvas.save(); - - // move text to center if bitmap is bigger that text - if (boundsHeight < bmpHeight) { - //calculate Y coordinate - In this case we want to draw the text in the - //center of the canvas so we move Y coordinate to center. - float textYCoordinate = (bmpHeight - boundsHeight) / 2; - canvas.translate(0, textYCoordinate); - } - - //draws static layout on canvas - sl.draw(canvas); - canvas.restore(); - - return bmp; - } - - @Override - @NonNull - public TextLayer getLayer() { - return (TextLayer) layer; - } - - @Override - protected void drawContent(@NonNull Canvas canvas, @Nullable Paint drawingPaint) { - if (bitmap != null) { - canvas.drawBitmap(bitmap, matrix, drawingPaint); - } - } - - @Override - public int getWidth() { - return bitmap != null ? bitmap.getWidth() : 0; - } - - @Override - public int getHeight() { - return bitmap != null ? bitmap.getHeight() : 0; - } - - @Override - public void updateEntity() { - updateEntity(true); - } -} \ No newline at end of file