Search code examples
androidencryptionaesexoplayer

Android - How to play AES/GCM/NoPadding encrypted Video with ExoPlayer?


I AES/GCM/NoPadding encrypted videos inside internal app storage and I want to play them using ExoPlayer.

Nothing really worked for me. I have tried:

  • AesCipherDataSource with FileDataSource -> No Extractor can read the stream
  • Custom DataSources -> Invalid NAL length

And these in various versions.

Am I missing something?


Here is some of the code:

My PlayerSetup

    fun setupPlayer(photoId: Int) = viewModelScope.launch(Dispatchers.IO) {
        val photo = photoRepository.get(photoId)

        player = SimpleExoPlayer.Builder(app)
            .setMediaSourceFactory(createMediaSourceFactory())
            .build()
        player!!.apply {
            onMain {
                setMediaItem(createMediaItem(photo))
                prepare()
                playWhenReady = true
            }
        }
    }

    private fun createMediaSourceFactory(): MediaSourceFactory {
        val aesDataSource = AesCipherDataSource(encryptionManager.encodedKey, FileDataSource())

        val factory = DataSource.Factory {
            aesDataSource
        }

        return ProgressiveMediaSource.Factory(factory)
    }

    private fun createMediaItem(photo: Photo): MediaItem {
        val uri = Uri.fromFile(app.getFileStreamPath(photo.internalFileName).canonicalFile)

        return MediaItem.Builder()
            .setMimeType(photo.type.mimeType)
            .setUri(uri)
            .build()
    }

And my custom DataSource try (not used in code above):

class AesGCMDataSource(
    private val upstream: DataSource,
    private val encryptionManager: EncryptionManager
) : DataSource {

    private var cipherInputStream: CipherInputStream? = null

    override fun addTransferListener(transferListener: TransferListener) {
        upstream.addTransferListener(transferListener)
    }

    override fun open(dataSpec: DataSpec): Long {
        val inputStream = DataSourceInputStream(upstream, dataSpec)
        cipherInputStream = encryptionManager.createCipherInputStream(inputStream)

        inputStream.open()
        return C.LENGTH_UNSET.toLong()
    }

    override fun read(target: ByteArray, offset: Int, length: Int): Int {
        Assertions.checkNotNull<Any>(cipherInputStream)

        val read = cipherInputStream!!.read(target, offset, length)

        return if (read < 0) {
            C.RESULT_END_OF_INPUT
        } else {
            read
        }
    }

    override fun getResponseHeaders(): MutableMap<String, MutableList<String>> {
        return upstream.responseHeaders
    }

    override fun getUri(): Uri? = upstream.uri

    override fun close() = upstream.close()
}

Solution

  • I found a solution for this:

    Create this DataSource:

    class AesDataSource(
        private val cipher: Cipher
    ) : DataSource {
    
        private var inputStream: CipherInputStream? = null
        private lateinit var uri: Uri
    
        override fun open(dataSpec: DataSpec): Long {
            uri = dataSpec.uri
            uri.path ?: return 0
    
            val file = File(uri.path!!).canonicalFile
            inputStream = CipherInputStream(file.inputStream(), cipher)
            if (dataSpec.position != 0L) {
                inputStream?.forceSkip(dataSpec.position) // Needed for skipping
            }
    
            return dataSpec.length
        }
    
        @Throws(IOException::class)
        override fun read(target: ByteArray, offset: Int, length: Int): Int =
            if (length == 0) {
                0
            } else {
                inputStream?.read(target, offset, length) ?: 0
            }
    
        override fun addTransferListener(transferListener: TransferListener) {}
    
        override fun getUri(): Uri = uri
    
        override fun close() {
            inputStream?.close()
        }
    }
    

    The DataSource uses this Extension function:

    /**
     * Skip bytes by reading them to a specific point.
     * This is needed in GCM because the Authorisation Tag wont match when bytes are really skipped.
     */
    fun CipherInputStream.forceSkip(bytesToSkip: Long): Long {
        var processedBytes = 0L
        while (processedBytes < bytesToSkip) {
            read()
            processedBytes++
        }
    
        return processedBytes
    }
    

    Use it like this:

    fun setupPlayer(file: Uri) {
            player = SimpleExoPlayer.Builder(app)
                .setMediaSourceFactory(createMediaSourceFactory())
                .build()
                .apply {
                    setMediaItem(createMediaItem(file))
                    prepare()
                    playWhenReady = true
                }
    }
    
    private fun createMediaSourceFactory(): MediaSourceFactory {
        val aesDataSource = AesDataSource(yourCipher) // Use your Cipher instance
    
        val factory = DataSource.Factory {
            aesDataSource
        }
    
        return ProgressiveMediaSource.Factory(factory)
    }
    
    private fun createMediaItem(file: Uri): MediaItem {
        return MediaItem.fromUri(uri)
    }