From 6a8ede0916ca5a88c908c9d7301f4523bde436d7 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 1 Aug 2024 16:16:39 +0530 Subject: [PATCH] enhancement: add drawing option to multimedia fragment * refactor: compress camera clicked pictures * refactor: add camera permission check --- .../main/java/com/ichi2/anki/NoteEditor.kt | 45 +++- .../anki/multimedia/AudioVideoFragment.kt | 2 + .../anki/multimedia/MultimediaActivity.kt | 3 +- .../anki/multimedia/MultimediaFragment.kt | 32 +++ .../multimedia/MultimediaImageFragment.kt | 243 +++++++++++++++--- .../ichi2/anki/multimedia/MultimediaUtils.kt | 14 + 6 files changed, 298 insertions(+), 41 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt index 62355579f8fc..3192577e8639 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt @@ -97,6 +97,7 @@ import com.ichi2.anki.multimedia.MultimediaBottomSheet import com.ichi2.anki.multimedia.MultimediaImageFragment import com.ichi2.anki.multimedia.MultimediaUtils.createImageFile import com.ichi2.anki.multimedia.MultimediaViewModel +import com.ichi2.anki.multimediacard.IMultimediaEditableNote import com.ichi2.anki.multimediacard.fields.AudioRecordingField import com.ichi2.anki.multimediacard.fields.EFieldType import com.ichi2.anki.multimediacard.fields.IField @@ -503,7 +504,12 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su Timber.w("Note is null, returning") return } - // TODO: start the MultimediaImageFragment with the image intent + openMultimediaImageFragment( + fieldIndex = 0, + field = ImageField(), + multimediaNote = note, + imageUri = imageUri + ) } override fun onSaveInstanceState(outState: Bundle) { @@ -1695,13 +1701,7 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su Timber.i("Selected Image option") val field = ImageField() note.setField(fieldIndex, field) - val imageIntent = MultimediaImageFragment.getIntent( - requireContext(), - MultimediaActivityExtra(fieldIndex, field, note), - MultimediaImageFragment.ImageOptions.GALLERY - ) - - multimediaFragmentLauncher.launch(imageIntent) + openMultimediaImageFragment(fieldIndex = fieldIndex, field, note) } MultimediaBottomSheet.MultimediaAction.SELECT_AUDIO_FILE -> { @@ -1718,7 +1718,17 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su } MultimediaBottomSheet.MultimediaAction.OPEN_DRAWING -> { - // TODO("Not yet implemented") + Timber.i("Selected Drawing option") + val field = ImageField() + note.setField(fieldIndex, field) + + val drawingIntent = MultimediaImageFragment.getIntent( + requireContext(), + MultimediaActivityExtra(fieldIndex, field, note), + MultimediaImageFragment.ImageOptions.DRAWING + ) + + multimediaFragmentLauncher.launch(drawingIntent) } MultimediaBottomSheet.MultimediaAction.SELECT_AUDIO_RECORDING -> { @@ -1765,6 +1775,23 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su } } + private fun openMultimediaImageFragment( + fieldIndex: Int, + field: IField, + multimediaNote: IMultimediaEditableNote, + imageUri: Uri? = null + ) { + val multimediaExtra = MultimediaActivityExtra(fieldIndex, field, multimediaNote, imageUri?.toString()) + + val imageIntent = MultimediaImageFragment.getIntent( + requireContext(), + multimediaExtra, + MultimediaImageFragment.ImageOptions.GALLERY + ) + + multimediaFragmentLauncher.launch(imageIntent) + } + private fun handleMultimediaResult(extras: Bundle) { val index = extras.getInt(MULTIMEDIA_RESULT_FIELD_INDEX) val field = extras.getSerializableCompat(MULTIMEDIA_RESULT) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.kt index 13c110052914..a089934b8f59 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/AudioVideoFragment.kt @@ -44,6 +44,7 @@ import com.ichi2.anki.multimedia.MultimediaActivity.Companion.MULTIMEDIA_RESULT import com.ichi2.anki.multimedia.MultimediaActivity.Companion.MULTIMEDIA_RESULT_FIELD_INDEX import com.ichi2.anki.multimedia.MultimediaUtils.createCachedFile import com.ichi2.anki.utils.ext.sharedPrefs +import com.ichi2.annotations.NeedsTest import com.ichi2.compat.CompatHelper import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.utils.ExceptionUtil.executeSafe @@ -87,6 +88,7 @@ class AudioVideoFragment : MultimediaFragment(R.layout.fragment_audio_video) { * Lazily initialized instance of MultimediaMenu. * The instance is created only when first accessed. */ + @NeedsTest("The menu drawable icon shoule be correctly set") private val multimediaMenu by lazy { MultimediaMenuProvider( menuResId = R.menu.multimedia_menu, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt index e9292c19923e..a2259980fbed 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaActivity.kt @@ -49,7 +49,8 @@ import kotlin.reflect.jvm.jvmName data class MultimediaActivityExtra( val index: Int, val field: IField, - val note: IMultimediaEditableNote + val note: IMultimediaEditableNote, + val imageUri: String? = null ) : Serializable /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt index 561ad3e65959..e86f4e5583f4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaFragment.kt @@ -17,6 +17,8 @@ package com.ichi2.anki.multimedia +import android.net.Uri +import android.os.Build import android.os.Bundle import android.text.format.Formatter import android.view.MenuItem @@ -25,10 +27,12 @@ import androidx.annotation.DrawableRes import androidx.annotation.LayoutRes import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import androidx.core.view.MenuHost import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import com.ichi2.anki.AnkiActivity +import com.ichi2.anki.CrashReportService import com.ichi2.anki.R import com.ichi2.anki.multimediacard.IMultimediaEditableNote import com.ichi2.anki.multimediacard.fields.IField @@ -56,6 +60,7 @@ abstract class MultimediaFragment(@LayoutRes layout: Int) : Fragment(layout) { protected var indexValue: Int = 0 protected lateinit var field: IField protected lateinit var note: IMultimediaEditableNote + protected var imageUri: Uri? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -70,10 +75,37 @@ abstract class MultimediaFragment(@LayoutRes layout: Int) : Fragment(layout) { indexValue = multimediaActivityExtra.index field = multimediaActivityExtra.field note = multimediaActivityExtra.note + if (multimediaActivityExtra.imageUri != null) { + imageUri = Uri.parse(multimediaActivityExtra.imageUri) + } } } } + /** + * Get Uri based on current image path + * + * @param file the file to get URI for + * @return current image path's uri + */ + fun getUriForFile(file: File): Uri { + Timber.d("getUriForFile() %s", file) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return FileProvider.getUriForFile( + requireActivity(), + requireActivity().applicationContext.packageName + ".apkgfileprovider", + file + ) + } + } catch (e: Exception) { + // #6628 - What would cause this? Is the fallback is effective? Telemetry to diagnose more: + Timber.w(e, "getUriForFile failed on %s - attempting fallback", file) + CrashReportService.sendExceptionReport(e, "MultimediaFragment", "Unexpected getUriForFile failure on $file", true) + } + return Uri.fromFile(file) + } + fun setMenuItemIcon(menuItem: MenuItem, @DrawableRes icon: Int) { menuItem.icon = ContextCompat.getDrawable(requireContext(), icon) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt index 672dcd562d1b..c43453dfe8ff 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt @@ -17,9 +17,11 @@ package com.ichi2.anki.multimedia +import android.Manifest import android.app.Activity import android.content.Context import android.content.Intent +import android.graphics.Bitmap import android.net.Uri import android.os.Bundle import android.provider.MediaStore @@ -30,23 +32,30 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.FileProvider +import androidx.core.os.BundleCompat import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import com.canhub.cropper.CropException import com.google.android.material.button.MaterialButton import com.ichi2.anki.CrashReportService +import com.ichi2.anki.DrawingActivity import com.ichi2.anki.R import com.ichi2.anki.multimedia.MultimediaActivity.Companion.EXTRA_MEDIA_OPTIONS import com.ichi2.anki.multimedia.MultimediaActivity.Companion.MULTIMEDIA_RESULT import com.ichi2.anki.multimedia.MultimediaActivity.Companion.MULTIMEDIA_RESULT_FIELD_INDEX import com.ichi2.anki.multimedia.MultimediaUtils.IMAGE_LIMIT +import com.ichi2.anki.multimedia.MultimediaUtils.IMAGE_SAVE_MAX_WIDTH import com.ichi2.anki.multimedia.MultimediaUtils.createCachedFile import com.ichi2.anki.multimedia.MultimediaUtils.createImageFile +import com.ichi2.anki.multimedia.MultimediaUtils.createNewCacheImageFile import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.annotations.NeedsTest import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat +import com.ichi2.utils.BitmapUtil +import com.ichi2.utils.ExifUtil import com.ichi2.utils.FileUtil import com.ichi2.utils.ImageUtils +import com.ichi2.utils.Permissions import com.ichi2.utils.message import com.ichi2.utils.negativeButton import com.ichi2.utils.positiveButton @@ -55,6 +64,8 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import timber.log.Timber import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream import java.io.IOException import java.text.DecimalFormat @@ -89,7 +100,41 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ Activity.RESULT_OK -> { view?.findViewById(R.id.no_image_textview)?.visibility = View.GONE - handleSelectImageIntent(result.data) + val data = result.data + if (data == null) { + Timber.w("handleSelectImageIntent() no intent provided") + showSomethingWentWrong() + return@registerForActivityResult + } + + val selectedImage = getImageUri(data) + handleSelectImageIntent(selectedImage) + } + } + } + + /** + * Launches the [DrawingActivity] and handles the result by adding the drawing as image. + */ + private val drawingActivityLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + when (result.resultCode) { + Activity.RESULT_CANCELED -> { + // If user didn't draw, return the indexValue as a result and finish the activity + if (viewModel.currentMultimediaUri.value == null) { + val resultData = Intent().apply { + putExtra(MULTIMEDIA_RESULT_FIELD_INDEX, indexValue) + } + requireActivity().setResult(AppCompatActivity.RESULT_CANCELED, resultData) + requireActivity().finish() + } + } + + Activity.RESULT_OK -> { + view?.findViewById(R.id.no_image_textview)?.visibility = View.GONE + val intent = result.data ?: return@registerForActivityResult + Timber.d("Intent not null, handling the result") + handleDrawingResult(intent) } } } @@ -162,6 +207,10 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ ) dispatchCamera() } + + ImageOptions.DRAWING -> { + openDrawingCanvas() + } } true } @@ -175,8 +224,8 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ super.onCreate(savedInstanceState) ankiCacheDirectory = FileUtil.getAnkiCacheDirectory(requireContext(), "temp-photos") if (ankiCacheDirectory == null) { - showErrorDialog() Timber.e("createUI() failed to get cache directory") + showErrorDialog(errorMessage = resources.getString(R.string.multimedia_editor_failed)) return } @@ -190,10 +239,47 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ setupMenu(multimediaMenu) imagePreview = view.findViewById(R.id.image_preview) imageFileSize = view.findViewById(R.id.image_size_textview) - handleSelectedImageOptions() + + if (selectedImageOptions == ImageOptions.CAMERA) { + if (!hasCameraPermission()) { + return + } + } + + handleImageUri() setupDoneButton() } + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + Timber.d("Camera permission granted") + handleSelectedImageOptions() + } else { + Timber.d("Camera permission denied") + showErrorDialog(resources.getString(R.string.multimedia_editor_camera_permission_refused)) + } + } + + private fun hasCameraPermission(): Boolean { + if (!Permissions.canRecordAudio(requireContext())) { + Timber.i("Requesting Audio Permissions") + requestPermissionLauncher.launch(Manifest.permission.CAMERA) + return false + } + return true + } + + private fun handleImageUri() { + if (imageUri != null) { + view?.findViewById(R.id.no_image_textview)?.visibility = View.GONE + handleSelectImageIntent(imageUri) + } else { + handleSelectedImageOptions() + } + } + private fun handleSelectedImageOptions() { when (selectedImageOptions) { ImageOptions.GALLERY -> { @@ -204,6 +290,11 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ dispatchCamera() Timber.d("MultimediaImageFragment:: Launching camera") } + + ImageOptions.DRAWING -> { + Timber.d("MultimediaImageFragment:: Opening drawing canvas") + openDrawingCanvas() + } } } @@ -236,6 +327,10 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ pickImageLauncher.launch(intent) } + private fun openDrawingCanvas() { + drawingActivityLauncher.launch(Intent(requireContext(), DrawingActivity::class.java)) + } + private fun dispatchCamera() { val photoFile: File? = try { requireContext().createImageFile() @@ -255,6 +350,39 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ } } + private fun handleDrawingResult(intent: Intent) { + val imageUri = BundleCompat.getParcelable( + intent.extras!!, + DrawingActivity.EXTRA_RESULT_WHITEBOARD, + Uri::class.java + ) + + if (imageUri == null) { + Timber.w("handleDrawingResult() no image Uri provided") + showSomethingWentWrong() + return + } + + val internalizedPick = internalizeUri(imageUri) + + if (internalizedPick == null) { + Timber.w( + "handleSelectImageIntent() unable to internalize image from Uri %s", + imageUri + ) + showSomethingWentWrong() + return + } + + val drawImagePath = internalizedPick.absolutePath + Timber.i("handleDrawingResult() Decoded image: '%s'", drawImagePath) + + imagePreview.setImageURI(imageUri) + viewModel.updateCurrentMultimediaPath(drawImagePath) + viewModel.updateCurrentMultimediaUri(imageUri) + updateAndDisplayImageSize(drawImagePath) + } + private fun handleTakePictureResult(imagePath: String?) { Timber.d("handleTakePictureResult") if (imagePath == null) { @@ -263,14 +391,12 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ } updateAndDisplayImageSize(imagePath) - val photoFile = File(imagePath) - val imageUri: Uri = FileProvider.getUriForFile( - requireContext(), - requireActivity().applicationContext.packageName + ".apkgfileprovider", - photoFile - ) - viewModel.updateCurrentMultimediaUri(imageUri) - imagePreview.setImageURI(imageUri) + + if (!rotateAndCompress(imagePath)) { + Timber.d("Unable to compress the clicked image") + showErrorDialog(errorMessage = resources.getString(R.string.multimedia_editor_image_compression_failed)) + return + } showCropDialog(getString(R.string.crop_image)) } @@ -303,16 +429,8 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ } } - private fun handleSelectImageIntent(data: Intent?) { - if (data == null) { - Timber.e("handleSelectImageIntent() no intent provided") - showSomethingWentWrong() - return - } - - val selectedImage = getImageUri(data) - - val mimeType = selectedImage?.let { context?.contentResolver?.getType(it) } + private fun handleSelectImageIntent(imageUri: Uri?) { + val mimeType = imageUri?.let { context?.contentResolver?.getType(it) } if (mimeType == "image/svg+xml") { Timber.i("Selected image is an SVG.") view?.findViewById(R.id.no_image_textview)?.apply { @@ -327,20 +445,17 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ } } - imagePreview.setImageURI(selectedImage) - viewModel.updateCurrentMultimediaUri(selectedImage) - - if (selectedImage == null) { + if (imageUri == null) { Timber.w("handleSelectImageIntent() selectedImage was null") showSomethingWentWrong() return } - val internalizedPick = internalizeUri(selectedImage) + val internalizedPick = internalizeUri(imageUri) if (internalizedPick == null) { Timber.w( "handleSelectImageIntent() unable to internalize image from Uri %s", - selectedImage + imageUri ) showSomethingWentWrong() return @@ -348,8 +463,11 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ val imagePath = internalizedPick.absolutePath - viewModel.updateCurrentMultimediaPath(imagePath) - updateAndDisplayImageSize(imagePath) + if (!rotateAndCompress(imagePath)) { + Timber.d("Unable to compress the clicked image") + showErrorDialog(errorMessage = resources.getString(R.string.multimedia_editor_image_compression_failed)) + return + } } private fun requestCrop() { @@ -371,7 +489,7 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ } else { // cropImage can give us more information. Not sure it is actionable so for now just log it. val error: String = - cropResult.error?.toString() ?: "Error info not available" + cropResult.error?.toString() ?: resources.getString(R.string.activity_result_unexpected) Timber.w(error, "cropImage threw an error") // condition can be removed if #12768 get fixed by Canhub if (cropResult.error is CropException.Cancellation) { @@ -386,6 +504,68 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ } } + /** + * Rotate and compress the image, with the side effect of the current image being backed by a new file + * + * @return true if successful, false indicates the current image is likely not usable, revert if possible + */ + private fun rotateAndCompress(imagePath: String): Boolean { + Timber.d("rotateAndCompress() on %s", imagePath) + + // Set the rotation of the camera image and save as JPEG + val imageFile = File(imagePath) + Timber.d("rotateAndCompress in path %s has size %d", imageFile.absolutePath, imageFile.length()) + + // Load into a bitmap with max size of 1920 pixels and rotate if necessary + var bitmap = BitmapUtil.decodeFile(imageFile, IMAGE_SAVE_MAX_WIDTH) + if (bitmap == null) { + Timber.w("rotateAndCompress() unable to decode file %s", imagePath) + return false + } + + var out: FileOutputStream? = null + try { + val outFile = createNewCacheImageFile(directory = ankiCacheDirectory) + out = FileOutputStream(outFile) + + // Rotate the bitmap if needed + bitmap = ExifUtil.rotateFromCamera(imageFile, bitmap) + + // Compress the bitmap to JPEG format with 90% quality + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + + // Delete the original image file + if (!imageFile.delete()) { + Timber.w("rotateAndCompress() delete of pre-compressed image failed %s", imagePath) + } + + val imageUri = getUriForFile(outFile) + + // TODO: see if we can use one value to the viewModel + viewModel.updateCurrentMultimediaUri(imageUri) + viewModel.updateCurrentMultimediaPath(outFile.path) + imagePreview.setImageURI(imageUri) + viewModel.selectedMediaFileSize = outFile.length() + updateAndDisplayImageSize(outFile.path) + + Timber.d("rotateAndCompress out path %s has size %d", outFile.absolutePath, outFile.length()) + } catch (e: FileNotFoundException) { + Timber.w(e, "rotateAndCompress() File not found for image compression %s", imagePath) + return false + } catch (e: IOException) { + Timber.w(e, "rotateAndCompress() create file failed for file %s", imagePath) + return false + } finally { + try { + out?.close() + } catch (e: IOException) { + Timber.w(e, "rotateAndCompress() Unable to clean up image compression output stream") + } + } + + return true + } + private fun internalizeUri(uri: Uri): File? { val internalFile: File val uriFileName = MultimediaUtils.getImageNameFromUri(requireContext(), uri) @@ -443,6 +623,7 @@ class MultimediaImageFragment : MultimediaFragment(R.layout.fragment_multimedia_ /** Image options that a user choose from the bottom sheet which [MultimediaImageFragment] uses **/ enum class ImageOptions { GALLERY, - CAMERA + CAMERA, + DRAWING } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaUtils.kt index c3345f7d7f0b..38a664f01ed4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaUtils.kt @@ -33,6 +33,20 @@ import java.io.File import java.io.IOException object MultimediaUtils { + /** + * Creates a new temporary image file in the specified cache directory. + * + * @param extension The desired file extension (default: "jpg"). + * @return The newly created image file. + * @throws IOException If an error occurs while creating the file. + */ + @Throws(IOException::class) + fun createNewCacheImageFile(extension: String = "jpg", directory: String?): File { + val storageDir = File(directory!!) + return File.createTempFile("img", ".$extension", storageDir) + } + + const val IMAGE_SAVE_MAX_WIDTH = 1920 /** * https://cs.android.com/android/platform/superproject/+/master:packages/providers/DownloadProvider/src/com/android/providers/downloads/MediaStoreDownloadsHelper.java;l=24