diff --git a/build.gradle b/build.gradle
index d1b21e3f5..bc625f4c4 100644
--- a/build.gradle
+++ b/build.gradle
@@ -4,18 +4,22 @@ buildscript {
repositories {
google()
mavenCentral()
+ maven { url "https://www.jitpack.io" }
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.2'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.21"
}
}
apply plugin: 'com.android.application'
+apply plugin: 'org.jetbrains.kotlin.android'
repositories {
google()
mavenCentral()
jcenter()
+ maven { url "https://www.jitpack.io" }
}
configurations {
@@ -46,7 +50,7 @@ dependencies {
implementation 'androidx.exifinterface:exifinterface:1.3.6'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
- implementation 'com.google.android.material:material:1.8.0'
+ implementation 'com.google.android.material:material:1.9.0'
implementation "androidx.emoji2:emoji2:1.2.0"
freeImplementation "androidx.emoji2:emoji2-bundled:1.2.0"
@@ -77,6 +81,12 @@ dependencies {
implementation 'com.google.guava:guava:31.1-android'
quicksyImplementation 'io.michaelrocks:libphonenumber-android:8.12.49'
implementation 'im.conversations.webrtc:webrtc-android:104.0.0'
+
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation "androidx.recyclerview:recyclerview:1.2.1"
+ implementation 'com.github.bumptech.glide:glide:4.15.1'
+ implementation 'info.androidhive:imagefilters:1.0.7'
+ implementation 'com.github.chrisbanes:PhotoView:2.3.0'
}
ext {
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index e1fe934af..2be7b2583 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -306,6 +306,12 @@
android:name=".ui.MediaBrowserActivity"
android:label="@string/media_browser" />
+
+
? = null
+ private var currPrimaryAction = PRIMARY_ACTION_NONE
+ private var currCropRotateAction = CROP_ROTATE_ASPECT_RATIO
+ private var currAspectRatio = ASPECT_RATIO_FREE
+ private var wasDrawCanvasPositioned = false
+ private var oldExif: ExifInterface? = null
+ private var filterInitialBitmap: Bitmap? = null
+ private var originalUri: Uri? = null
+
+ private lateinit var binding: ActivityEditBinding
+
+ private val launchSavingToastRunnable = Runnable {
+ toast(R.string.saving)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityEditBinding.inflate(layoutInflater)
+ val view = binding.root
+ setContentView(view)
+
+ supportActionBar?.hide();
+
+ binding.editorToolbar.title = intent.getStringExtra(KEY_CHAT_NAME)
+ binding.editorToolbar.setTitleTextColor(Color.WHITE)
+ binding.editorToolbar.setNavigationIconTint(Color.WHITE)
+ binding.editorToolbar.setNavigationOnClickListener {
+ onBackPressed()
+ }
+
+ binding.editorToolbar.inflateMenu(R.menu.menu_done)
+ binding.editorToolbar.setOnMenuItemClickListener {
+ when (it.itemId) {
+ R.id.action_done -> saveImage()
+ }
+
+ true
+ }
+
+
+ window.statusBarColor = ContextCompat.getColor(this, R.color.black26)
+
+ initEditActivity()
+ }
+
+ private fun initEditActivity() {
+ if (intent.data == null) {
+ toast(R.string.invalid_image_path)
+ finish()
+ return
+ }
+
+ uri = intent.data!!
+ originalUri = uri
+ if (uri!!.scheme != "file" && uri!!.scheme != "content") {
+ toast(R.string.unknown_file_location)
+ finish()
+ return
+ }
+
+ if (intent.extras?.containsKey(REAL_FILE_PATH) == true) {
+ val realPath = intent.extras!!.getString(REAL_FILE_PATH)
+ uri = when {
+ realPath!!.startsWith("file:/") -> Uri.parse(realPath)
+ else -> Uri.fromFile(File(realPath))
+ }
+ } else {
+ (getRealPathFromURI(uri!!))?.apply {
+ uri = Uri.fromFile(File(this))
+ }
+ }
+
+ loadDefaultImageView()
+ setupBottomActions()
+
+ if (config.lastEditorCropAspectRatio == ASPECT_RATIO_OTHER) {
+ if (config.lastEditorCropOtherAspectRatioX == 0f) {
+ config.lastEditorCropOtherAspectRatioX = 1f
+ }
+
+ if (config.lastEditorCropOtherAspectRatioY == 0f) {
+ config.lastEditorCropOtherAspectRatioY = 1f
+ }
+
+ lastOtherAspectRatio = Pair(config.lastEditorCropOtherAspectRatioX, config.lastEditorCropOtherAspectRatioY)
+ }
+ updateAspectRatio(config.lastEditorCropAspectRatio)
+ binding.cropImageView.guidelines = CropImageView.Guidelines.ON
+ binding.bottomAspectRatios.root.beVisible()
+ }
+
+ private fun loadDefaultImageView() {
+ binding.defaultImageView.beVisible()
+ binding.cropImageView.beGone()
+ binding.editorDrawCanvas.beGone()
+
+ val options = RequestOptions()
+ .skipMemoryCache(true)
+ .diskCacheStrategy(DiskCacheStrategy.NONE)
+
+ Glide.with(this)
+ .asBitmap()
+ .load(uri)
+ .apply(options)
+ .listener(object : RequestListener {
+ override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean {
+ if (uri != originalUri) {
+ uri = originalUri
+ Handler().post {
+ loadDefaultImageView()
+ }
+ }
+ return false
+ }
+
+ override fun onResourceReady(
+ bitmap: Bitmap?,
+ model: Any?,
+ target: Target?,
+ dataSource: DataSource?,
+ isFirstResource: Boolean
+ ): Boolean {
+ val currentFilter = getFiltersAdapter()?.getCurrentFilter()
+ if (filterInitialBitmap == null) {
+ loadCropImageView()
+ bottomCropRotateClicked()
+ }
+
+ if (filterInitialBitmap != null && currentFilter != null && currentFilter.filter.name != getString(R.string.none)) {
+ binding.defaultImageView.onGlobalLayout {
+ applyFilter(currentFilter)
+ }
+ } else {
+ filterInitialBitmap = bitmap
+ }
+
+ return false
+ }
+ }).into(binding.defaultImageView)
+ }
+
+ private fun loadCropImageView() {
+ binding.defaultImageView.beGone()
+ binding.editorDrawCanvas.beGone()
+ binding.cropImageView.apply {
+ beVisible()
+ setOnCropImageCompleteListener(this@EditActivity)
+ setImageUriAsync(uri)
+ guidelines = CropImageView.Guidelines.ON
+ }
+ }
+
+ private fun loadDrawCanvas() {
+ binding.defaultImageView.beGone()
+ binding.cropImageView.beGone()
+ binding.editorDrawCanvas.beVisible()
+
+ if (!wasDrawCanvasPositioned) {
+ wasDrawCanvasPositioned = true
+ binding.editorDrawCanvas.onGlobalLayout {
+ ensureBackgroundThread {
+ fillCanvasBackground()
+ }
+ }
+ }
+ }
+
+ private fun fillCanvasBackground() {
+ val size = Point()
+ windowManager.defaultDisplay.getSize(size)
+ val options = RequestOptions()
+ .format(DecodeFormat.PREFER_ARGB_8888)
+ .skipMemoryCache(true)
+ .diskCacheStrategy(DiskCacheStrategy.NONE)
+ .fitCenter()
+
+ try {
+ val builder = Glide.with(applicationContext)
+ .asBitmap()
+ .load(uri)
+ .apply(options)
+ .into(binding.editorDrawCanvas.width, binding.editorDrawCanvas.height)
+
+ val bitmap = builder.get()
+ runOnUiThread {
+ binding.editorDrawCanvas.apply {
+ updateBackgroundBitmap(bitmap)
+ layoutParams.width = bitmap.width
+ layoutParams.height = bitmap.height
+ android.util.Log.e("31fd", bitmap.height.toString() + " " + height)
+
+ translationY = max((height - bitmap.height) / 2f, 0f)
+ requestLayout()
+ }
+ }
+ } catch (e: Exception) {
+ showErrorToast(e)
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.N)
+ private fun saveImage() {
+ setOldExif()
+
+ if (binding.cropImageView.isVisible()) {
+ binding.cropImageView.getCroppedImageAsync()
+ } else if (binding.editorDrawCanvas.isVisible()) {
+ val bitmap = binding.editorDrawCanvas.getBitmap()
+ saveBitmapToFile(bitmap, true)
+ } else {
+ val currentFilter = getFiltersAdapter()?.getCurrentFilter() ?: return
+ toast(R.string.saving)
+
+ // clean up everything to free as much memory as possible
+ binding.defaultImageView.setImageResource(0)
+ binding.cropImageView.setImageBitmap(null)
+ binding.bottomEditorFilterActions.bottomActionsFilterList.adapter = null
+ binding.bottomEditorFilterActions.bottomActionsFilterList.beGone()
+
+ ensureBackgroundThread {
+ try {
+ val originalBitmap = Glide.with(applicationContext).asBitmap().load(uri).submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL).get()
+ currentFilter.filter.processFilter(originalBitmap)
+ saveBitmapToFile(originalBitmap, false)
+ } catch (e: OutOfMemoryError) {
+ toast(R.string.out_of_memory_error)
+ }
+ }
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.N)
+ private fun setOldExif() {
+ var inputStream: InputStream? = null
+ try {
+ if (isNougatPlus()) {
+ inputStream = contentResolver.openInputStream(uri!!)
+ oldExif = ExifInterface(inputStream!!)
+ }
+ } catch (e: Exception) {
+ } finally {
+ inputStream?.close()
+ }
+ }
+
+ private fun getFiltersAdapter() = binding.bottomEditorFilterActions.bottomActionsFilterList.adapter as? FiltersAdapter
+
+ private fun setupBottomActions() {
+ setupPrimaryActionButtons()
+ setupCropRotateActionButtons()
+ setupAspectRatioButtons()
+ setupDrawButtons()
+ }
+
+ private fun setupPrimaryActionButtons() {
+ binding.bottomEditorPrimaryActions.bottomPrimaryFilter.setOnClickListener {
+ bottomFilterClicked()
+ }
+
+ binding.bottomEditorPrimaryActions.bottomPrimaryCropRotate.setOnClickListener {
+ bottomCropRotateClicked()
+ }
+
+ binding.bottomEditorPrimaryActions.bottomPrimaryDraw.setOnClickListener {
+ bottomDrawClicked()
+ }
+ arrayOf(binding.bottomEditorPrimaryActions.bottomPrimaryFilter, binding.bottomEditorPrimaryActions.bottomPrimaryCropRotate, binding.bottomEditorPrimaryActions.bottomPrimaryDraw).forEach {
+ setupLongPress(it)
+ }
+ }
+
+ private fun bottomFilterClicked() {
+ currPrimaryAction = if (currPrimaryAction == PRIMARY_ACTION_FILTER) {
+ PRIMARY_ACTION_NONE
+ } else {
+ PRIMARY_ACTION_FILTER
+ }
+ updatePrimaryActionButtons()
+ }
+
+ private fun bottomCropRotateClicked() {
+ currPrimaryAction = if (currPrimaryAction == PRIMARY_ACTION_CROP_ROTATE) {
+ PRIMARY_ACTION_NONE
+ } else {
+ PRIMARY_ACTION_CROP_ROTATE
+ }
+ updatePrimaryActionButtons()
+ }
+
+ private fun bottomDrawClicked() {
+ currPrimaryAction = if (currPrimaryAction == PRIMARY_ACTION_DRAW) {
+ PRIMARY_ACTION_NONE
+ } else {
+ PRIMARY_ACTION_DRAW
+ }
+ updatePrimaryActionButtons()
+ }
+
+ private fun setupCropRotateActionButtons() {
+ binding.bottomEditorCropRotateActions.bottomRotate.setOnClickListener {
+ binding.cropImageView.rotateImage(90)
+ }
+
+ binding.bottomEditorCropRotateActions.bottomResize.setOnClickListener {
+ resizeImage()
+ }
+
+ binding.bottomEditorCropRotateActions.bottomFlipHorizontally.setOnClickListener {
+ binding.cropImageView.flipImageHorizontally()
+ }
+
+ binding.bottomEditorCropRotateActions.bottomFlipVertically.setOnClickListener {
+ binding.cropImageView.flipImageVertically()
+ }
+
+ binding.bottomEditorCropRotateActions.bottomAspectRatio.setOnClickListener {
+ currCropRotateAction = if (currCropRotateAction == CROP_ROTATE_ASPECT_RATIO) {
+ binding.cropImageView.guidelines = CropImageView.Guidelines.OFF
+ binding.bottomAspectRatios.root.beGone()
+ CROP_ROTATE_NONE
+ } else {
+ binding.cropImageView.guidelines = CropImageView.Guidelines.ON
+ binding.bottomAspectRatios.root.beVisible()
+ CROP_ROTATE_ASPECT_RATIO
+ }
+ updateCropRotateActionButtons()
+ }
+
+ arrayOf(binding.bottomEditorCropRotateActions.bottomRotate, binding.bottomEditorCropRotateActions.bottomResize, binding.bottomEditorCropRotateActions.bottomFlipHorizontally, binding.bottomEditorCropRotateActions.bottomFlipVertically, binding.bottomEditorCropRotateActions.bottomAspectRatio).forEach {
+ setupLongPress(it)
+ }
+ }
+
+ private fun setupAspectRatioButtons() {
+ binding.bottomAspectRatios.bottomAspectRatioFree.setOnClickListener {
+ updateAspectRatio(ASPECT_RATIO_FREE)
+ }
+
+ binding.bottomAspectRatios.bottomAspectRatioOneOne.setOnClickListener {
+ updateAspectRatio(ASPECT_RATIO_ONE_ONE)
+ }
+
+ binding.bottomAspectRatios.bottomAspectRatioFourThree.setOnClickListener {
+ updateAspectRatio(ASPECT_RATIO_FOUR_THREE)
+ }
+
+ binding.bottomAspectRatios.bottomAspectRatioSixteenNine.setOnClickListener {
+ updateAspectRatio(ASPECT_RATIO_SIXTEEN_NINE)
+ }
+
+ binding.bottomAspectRatios.bottomAspectRatioOther.setOnClickListener {
+ OtherAspectRatioDialog(this, lastOtherAspectRatio) {
+ lastOtherAspectRatio = it
+ config.lastEditorCropOtherAspectRatioX = it.first
+ config.lastEditorCropOtherAspectRatioY = it.second
+ updateAspectRatio(ASPECT_RATIO_OTHER)
+ }
+ }
+
+ updateAspectRatioButtons()
+ }
+
+ private fun setupDrawButtons() {
+ updateDrawColor(config.lastEditorDrawColor)
+ binding.bottomEditorDrawActions.bottomDrawWidth.progress = config.lastEditorBrushSize
+ updateBrushSize(config.lastEditorBrushSize)
+
+ binding.bottomEditorDrawActions.bottomDrawColorClickable.setOnClickListener {
+ ColorPickerDialog(this, drawColor) { wasPositivePressed, color ->
+ if (wasPositivePressed) {
+ updateDrawColor(color)
+ }
+ }
+ }
+
+ binding.bottomEditorDrawActions.bottomDrawWidth.onSeekBarChangeListener {
+ config.lastEditorBrushSize = it
+ updateBrushSize(it)
+ }
+
+ binding.bottomEditorDrawActions.bottomDrawUndo.setOnClickListener {
+ binding.editorDrawCanvas.undo()
+ }
+ }
+
+ private fun updateBrushSize(percent: Int) {
+ binding.editorDrawCanvas.updateBrushSize(percent)
+ val scale = Math.max(0.03f, percent / 100f)
+ binding.bottomEditorDrawActions.bottomDrawColor.scaleX = scale
+ binding.bottomEditorDrawActions.bottomDrawColor.scaleY = scale
+ }
+
+ private fun updatePrimaryActionButtons() {
+ if (binding.cropImageView.isGone() && currPrimaryAction == PRIMARY_ACTION_CROP_ROTATE) {
+ loadCropImageView()
+ } else if (binding.defaultImageView.isGone() && currPrimaryAction == PRIMARY_ACTION_FILTER) {
+ loadDefaultImageView()
+ } else if (binding.editorDrawCanvas.isGone() && currPrimaryAction == PRIMARY_ACTION_DRAW) {
+ loadDrawCanvas()
+ }
+
+ arrayOf(binding.bottomEditorPrimaryActions.bottomPrimaryFilter, binding.bottomEditorPrimaryActions.bottomPrimaryCropRotate, binding.bottomEditorPrimaryActions.bottomPrimaryDraw).forEach {
+ it.applyColorFilter(Color.WHITE)
+ }
+
+ val currentPrimaryActionButton = when (currPrimaryAction) {
+ PRIMARY_ACTION_FILTER -> binding.bottomEditorPrimaryActions.bottomPrimaryFilter
+ PRIMARY_ACTION_CROP_ROTATE -> binding.bottomEditorPrimaryActions.bottomPrimaryCropRotate
+ PRIMARY_ACTION_DRAW -> binding.bottomEditorPrimaryActions.bottomPrimaryDraw
+ else -> null
+ }
+
+ currentPrimaryActionButton?.applyColorFilter(getPrimaryColor(this))
+ binding.bottomEditorFilterActions
+ binding.bottomEditorFilterActions.root.beVisibleIf(currPrimaryAction == PRIMARY_ACTION_FILTER)
+ binding.bottomEditorCropRotateActions.root.beVisibleIf(currPrimaryAction == PRIMARY_ACTION_CROP_ROTATE)
+ binding.bottomEditorDrawActions.root.beVisibleIf(currPrimaryAction == PRIMARY_ACTION_DRAW)
+
+ if (currPrimaryAction == PRIMARY_ACTION_FILTER && binding.bottomEditorFilterActions.bottomActionsFilterList.adapter == null) {
+ ensureBackgroundThread {
+ val thumbnailSize = resources.getDimension(R.dimen.bottom_filters_thumbnail_size).toInt()
+
+ val bitmap = try {
+ Glide.with(this)
+ .asBitmap()
+ .load(uri).listener(object : RequestListener {
+ override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean {
+ showErrorToast(e.toString())
+ return false
+ }
+
+ override fun onResourceReady(
+ resource: Bitmap?,
+ model: Any?,
+ target: Target?,
+ dataSource: DataSource?,
+ isFirstResource: Boolean
+ ) = false
+ })
+ .submit(thumbnailSize, thumbnailSize)
+ .get()
+ } catch (e: GlideException) {
+ showErrorToast(e)
+ finish()
+ return@ensureBackgroundThread
+ }
+
+ runOnUiThread {
+ val filterThumbnailsManager = FilterThumbnailsManager()
+ filterThumbnailsManager.clearThumbs()
+
+ val noFilter = Filter(getString(R.string.none))
+ filterThumbnailsManager.addThumb(FilterItem(bitmap, noFilter))
+
+ FilterPack.getFilterPack(this).forEach {
+ val filterItem = FilterItem(bitmap, it)
+ filterThumbnailsManager.addThumb(filterItem)
+ }
+
+ val filterItems = filterThumbnailsManager.processThumbs()
+ val adapter = FiltersAdapter(applicationContext, filterItems) {
+ val layoutManager = binding.bottomEditorFilterActions.bottomActionsFilterList.layoutManager as LinearLayoutManager
+ applyFilter(filterItems[it])
+
+ if (it == layoutManager.findLastCompletelyVisibleItemPosition() || it == layoutManager.findLastVisibleItemPosition()) {
+ binding.bottomEditorFilterActions.bottomActionsFilterList.smoothScrollBy(thumbnailSize, 0)
+ } else if (it == layoutManager.findFirstCompletelyVisibleItemPosition() || it == layoutManager.findFirstVisibleItemPosition()) {
+ binding.bottomEditorFilterActions.bottomActionsFilterList.smoothScrollBy(-thumbnailSize, 0)
+ }
+ }
+
+ binding.bottomEditorFilterActions.bottomActionsFilterList.adapter = adapter
+ adapter.notifyDataSetChanged()
+ }
+ }
+ }
+
+ if (currPrimaryAction != PRIMARY_ACTION_CROP_ROTATE) {
+ binding.bottomAspectRatios.root.beGone()
+ currCropRotateAction = CROP_ROTATE_NONE
+ }
+ updateCropRotateActionButtons()
+ }
+
+ private fun applyFilter(filterItem: FilterItem) {
+ val newBitmap = Bitmap.createBitmap(filterInitialBitmap!!)
+ binding.defaultImageView.setImageBitmap(filterItem.filter.processFilter(newBitmap))
+ }
+
+ private fun updateAspectRatio(aspectRatio: Int) {
+ currAspectRatio = aspectRatio
+ config.lastEditorCropAspectRatio = aspectRatio
+ updateAspectRatioButtons()
+
+ binding.cropImageView.apply {
+ if (aspectRatio == ASPECT_RATIO_FREE) {
+ setFixedAspectRatio(false)
+ } else {
+ val newAspectRatio = when (aspectRatio) {
+ ASPECT_RATIO_ONE_ONE -> Pair(1f, 1f)
+ ASPECT_RATIO_FOUR_THREE -> Pair(4f, 3f)
+ ASPECT_RATIO_SIXTEEN_NINE -> Pair(16f, 9f)
+ else -> Pair(lastOtherAspectRatio!!.first, lastOtherAspectRatio!!.second)
+ }
+
+ setAspectRatio(newAspectRatio.first.toInt(), newAspectRatio.second.toInt())
+ }
+ }
+ }
+
+ private fun updateAspectRatioButtons() {
+ arrayOf(
+ binding.bottomAspectRatios.bottomAspectRatioFree,
+ binding.bottomAspectRatios.bottomAspectRatioOneOne,
+ binding.bottomAspectRatios.bottomAspectRatioFourThree,
+ binding.bottomAspectRatios.bottomAspectRatioSixteenNine,
+ binding.bottomAspectRatios.bottomAspectRatioOther,
+ ).forEach {
+ it.setTextColor(Color.WHITE)
+ }
+
+ val currentAspectRatioButton = when (currAspectRatio) {
+ ASPECT_RATIO_FREE -> binding.bottomAspectRatios.bottomAspectRatioFree
+ ASPECT_RATIO_ONE_ONE -> binding.bottomAspectRatios.bottomAspectRatioOneOne
+ ASPECT_RATIO_FOUR_THREE -> binding.bottomAspectRatios.bottomAspectRatioFourThree
+ ASPECT_RATIO_SIXTEEN_NINE -> binding.bottomAspectRatios.bottomAspectRatioSixteenNine
+ else -> binding.bottomAspectRatios.bottomAspectRatioOther
+ }
+
+ currentAspectRatioButton.setTextColor(getPrimaryColor(this))
+ }
+
+ private fun updateCropRotateActionButtons() {
+ arrayOf(binding.bottomEditorCropRotateActions.bottomAspectRatio).forEach {
+ it.applyColorFilter(Color.WHITE)
+ }
+
+ val primaryActionView = when (currCropRotateAction) {
+ CROP_ROTATE_ASPECT_RATIO -> binding.bottomEditorCropRotateActions.bottomAspectRatio
+ else -> null
+ }
+
+ primaryActionView?.applyColorFilter(getPrimaryColor(this))
+ }
+
+ private fun updateDrawColor(color: Int) {
+ drawColor = color
+ binding.bottomEditorDrawActions.bottomDrawColor.applyColorFilter(color)
+ config.lastEditorDrawColor = color
+ binding.editorDrawCanvas.updateColor(color)
+ }
+
+ private fun resizeImage() {
+ val point = getAreaSize()
+ if (point == null) {
+ toast(R.string.unknown_error_occurred)
+ return
+ }
+
+ ResizeDialog(this, point) {
+ resizeWidth = it.x
+ resizeHeight = it.y
+ binding.cropImageView.getCroppedImageAsync()
+ }
+ }
+
+ private fun shouldCropSquare(): Boolean {
+ val extras = intent.extras
+ return if (extras != null && extras.containsKey(ASPECT_X) && extras.containsKey(ASPECT_Y)) {
+ extras.getInt(ASPECT_X) == extras.getInt(ASPECT_Y)
+ } else {
+ false
+ }
+ }
+
+ private fun getAreaSize(): Point? {
+ val rect = binding.cropImageView.cropRect ?: return null
+ val rotation = binding.cropImageView.rotatedDegrees
+ return if (rotation == 0 || rotation == 180) {
+ Point(rect.width(), rect.height())
+ } else {
+ Point(rect.height(), rect.width())
+ }
+ }
+
+ override fun onCropImageComplete(view: CropImageView, result: CropImageView.CropResult) {
+ if (result.error == null) {
+ setOldExif()
+
+ val bitmap = result.bitmap
+
+ saveBitmapToFile(bitmap, true)
+ } else {
+ toast("${getString(R.string.image_editing_failed)}: ${result.error.message}")
+ }
+ }
+
+ private fun saveBitmapToFile(bitmap: Bitmap, showSavingToast: Boolean) {
+ val file = File(cacheDir, "editedImages/${UUID.randomUUID()}.jpg")
+
+ file.deleteRecursively()
+ file.parentFile?.mkdirs()
+
+ try {
+ ensureBackgroundThread {
+ try {
+ val out = FileOutputStream(file)
+ saveBitmap(file, bitmap, out, showSavingToast)
+ } catch (e: Exception) {
+ toast(R.string.image_editing_failed)
+ }
+ }
+ } catch (e: Exception) {
+ showErrorToast(e)
+ } catch (e: OutOfMemoryError) {
+ toast(R.string.out_of_memory_error)
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.N)
+ private fun saveBitmap(file: File, bitmap: Bitmap, out: OutputStream, showSavingToast: Boolean) {
+ if (showSavingToast) {
+ binding.root.postDelayed(launchSavingToastRunnable, 500)
+ }
+
+ if (resizeWidth > 0 && resizeHeight > 0) {
+ val resized = Bitmap.createScaledBitmap(bitmap, resizeWidth, resizeHeight, false)
+ resized.compress(file.absolutePath.getCompressionFormat(), 90, out)
+ } else {
+ bitmap.compress(file.absolutePath.getCompressionFormat(), 90, out)
+ }
+
+ try {
+ if (isNougatPlus()) {
+ val newExif = ExifInterface(file.absolutePath)
+ oldExif?.copyNonDimensionAttributesTo(newExif)
+ }
+ } catch (e: Exception) {
+ }
+
+ intent.putExtra(KEY_EDITED_URI, file.toUri())
+ setResult(Activity.RESULT_OK, intent)
+ out.close()
+ binding.root.removeCallbacks(launchSavingToastRunnable)
+ finish()
+ }
+
+ private fun setupLongPress(view: ImageView) {
+ view.setOnLongClickListener {
+ val contentDescription = view.contentDescription
+ if (contentDescription != null) {
+ toast(contentDescription.toString())
+ }
+ true
+ }
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/adapters/FiltersAdapter.kt b/src/main/java/eu/siacs/conversations/medialib/adapters/FiltersAdapter.kt
new file mode 100644
index 000000000..8157068ff
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/adapters/FiltersAdapter.kt
@@ -0,0 +1,60 @@
+package eu.siacs.conversations.medialib.adapters
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import eu.siacs.conversations.R
+import eu.siacs.conversations.databinding.EditorFilterItemBinding
+import eu.siacs.conversations.medialib.models.FilterItem
+
+class FiltersAdapter(val context: Context, val filterItems: ArrayList, val itemClick: (Int) -> Unit) :
+ RecyclerView.Adapter() {
+
+ private var currentSelection = filterItems.first()
+ private var strokeBackground = context.resources.getDrawable(R.drawable.stroke_background)
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.bindView(filterItems[position])
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val view = LayoutInflater.from(parent.context).inflate(R.layout.editor_filter_item, parent, false)
+ return ViewHolder(view)
+ }
+
+ override fun getItemCount() = filterItems.size
+
+ fun getCurrentFilter() = currentSelection
+
+ private fun setCurrentFilter(position: Int) {
+ val filterItem = filterItems.getOrNull(position) ?: return
+ if (currentSelection != filterItem) {
+ currentSelection = filterItem
+ notifyDataSetChanged()
+ itemClick.invoke(position)
+ }
+ }
+
+ inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+ private val binding = EditorFilterItemBinding.bind(view)
+
+ fun bindView(filterItem: FilterItem): View {
+ itemView.apply {
+ binding.editorFilterItemLabel.text = filterItem.filter.name
+ binding.editorFilterItemThumbnail.setImageBitmap(filterItem.bitmap)
+ binding.editorFilterItemThumbnail.background = if (getCurrentFilter() == filterItem) {
+ strokeBackground
+ } else {
+ null
+ }
+
+ setOnClickListener {
+ setCurrentFilter(adapterPosition)
+ }
+ }
+ return itemView
+ }
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/dialogs/ColorPickerDialog.kt b/src/main/java/eu/siacs/conversations/medialib/dialogs/ColorPickerDialog.kt
new file mode 100644
index 000000000..34979a54a
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/dialogs/ColorPickerDialog.kt
@@ -0,0 +1,254 @@
+package eu.siacs.conversations.medialib.dialogs
+
+import android.app.Activity
+import android.graphics.Color
+import android.view.MotionEvent
+import android.view.View
+import android.view.View.OnTouchListener
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.widget.EditText
+import android.widget.ImageView
+import androidx.appcompat.app.AlertDialog
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import eu.siacs.conversations.R
+import eu.siacs.conversations.databinding.DialogColorPickerBinding
+import eu.siacs.conversations.medialib.extensions.*
+import eu.siacs.conversations.medialib.views.ColorPickerSquare
+import java.util.LinkedList
+
+private const val RECENT_COLORS_NUMBER = 5
+
+// forked from https://github.com/yukuku/ambilwarna
+class ColorPickerDialog(
+ val activity: Activity,
+ color: Int,
+ val removeDimmedBackground: Boolean = false,
+ val addDefaultColorButton: Boolean = false,
+ val currentColorCallback: ((color: Int) -> Unit)? = null,
+ val callback: (wasPositivePressed: Boolean, color: Int) -> Unit
+) {
+ var viewHue: View
+ var viewSatVal: ColorPickerSquare
+ var viewCursor: ImageView
+ var viewNewColor: ImageView
+ var viewTarget: ImageView
+ var newHexField: EditText
+ var viewContainer: ViewGroup
+ private val baseConfig = activity.config
+ private val currentColorHsv = FloatArray(3)
+ private val backgroundColor = Color.BLACK
+ private var isHueBeingDragged = false
+ private var wasDimmedBackgroundRemoved = false
+ private var dialog: AlertDialog? = null
+
+ init {
+ Color.colorToHSV(color, currentColorHsv)
+
+ val binding = DialogColorPickerBinding.inflate(activity.layoutInflater)
+ val view = binding.root.apply {
+ if (isQPlus()) {
+ isForceDarkAllowed = false
+ }
+
+ viewHue = binding.colorPickerHue
+ viewSatVal = binding.colorPickerSquare
+ viewCursor = binding.colorPickerCursor
+
+ viewNewColor = binding.colorPickerNewColor
+ viewTarget = binding.colorPickerCursor
+ viewContainer = binding.colorPickerHolder
+ newHexField = binding.colorPickerNewHex
+
+ viewSatVal.setHue(getHue())
+
+ viewNewColor.setFillWithStroke(getColor(), backgroundColor)
+ binding.colorPickerOldColor.setFillWithStroke(color, backgroundColor)
+
+ val hexCode = getHexCode(color)
+ binding.colorPickerOldHex.text = "#$hexCode"
+ binding.colorPickerOldHex.setOnLongClickListener {
+ activity.copyToClipboard(hexCode)
+ true
+ }
+ newHexField.setText(hexCode)
+ setupRecentColors(binding)
+ }
+
+ viewHue.setOnTouchListener(OnTouchListener { v, event ->
+ if (event.action == MotionEvent.ACTION_DOWN) {
+ isHueBeingDragged = true
+ }
+
+ if (event.action == MotionEvent.ACTION_MOVE || event.action == MotionEvent.ACTION_DOWN || event.action == MotionEvent.ACTION_UP) {
+ var y = event.y
+ if (y < 0f)
+ y = 0f
+
+ if (y > viewHue.measuredHeight) {
+ y = viewHue.measuredHeight - 0.001f // to avoid jumping the cursor from bottom to top.
+ }
+ var hue = 360f - 360f / viewHue.measuredHeight * y
+ if (hue == 360f)
+ hue = 0f
+
+ currentColorHsv[0] = hue
+ updateHue()
+ newHexField.setText(getHexCode(getColor()))
+
+ if (event.action == MotionEvent.ACTION_UP) {
+ isHueBeingDragged = false
+ }
+ return@OnTouchListener true
+ }
+ false
+ })
+
+ viewSatVal.setOnTouchListener(OnTouchListener { v, event ->
+ if (event.action == MotionEvent.ACTION_MOVE || event.action == MotionEvent.ACTION_DOWN || event.action == MotionEvent.ACTION_UP) {
+ var x = event.x
+ var y = event.y
+
+ if (x < 0f)
+ x = 0f
+ if (x > viewSatVal.measuredWidth)
+ x = viewSatVal.measuredWidth.toFloat()
+ if (y < 0f)
+ y = 0f
+ if (y > viewSatVal.measuredHeight)
+ y = viewSatVal.measuredHeight.toFloat()
+
+ currentColorHsv[1] = 1f / viewSatVal.measuredWidth * x
+ currentColorHsv[2] = 1f - 1f / viewSatVal.measuredHeight * y
+
+ moveColorPicker()
+ viewNewColor.setFillWithStroke(getColor(), backgroundColor)
+ newHexField.setText(getHexCode(getColor()))
+ return@OnTouchListener true
+ }
+ false
+ })
+
+ newHexField.onTextChangeListener {
+ if (it.length == 6 && !isHueBeingDragged) {
+ try {
+ val newColor = Color.parseColor("#$it")
+ Color.colorToHSV(newColor, currentColorHsv)
+ updateHue()
+ moveColorPicker()
+ } catch (ignored: Exception) {
+ }
+ }
+ }
+
+ // val textColor = activity.getProperTextColor()
+ val builder = MaterialAlertDialogBuilder(activity)
+ .setPositiveButton(R.string.ok) { _, _ -> confirmNewColor() }
+ .setNegativeButton(R.string.cancel) { _, _ -> dialogDismissed() }
+ .setOnCancelListener { dialogDismissed() }
+ .apply {
+ if (addDefaultColorButton) {
+ setNeutralButton(R.string.default_color) { _, _ -> confirmDefaultColor() }
+ }
+ }
+
+ builder.apply {
+ activity.setupDialogStuff(view, this) { alertDialog ->
+ dialog = alertDialog
+ //view.color_picker_arrow.applyColorFilter(textColor)
+ //view.color_picker_hex_arrow.applyColorFilter(textColor)
+ // viewCursor.applyColorFilter(textColor)
+ }
+ }
+
+ view.onGlobalLayout {
+ moveHuePicker()
+ moveColorPicker()
+ }
+ }
+
+ private fun View.setupRecentColors(binding: DialogColorPickerBinding) {
+ val recentColors = baseConfig.colorPickerRecentColors
+ if (recentColors.isNotEmpty()) {
+ binding.recentColors.beVisible()
+ val squareSize = context.resources.getDimensionPixelSize(R.dimen.colorpicker_hue_width)
+ recentColors.take(RECENT_COLORS_NUMBER).forEach { recentColor ->
+ val recentColorView = ImageView(context)
+ recentColorView.id = View.generateViewId()
+ recentColorView.layoutParams = ViewGroup.LayoutParams(squareSize, squareSize)
+ recentColorView.setFillWithStroke(recentColor, backgroundColor)
+ recentColorView.setOnClickListener { newHexField.setText(getHexCode(recentColor)) }
+ binding.recentColors.addView(recentColorView)
+ binding.recentColorsFlow.addView(recentColorView)
+ }
+ }
+ }
+
+ private fun dialogDismissed() {
+ callback(false, 0)
+ }
+
+ private fun confirmDefaultColor() {
+ callback(true, 0)
+ }
+
+ private fun confirmNewColor() {
+ val hexValue = newHexField.value
+ val newColor = if (hexValue.length == 6) {
+ Color.parseColor("#$hexValue")
+ } else {
+ getColor()
+ }
+
+ addRecentColor(newColor)
+ callback(true, newColor)
+ }
+
+ private fun addRecentColor(color: Int) {
+ var recentColors = baseConfig.colorPickerRecentColors
+
+ recentColors.remove(color)
+ if (recentColors.size >= RECENT_COLORS_NUMBER) {
+ val numberOfColorsToDrop = recentColors.size - RECENT_COLORS_NUMBER + 1
+ recentColors = LinkedList(recentColors.dropLast(numberOfColorsToDrop))
+ }
+ recentColors.addFirst(color)
+
+ baseConfig.colorPickerRecentColors = recentColors
+ }
+
+ private fun getHexCode(color: Int) = color.toHex().substring(1)
+
+ private fun updateHue() {
+ viewSatVal.setHue(getHue())
+ moveHuePicker()
+ viewNewColor.setFillWithStroke(getColor(), backgroundColor)
+ if (removeDimmedBackground && !wasDimmedBackgroundRemoved) {
+ dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
+ wasDimmedBackgroundRemoved = true
+ }
+
+ currentColorCallback?.invoke(getColor())
+ }
+
+ private fun moveHuePicker() {
+ var y = viewHue.measuredHeight - getHue() * viewHue.measuredHeight / 360f
+ if (y == viewHue.measuredHeight.toFloat())
+ y = 0f
+
+ viewCursor.x = (viewHue.left - viewCursor.width).toFloat()
+ viewCursor.y = viewHue.top + y - viewCursor.height / 2
+ }
+
+ private fun moveColorPicker() {
+ val x = getSat() * viewSatVal.measuredWidth
+ val y = (1f - getVal()) * viewSatVal.measuredHeight
+ viewTarget.x = viewSatVal.left + x - viewTarget.width / 2
+ viewTarget.y = viewSatVal.top + y - viewTarget.height / 2
+ }
+
+ private fun getColor() = Color.HSVToColor(currentColorHsv)
+ private fun getHue() = currentColorHsv[0]
+ private fun getSat() = currentColorHsv[1]
+ private fun getVal() = currentColorHsv[2]
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/dialogs/CustomAspectRatioDialog.kt b/src/main/java/eu/siacs/conversations/medialib/dialogs/CustomAspectRatioDialog.kt
new file mode 100644
index 000000000..1b718d3a3
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/dialogs/CustomAspectRatioDialog.kt
@@ -0,0 +1,44 @@
+package eu.siacs.conversations.medialib.dialogs
+
+import android.app.Activity
+import android.widget.EditText
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import eu.siacs.conversations.R
+import eu.siacs.conversations.databinding.DialogCustomAspectRatioBinding
+import eu.siacs.conversations.medialib.extensions.setupDialogStuff
+import eu.siacs.conversations.medialib.extensions.showKeyboard
+import eu.siacs.conversations.medialib.extensions.value
+
+class CustomAspectRatioDialog(
+ val activity: Activity, val defaultCustomAspectRatio: Pair?, val callback: (aspectRatio: Pair) -> Unit
+) {
+ init {
+ val binding = DialogCustomAspectRatioBinding.inflate(activity.layoutInflater)
+ val view = binding.root.apply {
+ binding.aspectRatioWidth.setText(defaultCustomAspectRatio?.first?.toInt()?.toString() ?: "")
+ binding.aspectRatioHeight.setText(defaultCustomAspectRatio?.second?.toInt()?.toString() ?: "")
+ }
+
+ MaterialAlertDialogBuilder(activity)
+ .setPositiveButton(R.string.ok, null)
+ .setNegativeButton(R.string.cancel, null)
+ .apply {
+ activity.setupDialogStuff(view, this) { alertDialog ->
+ alertDialog.showKeyboard(binding.aspectRatioWidth)
+ alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
+ val width = getViewValue(binding.aspectRatioWidth)
+ val height = getViewValue(binding.aspectRatioHeight)
+ callback(Pair(width, height))
+ alertDialog.dismiss()
+ }
+ }
+ }
+ }
+
+ private fun getViewValue(view: EditText): Float {
+ val textValue = view.value
+ return if (textValue.isEmpty()) 0f else textValue.toFloat()
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/dialogs/OtherAspectRatioDialog.kt b/src/main/java/eu/siacs/conversations/medialib/dialogs/OtherAspectRatioDialog.kt
new file mode 100644
index 000000000..d6dbe21d9
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/dialogs/OtherAspectRatioDialog.kt
@@ -0,0 +1,82 @@
+package eu.siacs.conversations.medialib.dialogs
+
+import android.app.Activity
+import androidx.appcompat.app.AlertDialog
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import eu.siacs.conversations.R
+import eu.siacs.conversations.databinding.DialogOtherAspectRatioBinding
+import eu.siacs.conversations.medialib.extensions.setupDialogStuff
+
+class OtherAspectRatioDialog(
+ val activity: Activity,
+ val lastOtherAspectRatio: Pair?,
+ val callback: (aspectRatio: Pair) -> Unit
+) {
+ private var dialog: AlertDialog? = null
+
+ init {
+ val binding = DialogOtherAspectRatioBinding.inflate(activity.layoutInflater)
+ val view = binding.root.apply {
+ binding.otherAspectRatio21.setOnClickListener { ratioPicked(Pair(2f, 1f)) }
+ binding.otherAspectRatio32.setOnClickListener { ratioPicked(Pair(3f, 2f)) }
+ binding.otherAspectRatio43.setOnClickListener { ratioPicked(Pair(4f, 3f)) }
+ binding.otherAspectRatio53.setOnClickListener { ratioPicked(Pair(5f, 3f)) }
+ binding.otherAspectRatio169.setOnClickListener { ratioPicked(Pair(16f, 9f)) }
+ binding.otherAspectRatio199.setOnClickListener { ratioPicked(Pair(19f, 9f)) }
+ binding.otherAspectRatioCustom.setOnClickListener { customRatioPicked() }
+
+ binding.otherAspectRatio12.setOnClickListener { ratioPicked(Pair(1f, 2f)) }
+ binding.otherAspectRatio23.setOnClickListener { ratioPicked(Pair(2f, 3f)) }
+ binding.otherAspectRatio34.setOnClickListener { ratioPicked(Pair(3f, 4f)) }
+ binding.otherAspectRatio35.setOnClickListener { ratioPicked(Pair(3f, 5f)) }
+ binding.otherAspectRatio916.setOnClickListener { ratioPicked(Pair(9f, 16f)) }
+ binding.otherAspectRatio919.setOnClickListener { ratioPicked(Pair(9f, 19f)) }
+
+ val radio1SelectedItemId = when (lastOtherAspectRatio) {
+ Pair(2f, 1f) -> binding.otherAspectRatio21.id
+ Pair(3f, 2f) -> binding.otherAspectRatio32.id
+ Pair(4f, 3f) -> binding.otherAspectRatio43.id
+ Pair(5f, 3f) -> binding.otherAspectRatio53.id
+ Pair(16f, 9f) -> binding.otherAspectRatio169.id
+ Pair(19f, 9f) -> binding.otherAspectRatio199.id
+ else -> 0
+ }
+ binding.otherAspectRatioDialogRadio1.check(radio1SelectedItemId)
+
+ val radio2SelectedItemId = when (lastOtherAspectRatio) {
+ Pair(1f, 2f) -> binding.otherAspectRatio12.id
+ Pair(2f, 3f) -> binding.otherAspectRatio23.id
+ Pair(3f, 4f) -> binding.otherAspectRatio34.id
+ Pair(3f, 5f) -> binding.otherAspectRatio35.id
+ Pair(9f, 16f) -> binding.otherAspectRatio916.id
+ Pair(9f, 19f) -> binding.otherAspectRatio919.id
+ else -> 0
+ }
+ binding.otherAspectRatioDialogRadio2.check(radio2SelectedItemId)
+
+ if (radio1SelectedItemId == 0 && radio2SelectedItemId == 0) {
+ binding.otherAspectRatioDialogRadio1.check(binding.otherAspectRatioCustom.id)
+ }
+ }
+
+ MaterialAlertDialogBuilder(activity)
+ .setNegativeButton(R.string.cancel, null)
+ .apply {
+ activity.setupDialogStuff(view, this) { alertDialog ->
+ dialog = alertDialog
+ }
+ }
+ }
+
+ private fun customRatioPicked() {
+ CustomAspectRatioDialog(activity, lastOtherAspectRatio) {
+ callback(it)
+ dialog?.dismiss()
+ }
+ }
+
+ private fun ratioPicked(pair: Pair) {
+ callback(pair)
+ dialog?.dismiss()
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/dialogs/ResizeDialog.kt b/src/main/java/eu/siacs/conversations/medialib/dialogs/ResizeDialog.kt
new file mode 100644
index 000000000..c72548d96
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/dialogs/ResizeDialog.kt
@@ -0,0 +1,78 @@
+package eu.siacs.conversations.medialib.dialogs
+
+import android.app.Activity
+import android.graphics.Point
+import android.widget.EditText
+import androidx.appcompat.app.AlertDialog
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import eu.siacs.conversations.R
+import eu.siacs.conversations.databinding.DialogResizeImageBinding
+import eu.siacs.conversations.medialib.extensions.*
+
+class ResizeDialog(val activity: Activity, val size: Point, val callback: (newSize: Point) -> Unit) {
+ init {
+ val binding = DialogResizeImageBinding.inflate(activity.layoutInflater)
+ val view = binding.root
+ val widthView = binding.resizeImageWidth
+ val heightView = binding.resizeImageHeight
+
+ widthView.setText(size.x.toString())
+ heightView.setText(size.y.toString())
+
+ val ratio = size.x / size.y.toFloat()
+
+ widthView.onTextChangeListener {
+ if (widthView.hasFocus()) {
+ var width = getViewValue(widthView)
+ if (width > size.x) {
+ widthView.setText(size.x.toString())
+ width = size.x
+ }
+
+ if (binding.keepAspectRatio.isChecked) {
+ heightView.setText((width / ratio).toInt().toString())
+ }
+ }
+ }
+
+ heightView.onTextChangeListener {
+ if (heightView.hasFocus()) {
+ var height = getViewValue(heightView)
+ if (height > size.y) {
+ heightView.setText(size.y.toString())
+ height = size.y
+ }
+
+ if (binding.keepAspectRatio.isChecked) {
+ widthView.setText((height * ratio).toInt().toString())
+ }
+ }
+ }
+
+ MaterialAlertDialogBuilder(activity)
+ .setPositiveButton(R.string.ok, null)
+ .setNegativeButton(R.string.cancel, null)
+ .apply {
+ activity.setupDialogStuff(view, this, R.string.resize_and_save) { alertDialog ->
+ alertDialog.showKeyboard(binding.resizeImageWidth)
+ alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
+ val width = getViewValue(widthView)
+ val height = getViewValue(heightView)
+ if (width <= 0 || height <= 0) {
+ activity.toast(R.string.invalid_values)
+ return@setOnClickListener
+ }
+
+ val newSize = Point(getViewValue(widthView), getViewValue(heightView))
+ callback(newSize)
+ alertDialog.dismiss()
+ }
+ }
+ }
+ }
+
+ private fun getViewValue(view: EditText): Int {
+ val textValue = view.value
+ return if (textValue.isEmpty()) 0 else textValue.toInt()
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/extensions/Activity.kt b/src/main/java/eu/siacs/conversations/medialib/extensions/Activity.kt
new file mode 100644
index 000000000..401c5fa16
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/extensions/Activity.kt
@@ -0,0 +1,39 @@
+package eu.siacs.conversations.medialib.extensions
+
+import android.app.Activity
+import android.view.View
+import androidx.appcompat.app.AlertDialog
+import eu.siacs.conversations.R
+import eu.siacs.conversations.medialib.models.FileDirItem
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.OutputStream
+
+fun Activity.setupDialogStuff(
+ view: View,
+ dialog: AlertDialog.Builder,
+ titleId: Int = 0,
+ titleText: String = "",
+ cancelOnTouchOutside: Boolean = true,
+ callback: ((alertDialog: AlertDialog) -> Unit)? = null
+) {
+ if (isDestroyed || isFinishing) {
+ return
+ }
+
+ dialog.create().apply {
+ if (titleId != 0) {
+ setTitle(titleId)
+ } else if (titleText.isNotEmpty()) {
+ setTitle(titleText)
+ }
+
+ setView(view)
+ setCancelable(cancelOnTouchOutside)
+ if (!isFinishing) {
+ show()
+ }
+
+ callback?.invoke(this)
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/extensions/AlertDialog.kt b/src/main/java/eu/siacs/conversations/medialib/extensions/AlertDialog.kt
new file mode 100644
index 000000000..5561d1993
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/extensions/AlertDialog.kt
@@ -0,0 +1,20 @@
+package eu.siacs.conversations.medialib.extensions
+
+import android.view.WindowManager
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.widget.AppCompatEditText
+
+// in dialogs, lets use findViewById, because while some dialogs use MyEditText, material theme dialogs use TextInputEditText so the system takes care of it
+fun AlertDialog.showKeyboard(editText: AppCompatEditText) {
+ window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
+ editText.apply {
+ requestFocus()
+ onGlobalLayout {
+ setSelection(text.toString().length)
+ }
+ }
+}
+
+fun AlertDialog.hideKeyboard() {
+ window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN)
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/extensions/Constants.kt b/src/main/java/eu/siacs/conversations/medialib/extensions/Constants.kt
new file mode 100644
index 000000000..212e48130
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/extensions/Constants.kt
@@ -0,0 +1,42 @@
+package eu.siacs.conversations.medialib.extensions
+
+import android.os.Build
+import androidx.annotation.ChecksSdkIntAtLeast
+
+
+@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N)
+fun isNougatPlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
+
+@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N_MR1)
+fun isNougatMR1Plus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1
+
+@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
+fun isOreoPlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+
+@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O_MR1)
+fun isOreoMr1Plus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1
+
+@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P)
+fun isPiePlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
+
+@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q)
+fun isQPlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+
+@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R)
+fun isRPlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+
+@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
+fun isSPlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+
+//@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU)
+//fun isTiramisuPlus() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+
+fun ensureBackgroundThread(callback: () -> Unit) {
+ if (isOnMainThread()) {
+ Thread {
+ callback()
+ }.start()
+ } else {
+ callback()
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/extensions/Context.kt b/src/main/java/eu/siacs/conversations/medialib/extensions/Context.kt
new file mode 100644
index 000000000..79d691f25
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/extensions/Context.kt
@@ -0,0 +1,183 @@
+package eu.siacs.conversations.medialib.extensions
+
+import android.app.Activity
+import android.content.*
+import android.media.MediaScannerConnection
+import android.net.Uri
+import android.os.Environment
+import android.os.Handler
+import android.os.Looper
+import android.provider.DocumentsContract
+import android.provider.MediaStore
+import android.provider.OpenableColumns
+import android.widget.Toast
+import eu.siacs.conversations.R
+import eu.siacs.conversations.medialib.helpers.Config
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+import java.util.regex.Pattern
+
+val Context.config: Config get() = Config.newInstance(applicationContext)
+
+private const val ANDROID_DATA_DIR = "/Android/data/"
+private const val ANDROID_OBB_DIR = "/Android/obb/"
+val DIRS_ACCESSIBLE_ONLY_WITH_SAF = listOf(ANDROID_DATA_DIR, ANDROID_OBB_DIR)
+
+fun isOnMainThread() = Looper.myLooper() == Looper.getMainLooper()
+
+
+fun Context.toast(id: Int, length: Int = Toast.LENGTH_SHORT) {
+ toast(getString(id), length)
+}
+
+fun Context.toast(msg: String, length: Int = Toast.LENGTH_SHORT) {
+ try {
+ if (isOnMainThread()) {
+ doToast(this, msg, length)
+ } else {
+ Handler(Looper.getMainLooper()).post {
+ doToast(this, msg, length)
+ }
+ }
+ } catch (e: Exception) {
+ }
+}
+
+fun Context.showErrorToast(msg: String, length: Int = Toast.LENGTH_LONG) {
+ toast(String.format(getString(R.string.error), msg), length)
+}
+
+fun Context.showErrorToast(exception: Exception, length: Int = Toast.LENGTH_LONG) {
+ showErrorToast(exception.toString(), length)
+}
+
+fun Context.copyToClipboard(text: String) {
+ val clip = ClipData.newPlainText(getString(R.string.simple_commons), text)
+ (getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(clip)
+ val toastText = String.format(getString(R.string.value_copied_to_clipboard_show), text)
+ toast(toastText)
+}
+
+fun Context.getFilenameFromContentUri(uri: Uri): String? {
+ val projection = arrayOf(
+ OpenableColumns.DISPLAY_NAME
+ )
+
+ try {
+ val cursor = contentResolver.query(uri, projection, null, null, null)
+ cursor?.use {
+ if (cursor.moveToFirst()) {
+ return cursor.getStringValue(OpenableColumns.DISPLAY_NAME)
+ }
+ }
+ } catch (e: Exception) {
+ }
+ return null
+}
+
+// avoid calling this multiple times in row, it can delete whole folder contents
+fun Context.rescanPaths(paths: List, callback: (() -> Unit)? = null) {
+ if (paths.isEmpty()) {
+ callback?.invoke()
+ return
+ }
+
+ for (path in paths) {
+ Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE).apply {
+ data = Uri.fromFile(File(path))
+ sendBroadcast(this)
+ }
+ }
+
+ var cnt = paths.size
+ MediaScannerConnection.scanFile(applicationContext, paths.toTypedArray(), null) { s, uri ->
+ if (--cnt == 0) {
+ callback?.invoke()
+ }
+ }
+}
+
+// some helper functions were taken from https://github.com/iPaulPro/aFileChooser/blob/master/aFileChooser/src/com/ipaulpro/afilechooser/utils/FileUtils.java
+fun Context.getRealPathFromURI(uri: Uri): String? {
+ if (uri.scheme == "file") {
+ return uri.path
+ }
+
+ if (isDownloadsDocument(uri)) {
+ val id = DocumentsContract.getDocumentId(uri)
+ if (id.areDigitsOnly()) {
+ val newUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), id.toLong())
+ val path = getDataColumn(newUri)
+ if (path != null) {
+ return path
+ }
+ }
+ } else if (isExternalStorageDocument(uri)) {
+ val documentId = DocumentsContract.getDocumentId(uri)
+ val parts = documentId.split(":")
+ if (parts[0].equals("primary", true)) {
+ return "${Environment.getExternalStorageDirectory().absolutePath}/${parts[1]}"
+ }
+ } else if (isMediaDocument(uri)) {
+ val documentId = DocumentsContract.getDocumentId(uri)
+ val split = documentId.split(":").dropLastWhile { it.isEmpty() }.toTypedArray()
+ val type = split[0]
+
+ val contentUri = when (type) {
+ "video" -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+ "audio" -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
+ else -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ }
+
+ val selection = "_id=?"
+ val selectionArgs = arrayOf(split[1])
+ val path = getDataColumn(contentUri, selection, selectionArgs)
+ if (path != null) {
+ return path
+ }
+ }
+
+ return getDataColumn(uri)
+}
+
+fun Context.getDataColumn(uri: Uri, selection: String? = null, selectionArgs: Array? = null): String? {
+ try {
+ val projection = arrayOf(MediaStore.Files.FileColumns.DATA)
+ val cursor = contentResolver.query(uri, projection, selection, selectionArgs, null)
+ cursor?.use {
+ if (cursor.moveToFirst()) {
+ val data = cursor.getStringValue(MediaStore.Files.FileColumns.DATA)
+ if (data != "null") {
+ return data
+ }
+ }
+ }
+ } catch (e: Exception) {
+ }
+ return null
+}
+
+fun Context.getInternalStoragePath() =
+ if (File("/storage/emulated/0").exists()) "/storage/emulated/0" else Environment.getExternalStorageDirectory().absolutePath.trimEnd('/')
+
+fun Context.getCurrentFormattedDateTime(): String {
+ val simpleDateFormat = SimpleDateFormat("yyyy_MM_dd_HH_mm_ss", Locale.getDefault())
+ return simpleDateFormat.format(Date(System.currentTimeMillis()))
+}
+
+private fun isDownloadsDocument(uri: Uri) = uri.authority == "com.android.providers.downloads.documents"
+
+private fun isExternalStorageDocument(uri: Uri) = uri.authority == "com.android.externalstorage.documents"
+
+private fun isMediaDocument(uri: Uri) = uri.authority == "com.android.providers.media.documents"
+
+private fun doToast(context: Context, message: String, length: Int) {
+ if (context is Activity) {
+ if (!context.isFinishing && !context.isDestroyed) {
+ Toast.makeText(context, message, length).show()
+ }
+ } else {
+ Toast.makeText(context, message, length).show()
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/extensions/Cursor.kt b/src/main/java/eu/siacs/conversations/medialib/extensions/Cursor.kt
new file mode 100644
index 000000000..062aee07a
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/extensions/Cursor.kt
@@ -0,0 +1,25 @@
+package eu.siacs.conversations.medialib.extensions
+
+import android.annotation.SuppressLint
+import android.database.Cursor
+
+@SuppressLint("Range")
+fun Cursor.getStringValue(key: String) = getString(getColumnIndex(key))
+
+@SuppressLint("Range")
+fun Cursor.getStringValueOrNull(key: String) = if (isNull(getColumnIndex(key))) null else getString(getColumnIndex(key))
+
+@SuppressLint("Range")
+fun Cursor.getIntValue(key: String) = getInt(getColumnIndex(key))
+
+@SuppressLint("Range")
+fun Cursor.getIntValueOrNull(key: String) = if (isNull(getColumnIndex(key))) null else getInt(getColumnIndex(key))
+
+@SuppressLint("Range")
+fun Cursor.getLongValue(key: String) = getLong(getColumnIndex(key))
+
+@SuppressLint("Range")
+fun Cursor.getLongValueOrNull(key: String) = if (isNull(getColumnIndex(key))) null else getLong(getColumnIndex(key))
+
+@SuppressLint("Range")
+fun Cursor.getBlobValue(key: String) = getBlob(getColumnIndex(key))
diff --git a/src/main/java/eu/siacs/conversations/medialib/extensions/EditText.kt b/src/main/java/eu/siacs/conversations/medialib/extensions/EditText.kt
new file mode 100644
index 000000000..b63b104ad
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/extensions/EditText.kt
@@ -0,0 +1,22 @@
+package eu.siacs.conversations.medialib.extensions
+
+import android.text.Editable
+import android.text.Spannable
+import android.text.SpannableString
+import android.text.TextWatcher
+import android.text.style.BackgroundColorSpan
+import android.widget.EditText
+import android.widget.TextView
+import androidx.core.graphics.ColorUtils
+
+val EditText.value: String get() = text.toString().trim()
+
+fun EditText.onTextChangeListener(onTextChangedAction: (newText: String) -> Unit) = addTextChangedListener(object : TextWatcher {
+ override fun afterTextChanged(s: Editable?) {
+ onTextChangedAction(s.toString())
+ }
+
+ override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
+})
diff --git a/src/main/java/eu/siacs/conversations/medialib/extensions/ExifInterface.kt b/src/main/java/eu/siacs/conversations/medialib/extensions/ExifInterface.kt
new file mode 100644
index 000000000..33e1da44d
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/extensions/ExifInterface.kt
@@ -0,0 +1,58 @@
+package eu.siacs.conversations.medialib.extensions
+
+import androidx.exifinterface.media.ExifInterface
+import java.lang.reflect.Field
+import java.lang.reflect.Modifier
+
+fun ExifInterface.copyNonDimensionAttributesTo(destination: ExifInterface) {
+ val attributes = ExifInterfaceAttributes.AllNonDimensionAttributes
+
+ attributes.forEach {
+ val value = getAttribute(it)
+ if (value != null) {
+ destination.setAttribute(it, value)
+ }
+ }
+
+ try {
+ destination.saveAttributes()
+ } catch (ignored: Exception) {
+ }
+}
+
+private class ExifInterfaceAttributes {
+ companion object {
+ val AllNonDimensionAttributes = getAllNonDimensionExifAttributes()
+
+ private fun getAllNonDimensionExifAttributes(): List {
+ val tagFields = ExifInterface::class.java.fields.filter { field -> isExif(field) }
+
+ val excludeAttributes = arrayListOf(
+ ExifInterface.TAG_IMAGE_LENGTH,
+ ExifInterface.TAG_IMAGE_WIDTH,
+ ExifInterface.TAG_PIXEL_X_DIMENSION,
+ ExifInterface.TAG_PIXEL_Y_DIMENSION,
+ ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH,
+ ExifInterface.TAG_THUMBNAIL_IMAGE_WIDTH,
+ ExifInterface.TAG_ORIENTATION
+ )
+
+ return tagFields
+ .map { tagField -> tagField.get(null) as String }
+ .filter { x -> !excludeAttributes.contains(x) }
+ .distinct()
+ }
+
+ private fun isExif(field: Field): Boolean {
+ return field.type == String::class.java &&
+ isPublicStaticFinal(field.modifiers) &&
+ field.name.startsWith("TAG_")
+ }
+
+ private const val publicStaticFinal = Modifier.PUBLIC or Modifier.STATIC or Modifier.FINAL
+
+ private fun isPublicStaticFinal(modifiers: Int): Boolean {
+ return modifiers and publicStaticFinal > 0
+ }
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/extensions/ImageView.kt b/src/main/java/eu/siacs/conversations/medialib/extensions/ImageView.kt
new file mode 100644
index 000000000..85fa5b115
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/extensions/ImageView.kt
@@ -0,0 +1,30 @@
+package eu.siacs.conversations.medialib.extensions
+
+import android.graphics.PorterDuff
+import android.graphics.drawable.GradientDrawable
+import android.widget.ImageView
+import androidx.annotation.DrawableRes
+
+fun ImageView.setFillWithStroke(fillColor: Int, backgroundColor: Int, drawRectangle: Boolean = false) {
+ GradientDrawable().apply {
+ shape = if (drawRectangle) GradientDrawable.RECTANGLE else GradientDrawable.OVAL
+ setColor(fillColor)
+ background = this
+
+ if (backgroundColor == fillColor || fillColor == -2 && backgroundColor == -1) {
+ val strokeColor = backgroundColor.getContrastColor().adjustAlpha(0.5f)
+ setStroke(2, strokeColor)
+ }
+ }
+}
+
+fun ImageView.applyColorFilter(color: Int) = setColorFilter(color, PorterDuff.Mode.SRC_IN)
+
+fun ImageView.setImageResourceOrBeGone(@DrawableRes imageRes: Int?) {
+ if (imageRes != null) {
+ beVisible()
+ setImageResource(imageRes)
+ } else {
+ beGone()
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/extensions/Int.kt b/src/main/java/eu/siacs/conversations/medialib/extensions/Int.kt
new file mode 100644
index 000000000..edb06fc4a
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/extensions/Int.kt
@@ -0,0 +1,19 @@
+package eu.siacs.conversations.medialib.extensions
+
+import android.graphics.Color
+import eu.siacs.conversations.medialib.helpers.DARK_GREY
+
+fun Int.getContrastColor(): Int {
+ val y = (299 * Color.red(this) + 587 * Color.green(this) + 114 * Color.blue(this)) / 1000
+ return if (y >= 149 && this != Color.BLACK) DARK_GREY else Color.WHITE
+}
+
+fun Int.toHex() = String.format("#%06X", 0xFFFFFF and this).toUpperCase()
+
+fun Int.adjustAlpha(factor: Float): Int {
+ val alpha = Math.round(Color.alpha(this) * factor)
+ val red = Color.red(this)
+ val green = Color.green(this)
+ val blue = Color.blue(this)
+ return Color.argb(alpha, red, green, blue)
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/extensions/SeekBar.kt b/src/main/java/eu/siacs/conversations/medialib/extensions/SeekBar.kt
new file mode 100644
index 000000000..094401198
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/extensions/SeekBar.kt
@@ -0,0 +1,13 @@
+package eu.siacs.conversations.medialib.extensions
+
+import android.widget.SeekBar
+
+fun SeekBar.onSeekBarChangeListener(seekBarChangeListener: (progress: Int) -> Unit) = setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+ override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
+ seekBarChangeListener(progress)
+ }
+
+ override fun onStartTrackingTouch(seekBar: SeekBar) {}
+
+ override fun onStopTrackingTouch(seekBar: SeekBar) {}
+})
diff --git a/src/main/java/eu/siacs/conversations/medialib/extensions/String.kt b/src/main/java/eu/siacs/conversations/medialib/extensions/String.kt
new file mode 100644
index 000000000..d07247d3b
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/extensions/String.kt
@@ -0,0 +1,17 @@
+package eu.siacs.conversations.medialib.extensions
+
+import android.graphics.Bitmap
+
+fun String.getFilenameFromPath() = substring(lastIndexOf("/") + 1)
+
+fun String.getFilenameExtension() = substring(lastIndexOf(".") + 1)
+
+fun String.getCompressionFormat() = when (getFilenameExtension().lowercase()) {
+ "png" -> Bitmap.CompressFormat.PNG
+ "webp" -> Bitmap.CompressFormat.WEBP
+ else -> Bitmap.CompressFormat.JPEG
+}
+
+fun String.areDigitsOnly() = matches(Regex("[0-9]+"))
+
+fun String.getParentPath() = removeSuffix("/${getFilenameFromPath()}")
diff --git a/src/main/java/eu/siacs/conversations/medialib/extensions/View.kt b/src/main/java/eu/siacs/conversations/medialib/extensions/View.kt
new file mode 100644
index 000000000..91758a551
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/extensions/View.kt
@@ -0,0 +1,40 @@
+package eu.siacs.conversations.medialib.extensions
+
+import android.view.HapticFeedbackConstants
+import android.view.View
+import android.view.ViewTreeObserver
+
+fun View.beInvisibleIf(beInvisible: Boolean) = if (beInvisible) beInvisible() else beVisible()
+
+fun View.beVisibleIf(beVisible: Boolean) = if (beVisible) beVisible() else beGone()
+
+fun View.beGoneIf(beGone: Boolean) = beVisibleIf(!beGone)
+
+fun View.beInvisible() {
+ visibility = View.INVISIBLE
+}
+
+fun View.beVisible() {
+ visibility = View.VISIBLE
+}
+
+fun View.beGone() {
+ visibility = View.GONE
+}
+
+fun View.onGlobalLayout(callback: () -> Unit) {
+ viewTreeObserver?.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ if (viewTreeObserver != null) {
+ viewTreeObserver.removeOnGlobalLayoutListener(this)
+ callback()
+ }
+ }
+ })
+}
+
+fun View.isVisible() = visibility == View.VISIBLE
+
+fun View.isInvisible() = visibility == View.INVISIBLE
+
+fun View.isGone() = visibility == View.GONE
diff --git a/src/main/java/eu/siacs/conversations/medialib/helpers/Config.kt b/src/main/java/eu/siacs/conversations/medialib/helpers/Config.kt
new file mode 100644
index 000000000..04121f567
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/helpers/Config.kt
@@ -0,0 +1,50 @@
+package eu.siacs.conversations.medialib.helpers
+
+import android.content.Context
+import android.graphics.Color
+import eu.siacs.conversations.R
+import java.util.*
+
+class Config(private val context: Context) {
+ protected val prefs = context.getSharedPreferences("media_config_prefs", Context.MODE_PRIVATE)
+
+
+ companion object {
+ fun newInstance(context: Context) = Config(context)
+ }
+
+ // color picker last used colors
+ var colorPickerRecentColors: LinkedList
+ get(): LinkedList {
+ val defaultList = arrayListOf(
+ Color.RED,
+ Color.BLUE,
+ Color.GREEN,
+ Color.YELLOW,
+ Color.BLACK
+ )
+ return LinkedList(prefs.getString(COLOR_PICKER_RECENT_COLORS, null)?.lines()?.map { it.toInt() } ?: defaultList)
+ }
+ set(recentColors) = prefs.edit().putString(COLOR_PICKER_RECENT_COLORS, recentColors.joinToString(separator = "\n")).apply()
+
+
+ var lastEditorCropAspectRatio: Int
+ get() = prefs.getInt(LAST_EDITOR_CROP_ASPECT_RATIO, ASPECT_RATIO_FREE)
+ set(lastEditorCropAspectRatio) = prefs.edit().putInt(LAST_EDITOR_CROP_ASPECT_RATIO, lastEditorCropAspectRatio).apply()
+
+ var lastEditorCropOtherAspectRatioX: Float
+ get() = prefs.getFloat(LAST_EDITOR_CROP_OTHER_ASPECT_RATIO_X, 2f)
+ set(lastEditorCropOtherAspectRatioX) = prefs.edit().putFloat(LAST_EDITOR_CROP_OTHER_ASPECT_RATIO_X, lastEditorCropOtherAspectRatioX).apply()
+
+ var lastEditorCropOtherAspectRatioY: Float
+ get() = prefs.getFloat(LAST_EDITOR_CROP_OTHER_ASPECT_RATIO_Y, 1f)
+ set(lastEditorCropOtherAspectRatioY) = prefs.edit().putFloat(LAST_EDITOR_CROP_OTHER_ASPECT_RATIO_Y, lastEditorCropOtherAspectRatioY).apply()
+
+ var lastEditorDrawColor: Int
+ get() = prefs.getInt(LAST_EDITOR_DRAW_COLOR, context.getColor(R.color.editor_draw_default_color))
+ set(lastEditorDrawColor) = prefs.edit().putInt(LAST_EDITOR_DRAW_COLOR, lastEditorDrawColor).apply()
+
+ var lastEditorBrushSize: Int
+ get() = prefs.getInt(LAST_EDITOR_BRUSH_SIZE, 50)
+ set(lastEditorBrushSize) = prefs.edit().putInt(LAST_EDITOR_BRUSH_SIZE, lastEditorBrushSize).apply()
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/helpers/Constants.kt b/src/main/java/eu/siacs/conversations/medialib/helpers/Constants.kt
new file mode 100644
index 000000000..98323e666
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/helpers/Constants.kt
@@ -0,0 +1,21 @@
+package eu.siacs.conversations.medialib.helpers
+
+// shared preferences
+const val LAST_EDITOR_CROP_ASPECT_RATIO = "last_editor_crop_aspect_ratio"
+const val LAST_EDITOR_CROP_OTHER_ASPECT_RATIO_X = "last_editor_crop_other_aspect_ratio_x_2"
+const val LAST_EDITOR_CROP_OTHER_ASPECT_RATIO_Y = "last_editor_crop_other_aspect_ratio_y_2"
+const val LAST_EDITOR_DRAW_COLOR = "last_editor_draw_color"
+const val LAST_EDITOR_BRUSH_SIZE = "last_editor_brush_size"
+
+const val REAL_FILE_PATH = "real_file_path_2"
+
+const val COLOR_PICKER_RECENT_COLORS = "color_picker_recent_colors"
+
+val DARK_GREY = 0xFF333333.toInt()
+
+// aspect ratios used at the editor for cropping
+const val ASPECT_RATIO_FREE = 0
+const val ASPECT_RATIO_ONE_ONE = 1
+const val ASPECT_RATIO_FOUR_THREE = 2
+const val ASPECT_RATIO_SIXTEEN_NINE = 3
+const val ASPECT_RATIO_OTHER = 4
diff --git a/src/main/java/eu/siacs/conversations/medialib/helpers/FilterThumbnailsManager.kt b/src/main/java/eu/siacs/conversations/medialib/helpers/FilterThumbnailsManager.kt
new file mode 100644
index 000000000..7501d1a89
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/helpers/FilterThumbnailsManager.kt
@@ -0,0 +1,27 @@
+package eu.siacs.conversations.medialib.helpers
+
+import android.graphics.Bitmap
+import eu.siacs.conversations.medialib.models.FilterItem
+import java.util.*
+
+class FilterThumbnailsManager {
+ private var filterThumbnails = ArrayList(10)
+ private var processedThumbnails = ArrayList(10)
+
+ fun addThumb(filterItem: FilterItem) {
+ filterThumbnails.add(filterItem)
+ }
+
+ fun processThumbs(): ArrayList {
+ for (filterItem in filterThumbnails) {
+ filterItem.bitmap = filterItem.filter.processFilter(Bitmap.createBitmap(filterItem.bitmap))
+ processedThumbnails.add(filterItem)
+ }
+ return processedThumbnails
+ }
+
+ fun clearThumbs() {
+ filterThumbnails = ArrayList()
+ processedThumbnails = ArrayList()
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/models/FileDirItem.kt b/src/main/java/eu/siacs/conversations/medialib/models/FileDirItem.kt
new file mode 100644
index 000000000..b357ec356
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/models/FileDirItem.kt
@@ -0,0 +1,18 @@
+package eu.siacs.conversations.medialib.models
+
+import eu.siacs.conversations.medialib.extensions.getParentPath
+
+open class FileDirItem(
+ val path: String,
+ val name: String = "",
+ var isDirectory: Boolean = false,
+ var children: Int = 0,
+ var size: Long = 0L,
+ var modified: Long = 0L,
+ var mediaStoreId: Long = 0L
+) {
+ override fun toString() =
+ "FileDirItem(path=$path, name=$name, isDirectory=$isDirectory, children=$children, size=$size, modified=$modified, mediaStoreId=$mediaStoreId)"
+
+ fun getParentPath() = path.getParentPath()
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/models/FilterItem.kt b/src/main/java/eu/siacs/conversations/medialib/models/FilterItem.kt
new file mode 100644
index 000000000..7da94fca8
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/models/FilterItem.kt
@@ -0,0 +1,6 @@
+package eu.siacs.conversations.medialib.models
+
+import android.graphics.Bitmap
+import com.zomato.photofilters.imageprocessors.Filter
+
+data class FilterItem(var bitmap: Bitmap, val filter: Filter)
diff --git a/src/main/java/eu/siacs/conversations/medialib/models/PaintOptions.kt b/src/main/java/eu/siacs/conversations/medialib/models/PaintOptions.kt
new file mode 100644
index 000000000..bc5de180f
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/models/PaintOptions.kt
@@ -0,0 +1,5 @@
+package eu.siacs.conversations.medialib.models
+
+import android.graphics.Color
+
+data class PaintOptions(var color: Int = Color.BLACK, var strokeWidth: Float = 5f)
diff --git a/src/main/java/eu/siacs/conversations/medialib/views/ColorPickerSquare.kt b/src/main/java/eu/siacs/conversations/medialib/views/ColorPickerSquare.kt
new file mode 100644
index 000000000..9cc6d37ab
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/views/ColorPickerSquare.kt
@@ -0,0 +1,33 @@
+package eu.siacs.conversations.medialib.views
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.*
+import android.graphics.Shader.TileMode
+import android.util.AttributeSet
+import android.view.View
+
+class ColorPickerSquare(context: Context, attrs: AttributeSet) : View(context, attrs) {
+ var paint: Paint? = null
+ var luar: Shader = LinearGradient(0f, 0f, 0f, measuredHeight.toFloat(), Color.WHITE, Color.BLACK, Shader.TileMode.CLAMP)
+ val color = floatArrayOf(1f, 1f, 1f)
+
+ @SuppressLint("DrawAllocation")
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ if (paint == null) {
+ paint = Paint()
+ luar = LinearGradient(0f, 0f, 0f, measuredHeight.toFloat(), Color.WHITE, Color.BLACK, TileMode.CLAMP)
+ }
+ val rgb = Color.HSVToColor(color)
+ val dalam = LinearGradient(0f, 0f, measuredWidth.toFloat(), 0f, Color.WHITE, rgb, TileMode.CLAMP)
+ val shader = ComposeShader(luar, dalam, PorterDuff.Mode.MULTIPLY)
+ paint!!.shader = shader
+ canvas.drawRect(0f, 0f, measuredWidth.toFloat(), measuredHeight.toFloat(), paint!!)
+ }
+
+ fun setHue(hue: Float) {
+ color[0] = hue
+ invalidate()
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/medialib/views/EditorDrawCanvas.kt b/src/main/java/eu/siacs/conversations/medialib/views/EditorDrawCanvas.kt
new file mode 100644
index 000000000..7e55ee5f3
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/medialib/views/EditorDrawCanvas.kt
@@ -0,0 +1,145 @@
+package eu.siacs.conversations.medialib.views
+
+import android.content.Context
+import android.graphics.*
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.view.View
+import eu.siacs.conversations.R
+import eu.siacs.conversations.medialib.models.PaintOptions
+
+class EditorDrawCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
+ private var mCurX = 0f
+ private var mCurY = 0f
+ private var mStartX = 0f
+ private var mStartY = 0f
+ private var mColor = 0
+ private var mWasMultitouch = false
+
+ private var mPaths = LinkedHashMap()
+ private var mPaint = Paint()
+ private var mPath = Path()
+ private var mPaintOptions = PaintOptions()
+
+ private var backgroundBitmap: Bitmap? = null
+
+ init {
+ mColor = context.getColor(R.color.editor_draw_default_color)
+ mPaint.apply {
+ color = mColor
+ style = Paint.Style.STROKE
+ strokeJoin = Paint.Join.ROUND
+ strokeCap = Paint.Cap.ROUND
+ strokeWidth = 40f
+ isAntiAlias = true
+ }
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ canvas.save()
+
+ if (backgroundBitmap != null) {
+ canvas.drawBitmap(backgroundBitmap!!, 0f, 0f, null)
+ }
+
+ for ((key, value) in mPaths) {
+ changePaint(value)
+ canvas.drawPath(key, mPaint)
+ }
+
+ changePaint(mPaintOptions)
+ canvas.drawPath(mPath, mPaint)
+ canvas.restore()
+ }
+
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ val x = event.x
+ val y = event.y
+
+ when (event.action and MotionEvent.ACTION_MASK) {
+ MotionEvent.ACTION_DOWN -> {
+ mWasMultitouch = false
+ mStartX = x
+ mStartY = y
+ actionDown(x, y)
+ }
+ MotionEvent.ACTION_MOVE -> {
+ if (event.pointerCount == 1 && !mWasMultitouch) {
+ actionMove(x, y)
+ }
+ }
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> actionUp()
+ MotionEvent.ACTION_POINTER_DOWN -> mWasMultitouch = true
+ }
+
+ invalidate()
+ return true
+ }
+
+ private fun actionDown(x: Float, y: Float) {
+ mPath.reset()
+ mPath.moveTo(x, y)
+ mCurX = x
+ mCurY = y
+ }
+
+ private fun actionMove(x: Float, y: Float) {
+ mPath.quadTo(mCurX, mCurY, (x + mCurX) / 2, (y + mCurY) / 2)
+ mCurX = x
+ mCurY = y
+ }
+
+ private fun actionUp() {
+ if (!mWasMultitouch) {
+ mPath.lineTo(mCurX, mCurY)
+
+ // draw a dot on click
+ if (mStartX == mCurX && mStartY == mCurY) {
+ mPath.lineTo(mCurX, mCurY + 2)
+ mPath.lineTo(mCurX + 1, mCurY + 2)
+ mPath.lineTo(mCurX + 1, mCurY)
+ }
+ }
+
+ mPaths[mPath] = mPaintOptions
+ mPath = Path()
+ mPaintOptions = PaintOptions(mPaintOptions.color, mPaintOptions.strokeWidth)
+ }
+
+ private fun changePaint(paintOptions: PaintOptions) {
+ mPaint.color = paintOptions.color
+ mPaint.strokeWidth = paintOptions.strokeWidth
+ }
+
+ fun updateColor(newColor: Int) {
+ mPaintOptions.color = newColor
+ }
+
+ fun updateBrushSize(newBrushSize: Int) {
+ mPaintOptions.strokeWidth = resources.getDimension(R.dimen.full_brush_size) * (newBrushSize / 100f)
+ }
+
+ fun updateBackgroundBitmap(bitmap: Bitmap) {
+ backgroundBitmap = bitmap
+ invalidate()
+ }
+
+ fun getBitmap(): Bitmap {
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ canvas.drawColor(Color.WHITE)
+ draw(canvas)
+ return bitmap
+ }
+
+ fun undo() {
+ if (mPaths.isEmpty()) {
+ return
+ }
+
+ val lastKey = mPaths.keys.lastOrNull()
+ mPaths.remove(lastKey)
+ invalidate()
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
index 76965dde3..b4049690e 100644
--- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
+++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
@@ -98,6 +98,7 @@ import eu.siacs.conversations.entities.ReadByMarker;
import eu.siacs.conversations.entities.Transferable;
import eu.siacs.conversations.entities.TransferablePlaceholder;
import eu.siacs.conversations.http.HttpDownloadConnection;
+import eu.siacs.conversations.medialib.activities.EditActivity;
import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.services.MessageArchiveService;
import eu.siacs.conversations.services.QuickConversationsService;
@@ -163,6 +164,7 @@ public class ConversationFragment extends XmppFragment
public static final int ATTACHMENT_CHOICE_LOCATION = 0x0305;
public static final int ATTACHMENT_CHOICE_INVALID = 0x0306;
public static final int ATTACHMENT_CHOICE_RECORD_VIDEO = 0x0307;
+ public static final int ATTACHMENT_CHOICE_EDIT_PHOTO = 0x0308;
public static final String RECENTLY_USED_QUICK_ACTION = "recently_used_quick_action";
public static final String STATE_CONVERSATION_UUID =
@@ -1032,14 +1034,25 @@ public class ConversationFragment extends XmppFragment
case ATTACHMENT_CHOICE_CHOOSE_IMAGE:
final List imageUris =
Attachment.extractAttachments(getActivity(), data, Attachment.Type.IMAGE);
- mediaPreviewAdapter.addMediaPreviews(imageUris);
- toggleInputMethod();
+ if (imageUris.size() == 1) {
+ editImage(imageUris.get(0).getUri());
+ } else {
+ mediaPreviewAdapter.addMediaPreviews(imageUris);
+ toggleInputMethod();
+ }
break;
case ATTACHMENT_CHOICE_TAKE_PHOTO:
final Uri takePhotoUri = pendingTakePhotoUri.pop();
if (takePhotoUri != null) {
- mediaPreviewAdapter.addMediaPreviews(
- Attachment.of(getActivity(), takePhotoUri, Attachment.Type.IMAGE));
+ editImage(takePhotoUri);
+ } else {
+ Log.d(Config.LOGTAG, "lost take photo uri. unable to to attach");
+ }
+ break;
+ case ATTACHMENT_CHOICE_EDIT_PHOTO:
+ final Uri editedUriPhoto = data.getParcelableExtra(EditActivity.KEY_EDITED_URI);
+ if (editedUriPhoto != null) {
+ mediaPreviewAdapter.replaceOrAddMediaPreview(data.getData(), editedUriPhoto, Attachment.Type.IMAGE);
toggleInputMethod();
} else {
Log.d(Config.LOGTAG, "lost take photo uri. unable to to attach");
@@ -1085,6 +1098,13 @@ public class ConversationFragment extends XmppFragment
}
}
+ public void editImage(Uri uri) {
+ Intent intent = new Intent(activity, EditActivity.class);
+ intent.setData(uri);
+ intent.putExtra(EditActivity.KEY_CHAT_NAME, conversation.getName());
+ startActivityForResult(intent, ATTACHMENT_CHOICE_EDIT_PHOTO);
+ }
+
private void commitAttachments() {
final List attachments = mediaPreviewAdapter.getAttachments();
if (anyNeedsExternalStoragePermission(attachments)
diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/MediaPreviewAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/MediaPreviewAdapter.java
index 44a3835e0..68625f3c6 100644
--- a/src/main/java/eu/siacs/conversations/ui/adapter/MediaPreviewAdapter.java
+++ b/src/main/java/eu/siacs/conversations/ui/adapter/MediaPreviewAdapter.java
@@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.view.LayoutInflater;
+import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.Toast;
@@ -22,7 +23,6 @@ import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.RejectedExecutionException;
-
import eu.siacs.conversations.R;
import eu.siacs.conversations.databinding.MediaPreviewBinding;
import eu.siacs.conversations.persistance.FileBackend;
@@ -65,7 +65,13 @@ public class MediaPreviewAdapter extends RecyclerView.Adapter view(context, attachment));
+ holder.binding.mediaPreview.setOnClickListener(v -> {
+ if (attachment.getType() == Attachment.Type.IMAGE) {
+ conversationFragment.editImage(attachment.getUri());
+ } else {
+ view(context, attachment);
+ }
+ });
}
private static void view(final Context context, Attachment attachment) {
@@ -82,6 +88,23 @@ public class MediaPreviewAdapter extends RecyclerView.Adapter attachments) {
this.mediaPreviews.addAll(attachments);
notifyDataSetChanged();
diff --git a/src/main/res/drawable-nodpi/img_color_picker_hue.png b/src/main/res/drawable-nodpi/img_color_picker_hue.png
new file mode 100644
index 000000000..83bb0251d
Binary files /dev/null and b/src/main/res/drawable-nodpi/img_color_picker_hue.png differ
diff --git a/src/main/res/drawable/circle_background.xml b/src/main/res/drawable/circle_background.xml
new file mode 100644
index 000000000..b35a38374
--- /dev/null
+++ b/src/main/res/drawable/circle_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/src/main/res/drawable/circle_stroke_white.xml b/src/main/res/drawable/circle_stroke_white.xml
new file mode 100644
index 000000000..fa003ee1d
--- /dev/null
+++ b/src/main/res/drawable/circle_stroke_white.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/src/main/res/drawable/color_picker_circle.xml b/src/main/res/drawable/color_picker_circle.xml
new file mode 100644
index 000000000..20e701b72
--- /dev/null
+++ b/src/main/res/drawable/color_picker_circle.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
diff --git a/src/main/res/drawable/gradient_background.xml b/src/main/res/drawable/gradient_background.xml
new file mode 100644
index 000000000..1ff04393f
--- /dev/null
+++ b/src/main/res/drawable/gradient_background.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/src/main/res/drawable/ic_arrow_right_vector.xml b/src/main/res/drawable/ic_arrow_right_vector.xml
new file mode 100644
index 000000000..ed05ec242
--- /dev/null
+++ b/src/main/res/drawable/ic_arrow_right_vector.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/main/res/drawable/ic_aspect_ratio_vector.xml b/src/main/res/drawable/ic_aspect_ratio_vector.xml
new file mode 100644
index 000000000..3fcbb3683
--- /dev/null
+++ b/src/main/res/drawable/ic_aspect_ratio_vector.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/main/res/drawable/ic_chevron_right_unpadded_vector.xml b/src/main/res/drawable/ic_chevron_right_unpadded_vector.xml
new file mode 100644
index 000000000..aeda28584
--- /dev/null
+++ b/src/main/res/drawable/ic_chevron_right_unpadded_vector.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/main/res/drawable/ic_crop_rotate_vector.xml b/src/main/res/drawable/ic_crop_rotate_vector.xml
new file mode 100644
index 000000000..d24e5faf5
--- /dev/null
+++ b/src/main/res/drawable/ic_crop_rotate_vector.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/main/res/drawable/ic_done_24dp.xml b/src/main/res/drawable/ic_done_24dp.xml
new file mode 100644
index 000000000..b4a513819
--- /dev/null
+++ b/src/main/res/drawable/ic_done_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/main/res/drawable/ic_draw_vector.xml b/src/main/res/drawable/ic_draw_vector.xml
new file mode 100644
index 000000000..dfa88b66e
--- /dev/null
+++ b/src/main/res/drawable/ic_draw_vector.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/main/res/drawable/ic_flip_horizontally_vector.xml b/src/main/res/drawable/ic_flip_horizontally_vector.xml
new file mode 100644
index 000000000..42733799a
--- /dev/null
+++ b/src/main/res/drawable/ic_flip_horizontally_vector.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/main/res/drawable/ic_flip_vertically_vector.xml b/src/main/res/drawable/ic_flip_vertically_vector.xml
new file mode 100644
index 000000000..92eb70ba3
--- /dev/null
+++ b/src/main/res/drawable/ic_flip_vertically_vector.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/main/res/drawable/ic_minimize_vector.xml b/src/main/res/drawable/ic_minimize_vector.xml
new file mode 100644
index 000000000..77e8c0f3c
--- /dev/null
+++ b/src/main/res/drawable/ic_minimize_vector.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/main/res/drawable/ic_photo_filter_vector.xml b/src/main/res/drawable/ic_photo_filter_vector.xml
new file mode 100644
index 000000000..7b2290b49
--- /dev/null
+++ b/src/main/res/drawable/ic_photo_filter_vector.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/main/res/drawable/ic_rotate_right_vector.xml b/src/main/res/drawable/ic_rotate_right_vector.xml
new file mode 100644
index 000000000..fed0c5047
--- /dev/null
+++ b/src/main/res/drawable/ic_rotate_right_vector.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/main/res/drawable/ic_undo_vector.xml b/src/main/res/drawable/ic_undo_vector.xml
new file mode 100644
index 000000000..9eebb0763
--- /dev/null
+++ b/src/main/res/drawable/ic_undo_vector.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/main/res/drawable/strings.xml b/src/main/res/drawable/strings.xml
new file mode 100644
index 000000000..b8c5b90d2
--- /dev/null
+++ b/src/main/res/drawable/strings.xml
@@ -0,0 +1,46 @@
+
+
+ Simple Gallery
+ Gallery
+
+ OK
+ Cancel
+ Custom
+
+ Resize selection and save
+ Width
+ Height
+ Keep aspect ratio
+ Please enter a valid resolution
+
+ Editor
+ Basic Editor
+ Rotate
+ Invalid image path
+ Image editing failed
+ Unknown file location
+ Transform
+ Crop
+ Draw
+ Flip horizontally
+ Flip vertically
+ Free
+ Other
+
+ Thumbnails
+ saving
+ out_of_memory_error
+ none
+ file_saved
+ error
+ simple_commons
+ value_copied_to_clipboard_show
+ default_color
+ unknown_error_occurred
+ undo
+ change_color
+ resize
+ filter
+ could_not_create_file
+
+
diff --git a/src/main/res/drawable/stroke_background.xml b/src/main/res/drawable/stroke_background.xml
new file mode 100644
index 000000000..518fda83c
--- /dev/null
+++ b/src/main/res/drawable/stroke_background.xml
@@ -0,0 +1,8 @@
+
+
+ -
+
+
+
+
+
diff --git a/src/main/res/layout/activity_edit.xml b/src/main/res/layout/activity_edit.xml
new file mode 100644
index 000000000..6820cf901
--- /dev/null
+++ b/src/main/res/layout/activity_edit.xml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/res/layout/bottom_actions_aspect_ratio.xml b/src/main/res/layout/bottom_actions_aspect_ratio.xml
new file mode 100644
index 000000000..06acdfa71
--- /dev/null
+++ b/src/main/res/layout/bottom_actions_aspect_ratio.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/res/layout/bottom_editor_actions_filter.xml b/src/main/res/layout/bottom_editor_actions_filter.xml
new file mode 100644
index 000000000..77cffb69f
--- /dev/null
+++ b/src/main/res/layout/bottom_editor_actions_filter.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/res/layout/bottom_editor_crop_rotate_actions.xml b/src/main/res/layout/bottom_editor_crop_rotate_actions.xml
new file mode 100644
index 000000000..78dc5dace
--- /dev/null
+++ b/src/main/res/layout/bottom_editor_crop_rotate_actions.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/res/layout/bottom_editor_draw_actions.xml b/src/main/res/layout/bottom_editor_draw_actions.xml
new file mode 100644
index 000000000..6022b37f0
--- /dev/null
+++ b/src/main/res/layout/bottom_editor_draw_actions.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/res/layout/bottom_editor_primary_actions.xml b/src/main/res/layout/bottom_editor_primary_actions.xml
new file mode 100644
index 000000000..b0524d5af
--- /dev/null
+++ b/src/main/res/layout/bottom_editor_primary_actions.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/res/layout/dialog_color_picker.xml b/src/main/res/layout/dialog_color_picker.xml
new file mode 100644
index 000000000..754bdf832
--- /dev/null
+++ b/src/main/res/layout/dialog_color_picker.xml
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/res/layout/dialog_custom_aspect_ratio.xml b/src/main/res/layout/dialog_custom_aspect_ratio.xml
new file mode 100644
index 000000000..dd3db974c
--- /dev/null
+++ b/src/main/res/layout/dialog_custom_aspect_ratio.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/res/layout/dialog_other_aspect_ratio.xml b/src/main/res/layout/dialog_other_aspect_ratio.xml
new file mode 100644
index 000000000..127d7ae85
--- /dev/null
+++ b/src/main/res/layout/dialog_other_aspect_ratio.xml
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/res/layout/dialog_resize_image.xml b/src/main/res/layout/dialog_resize_image.xml
new file mode 100644
index 000000000..bbd7e7cc8
--- /dev/null
+++ b/src/main/res/layout/dialog_resize_image.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/res/layout/editor_filter_item.xml b/src/main/res/layout/editor_filter_item.xml
new file mode 100644
index 000000000..c76a9480a
--- /dev/null
+++ b/src/main/res/layout/editor_filter_item.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/res/menu/menu_done.xml b/src/main/res/menu/menu_done.xml
new file mode 100644
index 000000000..ed5bd32c7
--- /dev/null
+++ b/src/main/res/menu/menu_done.xml
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/src/main/res/values/colors.xml b/src/main/res/values/colors.xml
index a5a44dc27..41078a032 100644
--- a/src/main/res/values/colors.xml
+++ b/src/main/res/values/colors.xml
@@ -46,4 +46,7 @@
#ff2196f3
#aa82B1FF
+
+ #BB000000
+ #000000
\ No newline at end of file
diff --git a/src/main/res/values/dimens.xml b/src/main/res/values/dimens.xml
index baa9d4ea9..f422091fb 100644
--- a/src/main/res/values/dimens.xml
+++ b/src/main/res/values/dimens.xml
@@ -44,4 +44,34 @@
128dp
96dp
24dp
+
+ 26dp
+ 64dp
+ 48dp
+ 120dp
+ 172dp
+ 48dp
+ 76dp
+ 90dp
+ 98dp
+ 180dp
+ 40dp
+
+ 1dp
+ 2dp
+ 4dp
+ 6dp
+ 8dp
+ 12dp
+ 16dp
+
+ 8sp
+ 10sp
+ 12sp
+ 14sp
+ 15sp
+ 16sp
+ 18sp
+
+ 30dp
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index a6f6e5877..fcd6b4d64 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -1011,4 +1011,35 @@
Remove account from server
Could not delete account from server
+ Custom
+ Resize selection and save
+ Width
+ Height
+ Keep aspect ratio
+ Please enter a valid resolution
+ Editor
+ Basic Editor
+ Rotate
+ Invalid image path
+ Image editing failed
+ Unknown file location
+ Transform
+ Crop
+ Draw
+ Flip horizontally
+ Flip vertically
+ Free
+ Other
+ Thumbnails
+ saving
+ out_of_memory_error
+ file_saved
+ simple_commons
+ value_copied_to_clipboard_show
+ default_color
+ unknown_error_occurred
+ change_color
+ resize
+ filter
+ could_not_create_file
diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml
index dd43124f4..bbd780fee 100644
--- a/src/main/res/values/themes.xml
+++ b/src/main/res/values/themes.xml
@@ -376,9 +376,10 @@