diff --git a/android/app/src/main/java/net/pengcook/android/presentation/core/util/ImageUtils.kt b/android/app/src/main/java/net/pengcook/android/presentation/core/util/ImageUtils.kt index 242b4ee0..e486621e 100644 --- a/android/app/src/main/java/net/pengcook/android/presentation/core/util/ImageUtils.kt +++ b/android/app/src/main/java/net/pengcook/android/presentation/core/util/ImageUtils.kt @@ -2,68 +2,106 @@ package net.pengcook.android.presentation.core.util import android.content.Context import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.media.ExifInterface import android.net.Uri import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File -import java.io.IOException +import java.io.FileOutputStream import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -class ImageUtils(private val context: Context) { +class ImageUtils( + private val context: Context, +) { private fun createTempImageFile(): File { val timeStamp: String = - SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + SimpleDateFormat(DATA_FORMAT, Locale.getDefault()).format(Date()) val storageDir: File? = context.getExternalFilesDir(null) return File.createTempFile( "JPEG_${timeStamp}_", - ".jpg", + FILE_SUFFIX, storageDir, ) } fun createImageFile(): File { val timeStamp: String = - SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + SimpleDateFormat(DATA_FORMAT, Locale.getDefault()).format(Date()) val storageDir: File? = context.getExternalFilesDir(null) return File .createTempFile( "JPEG_${timeStamp}_", - ".jpg", + FILE_SUFFIX, storageDir, ) } - fun getUriForFile(file: File): Uri { - return FileProvider.getUriForFile( + fun getUriForFile(file: File): Uri = + FileProvider.getUriForFile( context, "net.pengcook.android.fileprovider", file, ) - } - fun processImageUri(uri: Uri): String? { - return try { + fun isPermissionGranted(permissions: Array): Boolean = + permissions.all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + + suspend fun compressAndResizeImage(uri: Uri): File = + withContext(Dispatchers.IO) { val inputStream = context.contentResolver.openInputStream(uri) - if (inputStream != null) { - val tempFile = createTempImageFile() - tempFile.outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - tempFile.absolutePath - } else { - null - } - } catch (e: IOException) { - e.printStackTrace() - null + val originalBitmap = BitmapFactory.decodeStream(inputStream) + + val resizedBitmap = + adjustImageOrientation( + Bitmap.createScaledBitmap( + originalBitmap, + MAX_WIDTH, + MAX_HEIGHT, + true, + ), + uri, + ) + + val compressedFile = createTempImageFile() + val outputStream = FileOutputStream(compressedFile) + resizedBitmap.compress(Bitmap.CompressFormat.JPEG, COMPRESSED_QUALITY, outputStream) + outputStream.flush() + outputStream.close() + + compressedFile } - } - fun isPermissionGranted(permissions: Array): Boolean { - return permissions.all { - ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + private fun adjustImageOrientation( + bitmap: Bitmap, + uri: Uri, + ): Bitmap { + val inputStream = context.contentResolver.openInputStream(uri) + val exif = ExifInterface(inputStream!!) + val orientation = + exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) } + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } + + companion object { + private const val COMPRESSED_QUALITY = 80 + private const val MAX_WIDTH = 1080 + private const val DATA_FORMAT = "yyyyMMdd_HHmmss" + private const val FILE_SUFFIX = ".jpg" + private const val MAX_HEIGHT = 1080 } } diff --git a/android/app/src/main/java/net/pengcook/android/presentation/making/RecipeMakingFragment.kt b/android/app/src/main/java/net/pengcook/android/presentation/making/RecipeMakingFragment.kt index bde767ed..1b3d28e0 100644 --- a/android/app/src/main/java/net/pengcook/android/presentation/making/RecipeMakingFragment.kt +++ b/android/app/src/main/java/net/pengcook/android/presentation/making/RecipeMakingFragment.kt @@ -9,11 +9,16 @@ import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import androidx.activity.result.contract.ActivityResultContracts +import androidx.datastore.core.IOException import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.pengcook.android.R import net.pengcook.android.databinding.FragmentRecipeMakingBinding import net.pengcook.android.presentation.core.util.AnalyticsLogging @@ -65,7 +70,7 @@ class RecipeMakingFragment : Fragment() { if (currentPhotoPath != null) { viewModel.fetchImageUri(File(currentPhotoPath!!).name) } else { - processImageUri(photoUri) + compressAndFetchPresignedUrl(photoUri) } } ?: run { showSnackBar(getString(R.string.image_selection_failed)) @@ -119,12 +124,19 @@ class RecipeMakingFragment : Fragment() { takePictureLauncher.launch(photoUri) } - private fun processImageUri(uri: Uri) { - currentPhotoPath = imageUtils.processImageUri(uri) - if (currentPhotoPath != null) { - viewModel.fetchImageUri(File(currentPhotoPath!!).name) - } else { - showSnackBar(getString(R.string.image_selection_failed)) + private fun compressAndFetchPresignedUrl(uri: Uri) { + viewLifecycleOwner.lifecycleScope.launch { + try { + val compressedFile = imageUtils.compressAndResizeImage(uri) + currentPhotoPath = compressedFile.absolutePath + + viewModel.fetchImageUri(File(currentPhotoPath!!).name) + } catch (e: IOException) { + e.printStackTrace() + withContext(Dispatchers.Main) { + showSnackBar(getString(R.string.image_selection_failed)) + } + } } } diff --git a/android/app/src/main/java/net/pengcook/android/presentation/making/step/StepMakingFragment.kt b/android/app/src/main/java/net/pengcook/android/presentation/making/step/StepMakingFragment.kt index 273a8de8..0146ebea 100644 --- a/android/app/src/main/java/net/pengcook/android/presentation/making/step/StepMakingFragment.kt +++ b/android/app/src/main/java/net/pengcook/android/presentation/making/step/StepMakingFragment.kt @@ -9,11 +9,16 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.datastore.core.IOException import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.pengcook.android.R import net.pengcook.android.databinding.FragmentMakingStepBinding import net.pengcook.android.presentation.core.util.AnalyticsLogging @@ -72,7 +77,7 @@ class StepMakingFragment : Fragment() { if (currentPhotoPath != null) { viewModel.fetchImageUri(File(currentPhotoPath!!).name) } else { - processImageUri(photoUri) + compressAndFetchPresignedUrl(photoUri) } } ?: run { showToast(getString(R.string.image_selection_failed)) @@ -181,12 +186,18 @@ class StepMakingFragment : Fragment() { viewModel.uploadImageToS3(presignedUrl, file) } - private fun processImageUri(uri: Uri) { - currentPhotoPath = imageUtils.processImageUri(uri) - if (currentPhotoPath != null) { - viewModel.fetchImageUri(File(currentPhotoPath!!).name) - } else { - showToast(getString(R.string.image_selection_failed)) + private fun compressAndFetchPresignedUrl(uri: Uri) { + viewLifecycleOwner.lifecycleScope.launch { + try { + val compressedFile = imageUtils.compressAndResizeImage(uri) + currentPhotoPath = compressedFile.absolutePath + viewModel.fetchImageUri(File(currentPhotoPath!!).name) + } catch (e: IOException) { + e.printStackTrace() + withContext(Dispatchers.Main) { + showToast(getString(R.string.image_selection_failed)) + } + } } } diff --git a/android/app/src/main/java/net/pengcook/android/presentation/setting/edit/EditProfileFragment.kt b/android/app/src/main/java/net/pengcook/android/presentation/setting/edit/EditProfileFragment.kt index bf1123d0..56ce6ccb 100644 --- a/android/app/src/main/java/net/pengcook/android/presentation/setting/edit/EditProfileFragment.kt +++ b/android/app/src/main/java/net/pengcook/android/presentation/setting/edit/EditProfileFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import androidx.activity.result.contract.ActivityResultContracts +import androidx.datastore.core.IOException import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -15,6 +16,7 @@ import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.pengcook.android.R import net.pengcook.android.databinding.FragmentEditProfileBinding import net.pengcook.android.presentation.core.util.AnalyticsLogging @@ -43,7 +45,7 @@ class EditProfileFragment : Fragment() { if (currentPhotoPath != null) { viewModel.fetchImageUri(File(currentPhotoPath!!).name) } else { - processImageUri(photoUri) + compressAndFetchPresignedUrl(photoUri) } } ?: run { showSnackBar(getString(R.string.image_selection_failed)) @@ -86,12 +88,19 @@ class EditProfileFragment : Fragment() { viewModel.uploadImageToS3(presignedUrl, file) } - private fun processImageUri(uri: Uri) { - currentPhotoPath = imageUtils.processImageUri(uri) - if (currentPhotoPath != null) { - viewModel.fetchImageUri(File(currentPhotoPath!!).name) - } else { - showSnackBar(getString(R.string.image_selection_failed)) + private fun compressAndFetchPresignedUrl(uri: Uri) { + viewLifecycleOwner.lifecycleScope.launch { + try { + val compressedFile = imageUtils.compressAndResizeImage(uri) + currentPhotoPath = compressedFile.absolutePath + + viewModel.fetchImageUri(File(currentPhotoPath!!).name) + } catch (e: IOException) { + e.printStackTrace() + withContext(Dispatchers.Main) { + showSnackBar(getString(R.string.image_selection_failed)) + } + } } } diff --git a/android/app/src/main/java/net/pengcook/android/presentation/signup/SignUpFragment.kt b/android/app/src/main/java/net/pengcook/android/presentation/signup/SignUpFragment.kt index 65ae44e8..0bcfc1da 100644 --- a/android/app/src/main/java/net/pengcook/android/presentation/signup/SignUpFragment.kt +++ b/android/app/src/main/java/net/pengcook/android/presentation/signup/SignUpFragment.kt @@ -20,6 +20,7 @@ import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.pengcook.android.R import net.pengcook.android.databinding.FragmentSignUpBinding import net.pengcook.android.presentation.core.util.AnalyticsLogging @@ -28,6 +29,7 @@ import net.pengcook.android.presentation.core.util.ImageUtils import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream +import java.io.IOException import javax.inject.Inject @AndroidEntryPoint @@ -57,7 +59,7 @@ class SignUpFragment : Fragment() { if (currentPhotoPath != null) { viewModel.fetchImageUri(File(currentPhotoPath!!).name) } else { - processImageUri(photoUri) + compressAndFetchPresignedUrl(photoUri) } } ?: run { showSnackBar(getString(R.string.image_selection_failed)) @@ -100,12 +102,19 @@ class SignUpFragment : Fragment() { viewModel.uploadImageToS3(presignedUrl, file) } - private fun processImageUri(uri: Uri) { - currentPhotoPath = imageUtils.processImageUri(uri) - if (currentPhotoPath != null) { - viewModel.fetchImageUri(File(currentPhotoPath!!).name) - } else { - showSnackBar(getString(R.string.image_selection_failed)) + private fun compressAndFetchPresignedUrl(uri: Uri) { + viewLifecycleOwner.lifecycleScope.launch { + try { + val compressedFile = imageUtils.compressAndResizeImage(uri) + currentPhotoPath = compressedFile.absolutePath + + viewModel.fetchImageUri(File(currentPhotoPath!!).name) + } catch (e: IOException) { + e.printStackTrace() + withContext(Dispatchers.Main) { + showSnackBar(getString(R.string.image_selection_failed)) + } + } } }