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 @@