androidkotlinandroid-roomtypeconverter

TransactionTooLargeException When Taking Photo


I'm having a problem in my app when I create an intent to launch the camera app to take a picture, my app crashes and I receive the following error:

2021-06-11 18:07:46.914 7506-7506/com.package.app E/JavaBinder: !!! FAILED BINDER TRANSACTION !!!  (parcel size = 14763232)

...

2021-06-11 18:07:49.567 7506-7506/com.package.app E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.package.app, PID: 7506
    java.lang.RuntimeException: android.os.TransactionTooLargeException: data parcel size 14763232 bytes
        at android.app.servertransaction.PendingTransactionActions$StopInfo.run(PendingTransactionActions.java:161)
        at android.os.Handler.handleCallback(Handler.java:883)
        at android.os.Handler.dispatchMessage(Handler.java:100)
        at android.os.Looper.loop(Looper.java:214)
        at android.app.ActivityThread.main(ActivityThread.java:7356)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)
     Caused by: android.os.TransactionTooLargeException: data parcel size 14763232 bytes
        at android.os.BinderProxy.transactNative(Native Method)
        at android.os.BinderProxy.transact(BinderProxy.java:510)
        at android.app.IActivityTaskManager$Stub$Proxy.activityStopped(IActivityTaskManager.java:4524)
        at android.app.servertransaction.PendingTransactionActions$StopInfo.run(PendingTransactionActions.java:145)
        at android.os.Handler.handleCallback(Handler.java:883) 
        at android.os.Handler.dispatchMessage(Handler.java:100) 
        at android.os.Looper.loop(Looper.java:214) 
        at android.app.ActivityThread.main(ActivityThread.java:7356) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930) 

The photos, after they are taken in the camera app and returned to my app, are saved in a Room database. Interestingly, the problem only happens if there is a photo already saved in the row of the database that I'm trying to add/replace a photo. When creating a new row or taking a photo in a row which has no picture, I'm able to take the photo and save it into my database without any problem.

My Room database has a TypeConverter which converts bitmaps into base64 string for storage in the database and back to a bitmap when required to view it. After playing with the code for a while, I tried removing the Converter from the database and implementing it's functions into my viewmodel and fragment. The app works now regardless of whether there is a picture being replaced or not.

I now suspect something to be wrong with how I implemented the Converter but I'm not sure what it could be. Please look at my code below.

Fragment

lateinit var currentPhotoPath: String


@AndroidEntryPoint
class Fragment : Fragment(R.layout.fragment) {


    private val viewModel: ViewModel by viewModels()
    private var _binding: FragmentBinding? = null



    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.apply {
            ivPicture.setImageBitmap(viewModel.entryPictures)

            fab.setOnClickListener {
                viewModel.onSaveClick()
            }
        }


        viewLifecycleOwner.lifecycleScope.launchWhenStarted {
            viewModel.event.collect { event ->
                when (event) {
                    ViewModel.Event.NavigateToPhotoActivity -> {
                        dispatchTakePictureIntent()
                    }
                }
            }
        }

        setHasOptionsMenu(true)

    }

    private val REQUEST_IMAGE_CAPTURE = 23

    private fun dispatchTakePictureIntent() {

        Intent(MediaStore.ACTION_IMAGE_CAPTURE).also { takePictureIntent ->
            val packageManager = requireContext().packageManager
            takePictureIntent.resolveActivity(packageManager)?.also {
                val photoFile: File? = try {
                    createImageFile()
                } catch (ex: IOException) {
                    Toast.makeText(activity, "Error Creating File", Toast.LENGTH_LONG).show()
                    null
                }
                photoFile?.also {
                    val photoURI: Uri = FileProvider.getUriForFile(
                        requireContext(),
                        "com.package.app.fileprovider",
                        it
                    )
                    takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI)
                    requireActivity().startActivityFromFragment(this, takePictureIntent, REQUEST_IMAGE_CAPTURE)
                }
            }
        }
    }

    @Throws(IOException::class)
    private fun createImageFile(): File {
        val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date())
        val storageDir: File? = context?.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
        return File.createTempFile(
            "JPEG_${timeStamp}_", /* prefix */
            ".jpg", /* suffix */
            storageDir /* directory */
        ).apply {
            // Save a file: path for use with ACTION_VIEW intents
            currentPhotoPath = absolutePath
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode == REQUEST_IMAGE_CAPTURE && resultCode == Activity.RESULT_OK) {

            lifecycleScope.launch {

                val takenImage = BitmapFactory.decodeFile(currentPhotoPath)

                viewModel.onPhotoRetrieved(takenImage)

                binding.ivPicture.apply {
                    visibility = View.VISIBLE
                    setImageBitmap(takenImage)
                }
            }

        } else {
            Toast.makeText(activity, "Error Retrieving Image", Toast.LENGTH_LONG).show()
        }
    }

    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
        inflater.inflate(R.menu.menu_fragment, menu)
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return when (item.itemId) {
            R.id.icon_photo -> {
                viewModel.onTakePhotoSelected()
                true
            }
            else -> super.onOptionsItemSelected(item)
        }
    }


    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

ViewModel:

@HiltViewModel
class ViewModel @Inject constructor(
    private val dao: EntryDao,
    private val state: SavedStateHandle
) : ViewModel() {


    val entry = state.get<Entry>("entry")

    var entryPictures = entry?.pictures

    private val eventChannel = Channel<Event>()
    val event = eventChannel.receiveAsFlow()


    fun onSaveClick() {
        if (entry != null) {
            val updatedEntry = entry.copy(
                pictures = entryPictures
            )
            updatedEntry(updatedEntry)
        } else {
            val newEntry = Entry(
                pictures = entryPictures
            )

            createEntry(newEntry)
        }
    }

    private fun createEntry(entry: Entry) = viewModelScope.launch {
        dao.insert(entry)
    }

    private fun updatedEntry(entry: Entry) = viewModelScope.launch {
        dao.update(entry)
    }

    fun onTakePhotoSelected() = viewModelScope.launch {

        eventChannel.send(Event.NavigateToPhotoActivity)
    }

    fun onPhotoRetrieved(bitmap: Bitmap) = viewModelScope.launch {
        entryPictures = bitmap

    }


    sealed class Event {
        object NavigateToPhotoActivity : Event()
    }
}

Database:

@Database(entities = [Entry::class], version = 1)
@TypeConverters(Converters::class)

abstract class Database : RoomDatabase() {

    abstract fun entryDao(): EntryDao

    class Callback @Inject constructor(
        private val database: Provider<com.mayuram.ascend.data.Database>,
        @ApplicationScope private val applicationScope: CoroutineScope
    ) : RoomDatabase.Callback() {

        override fun onCreate(db: SupportSQLiteDatabase) {
            super.onCreate(db)

            val dao = database.get().entryDao()

            applicationScope.launch {
                dao.insert(Entry(null))
                dao.insert(Entry(null))
                dao.insert(Entry(null))
                dao.insert(Entry(null))
            }
        }
    }
}

Converter


class Converters {

    @Suppress("DEPRECATION")
    @TypeConverter
    fun bitmapToString(bitmap: Bitmap?): String {

        val outputStream = ByteArrayOutputStream()

        if (android.os.Build.VERSION.SDK_INT >= 30) {
            bitmap?.compress(Bitmap.CompressFormat.WEBP_LOSSY, 50, outputStream)
        } else {
            bitmap?.compress(Bitmap.CompressFormat.WEBP, 50, outputStream)
        }
        val imageBytes: ByteArray = outputStream.toByteArray()

        return Base64.encodeToString(imageBytes, Base64.DEFAULT)
    }

    @TypeConverter
    fun stringToBitmap(string: String): Bitmap? {
        val imageBytes: ByteArray = Base64.decode(string, Base64.DEFAULT)

        return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)

    }
}

DataClass:

@Entity(tableName = "entry_table")
@Parcelize
data class Entry(
    val pictures: Bitmap?,
    @PrimaryKey(autoGenerate = true) val id: Int = 0
) : Parcelable

Changes I made to get it to work:

In ViewModel, modified onPhotoRetrieved function to convert image to string

    fun onPhotoRetrieved(bitmap: Bitmap) = viewModelScope.launch {

        val outputStream = ByteArrayOutputStream()

        if (android.os.Build.VERSION.SDK_INT >= 30) {
            bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, 50, outputStream)
        } else {
            bitmap.compress(Bitmap.CompressFormat.WEBP, 50, outputStream)
        }
        val imageBytes: ByteArray = outputStream.toByteArray()
        val result = Base64.encodeToString(imageBytes, Base64.DEFAULT)

        entryPictures = result

    }

In fragment, added the convert string to bitmap function in onViewCreated

val imageBytes: ByteArray = Base64.decode(viewModel.entryPictures.toString(), Base64.DEFAULT)
val result = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
ivPicture.setImageBitmap(result)

Also changed type of val pictures to String? instead of Bitmap? in my data class and commented out @TypeConverters in my Database.


Solution

  • Fix

    In short you need to reconsider saving images in the database and consider storing a path to the image (or suitable part thereof, to uniquely identify the image) with the actual image being store at a suitable location.

    An alternative, but perhaps still quite costly resource wise. Could be to consider storing manageable chunks of images (perhaps consider 100k chunks). e.g. How to use images in Android SQLite that are larger than the limitations of a CursorWindow?

    Another alternative could be to store smaller images (if any consider they are photos) in the database but larger images as paths. e.g. How can I insert image in a sqlite database

    The Issue

    What you have come across is a precursor (pun intended :)) to other problems with the size of images.

    That is you have exceeded the 1Mb limit of a Parcel as explained by TransactionTooLargeException which includes :-

    The Binder transaction buffer has a limited fixed size, currently 1MB, which is shared by all transactions in progress for the process. Consequently this exception can be thrown when there are many transactions in progress even when most of the individual transactions are of moderate size.

    Your parcel(image) appears to be 14763232 i.e. 14Mb.

    Even if you increased the parcel Size you could then hit the Android SQLite implementation and therefore Room issues with Cursor size limitations and or the inefficiencies of the reduced number of rows in a Cursor.

    When creating a new row or taking a photo in a row which has no picture, I'm able to take the photo and save it into my database without any problem.

    When inserting the limitations aren't there as you insert on an individual basis. The limitations are when extracting the data as typically/often groups of data are extracted in a single request and an intermediate buffer is used (i.e. a Cursor is a buffer).