From 32b5864f6eee871c1ce65c377260397f20c37217 Mon Sep 17 00:00:00 2001 From: qnga <32197639+qnga@users.noreply.github.com> Date: Tue, 7 Jan 2025 16:18:23 +0100 Subject: [PATCH] Fix `LcpDecryptor` and `FileZipLicenseContainer` (#602) --- gradle/libs.versions.toml | 2 + readium/lcp/build.gradle.kts | 3 + .../java/org/readium/r2/lcp/LcpDecryptor.kt | 429 ++++++++++-------- .../container/FileZipLicenseContainer.kt | 19 +- .../org/readium/r2/lcp/LcpDecryptorTest.kt | 130 ++++++ .../readium/r2/testapp/reader/MediaService.kt | 2 +- 6 files changed, 383 insertions(+), 202 deletions(-) create mode 100644 readium/lcp/src/test/java/org/readium/r2/lcp/LcpDecryptorTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 611a710205..5ac1cba376 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ picasso = "2.8" pspdfkit = "8.4.1" robolectric = "4.12.2" +mockk = "1.13.14" timber = "5.0.1" @@ -147,6 +148,7 @@ picasso = { group = "com.squareup.picasso", name = "picasso", version.ref = "pic pspdfkit = { group = "com.pspdfkit", name = "pspdfkit", version.ref ="pspdfkit" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk"} timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } diff --git a/readium/lcp/build.gradle.kts b/readium/lcp/build.gradle.kts index 8a2aec53ef..ac4bbb2668 100644 --- a/readium/lcp/build.gradle.kts +++ b/readium/lcp/build.gradle.kts @@ -37,6 +37,9 @@ dependencies { // Tests testImplementation(libs.junit) testImplementation(libs.kotlin.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) androidTestImplementation(libs.androidx.ext.junit) androidTestImplementation(libs.androidx.expresso.core) diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptor.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptor.kt index 1ca727327b..d5d72910b6 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptor.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptor.kt @@ -59,239 +59,188 @@ internal class LcpDecryptor( encryption.isDeflated || !encryption.isCbcEncrypted -> FullLcpResource(resource, encryption, license) else -> - CbcLcpResource(resource, license) + CbcLcpResource(resource, license, encryption.originalLength) } } } +} - /** - * A LCP resource that is read, decrypted and cached fully before reading requested ranges. - * - * Can be used when it's impossible to map a read range (byte range request) to the encrypted - * resource, for example when the resource is deflated before encryption. - */ - private class FullLcpResource( - resource: Resource, - private val encryption: Encryption, - private val license: LcpLicense - ) : TransformingResource(resource) { - - override suspend fun transform(data: Try): Try = - license.decryptFully(data, encryption.isDeflated) - - override suspend fun length(): Try = - encryption.originalLength?.let { Try.success(it) } - ?: super.length() - } +/** + * A LCP resource that is read, decrypted and cached fully before reading requested ranges. + * + * Can be used when it's impossible to map a read range (byte range request) to the encrypted + * resource, for example when the resource is deflated before encryption. + */ +private class FullLcpResource( + resource: Resource, + private val encryption: Encryption, + private val license: LcpLicense +) : TransformingResource(resource) { + + override suspend fun transform(data: Try): Try = + license.decryptFully(data, encryption.isDeflated) + + override suspend fun length(): Try = + encryption.originalLength?.let { Try.success(it) } + ?: super.length() +} - /** - * A LCP resource used to read content encrypted with the CBC algorithm. - * - * Supports random access for byte range requests, but the resource MUST NOT be deflated. - */ - private class CbcLcpResource( - private val resource: Resource, - private val license: LcpLicense - ) : Resource by resource { - - private class Cache( - var startIndex: Int? = null, - val data: ByteArray = ByteArray(3 * AES_BLOCK_SIZE) - ) - - private lateinit var _length: Try - - /* - * Decryption needs to look around the data strictly matching the content to decipher. - * That means that in case of contiguous read requests, data fetched from the underlying - * resource are not contiguous. Every request to the underlying resource starts slightly - * before the end of the previous one. This is an issue with remote publications because - * you have to make a new HTTP request every time instead of reusing the previous one. - * To alleviate this, we cache the three last bytes read in each call and reuse them - * in the next call if possible. - */ - private val _cache: Cache = Cache() - - /** Plain text size. */ - override suspend fun length(): Try { - if (::_length.isInitialized) { - return _length - } +/** + * A LCP resource used to read content encrypted with the CBC algorithm. + * + * Supports random access for byte range requests, but the resource MUST NOT be deflated. + */ +internal class CbcLcpResource( + resource: Resource, + private val license: LcpLicense, + private val originalLength: Long? = null +) : Resource by resource { - // Unfortunately, encryption.originalLength is not reliable. - _length = lengthFromPadding() + private val resource = CachingRangeTailResource(resource, 4 * AES_BLOCK_SIZE) - return _length - } + private var builtinPaddingLength: Int? = null - private suspend fun lengthFromPadding(): Try { - val length = resource.length() - .getOrElse { return Try.failure(it) } + /** Plain text size. */ + override suspend fun length(): Try { + originalLength?.let { return Try.success(it) } - if (length < 2 * AES_BLOCK_SIZE) { - return Try.failure( - ReadError.Decoding( - DebugError("Invalid CBC-encrypted stream.") - ) + val encryptedLength = resource.length() + .getOrElse { return Try.failure(it) } + + if (encryptedLength == 0L) { + return Try.success(0) + } + + if (encryptedLength < 2 * AES_BLOCK_SIZE) { + return Try.failure( + ReadError.Decoding( + DebugError("Invalid CBC-encrypted stream.") ) - } + ) + } - val readOffset = length - (2 * AES_BLOCK_SIZE) - val bytes = resource.read(readOffset..length) + val paddingLength = builtinPaddingLength + ?: readPaddingLength(encryptedLength) + .onSuccess { builtinPaddingLength = it } .getOrElse { return Try.failure(it) } + ?: 0 // encryptedLength is bullshit, we can't get paddingLength + // Not sure if an inaccurate length is better than nothing, + // but ExoPlayer can't work with no length at all. - return lengthFromLastTwoBlocks(length, bytes) - } + val originalLength = encryptedLength - + AES_BLOCK_SIZE - // Minus IV + paddingLength // Minus padding size - private suspend fun lengthFromLastTwoBlocks( - cipheredLength: Long, - lastTwoBlocks: ByteArray - ): Try { - require(lastTwoBlocks.size == 2 * AES_BLOCK_SIZE) + return Try.success(originalLength) + } - val decryptedBytes = license.decrypt(lastTwoBlocks) - .getOrElse { - return Try.failure( - ReadError.Decoding( - DebugError("Can't decrypt trailing size of CBC-encrypted stream") - ) - ) - } + private suspend fun readPaddingLength( + encryptedSize: Long + ): Try { + val readOffset = encryptedSize - (2 * AES_BLOCK_SIZE) - check(decryptedBytes.size == AES_BLOCK_SIZE) + // try to read one more byte past the end to check if this is the actual end + val bytes = resource.read(readOffset until encryptedSize + 1) + .getOrElse { return Try.failure(it) } - val adjustedLength = cipheredLength - - AES_BLOCK_SIZE - // Minus IV - decryptedBytes.last().toInt() // Minus padding size + if (bytes.size != 2 * AES_BLOCK_SIZE) { + // encryptedSize must be bullshit, we cannot know the padding length + return Try.success(null) + } - return if (adjustedLength >= 0) { - Try.success(adjustedLength) - } else { - Try.failure( + val decryptedBytes = license.decrypt(bytes) + .getOrElse { + return Try.failure( ReadError.Decoding( - DebugError("Padding length seems invalid.") + DebugError("Can't decrypt trailing size of CBC-encrypted stream") ) ) } + + check(decryptedBytes.size == AES_BLOCK_SIZE) + return Try.success(decryptedBytes.last().toInt()) + } + + override suspend fun read(range: LongRange?): Try { + if (range == null) { + return license.decryptFully(resource.read(), isDeflated = false) } - override suspend fun read(range: LongRange?): Try { - if (range == null) { - return license.decryptFully(resource.read(), isDeflated = false) - } + @Suppress("NAME_SHADOWING") + val range = range + .coerceFirstNonNegative() + .requireLengthFitInt() - @Suppress("NAME_SHADOWING") - val range = range - .coerceFirstNonNegative() - .requireLengthFitInt() + if (range.isEmpty()) { + return Try.success(ByteArray(0)) + } - if (range.isEmpty()) { - return Try.success(ByteArray(0)) - } + // range bounds must be multiple of AES_BLOCK_SIZE + val startPadding = range.first - range.first.floorMultipleOf(AES_BLOCK_SIZE.toLong()) + val endPadding = (range.last + 1).ceilMultipleOf(AES_BLOCK_SIZE.toLong()) - (range.last + 1) - val rangeSize = range.last + 1 - range.first + // encrypted data is shifted by AES_BLOCK_SIZE because of IV and + // the previous block must be provided to perform XOR on intermediate blocks + // we also try to read one extra block to check if there is more data coming or we read the + // last block + val encryptedStart = range.first - startPadding + val encryptedEndExclusive = range.last + 1 + endPadding + 2 * AES_BLOCK_SIZE + val encryptedRangeSize = (encryptedEndExclusive - encryptedStart).toInt() - val encryptedLength = resource.length() - .getOrElse { return Try.failure(it) } + val encryptedData = resource.read(encryptedStart until encryptedEndExclusive) + .getOrElse { return Try.failure(it) } - // range bounds must be multiple of AES_BLOCK_SIZE and - val startPadding = range.first - range.first.floorMultipleOf(AES_BLOCK_SIZE.toLong()) - val endPadding = (range.last + 1).ceilMultipleOf(AES_BLOCK_SIZE.toLong()) - range.last - 1 + if (encryptedData.size % AES_BLOCK_SIZE != 0) { + return Try.failure( + ReadError.Decoding( + DebugError("Encrypted data size is not a multiple of AES block size.") + ) + ) + } - // encrypted data is shifted by AES_BLOCK_SIZE because of IV and - // the previous block must be provided to perform XOR on intermediate blocks - val encryptedStart = range.first - startPadding - val encryptedEndExclusive = range.last + 1 + endPadding + AES_BLOCK_SIZE + // Out of range request, there are no data to decrypt, only maybe a previous block. + if (encryptedData.size < 2 * AES_BLOCK_SIZE) { + return Try.success(ByteArray(0)) + } - val encryptedData = getEncryptedData(encryptedStart until encryptedEndExclusive) - .getOrElse { return Try.failure(it) } + // Have we got all requested data with the extra block or less? + val dataIncludesBuiltinPadding = encryptedData.size < encryptedRangeSize - val bytes = license.decrypt(encryptedData) - .onSuccess { - check(it.isEmpty() || it.size == encryptedData.size - AES_BLOCK_SIZE) - } - .getOrElse { - return Try.failure( - ReadError.Decoding( - DebugError( - "Can't decrypt the content for resource with key: ${resource.sourceUrl}", - it - ) - ) - ) - } + // We might not have got all data requested because range.last is allowed to be out of bounds. + check(encryptedData.size <= encryptedRangeSize) + val missingEndSize = encryptedRangeSize - encryptedData.size - // was the last block read to provide the desired range - val lastBlockRead = encryptedLength - encryptedEndExclusive <= AES_BLOCK_SIZE - - val dataSlice = - if (lastBlockRead) { - val decryptedLength = - if (::_length.isInitialized) { - _length - } else { - val lastTwoBlocks = encryptedData.sliceArray( - encryptedData.size - 2 until encryptedData.size - ) - lengthFromLastTwoBlocks(encryptedLength, lastTwoBlocks) - .onSuccess { _length = Try.success(it) } - }.getOrElse { return Try.failure(it) } - - // use decrypted length to ensure range.last doesn't exceed decrypted length - 1 - val dataLength = (range.last + 1).coerceAtMost(decryptedLength) - range.first - - // keep only enough bytes to fit the length corrected request in order to never include padding - val sliceEnd = startPadding + dataLength.toInt() - - startPadding.toInt() until sliceEnd.toInt() - } else { - // the last block was not read, so there's no need to compute decrypted length - - // bytes contains deciphered data for startPadding, then for the requested - // range, and then for endPadding - // the requested range might have been far too large, in which case bytes doesn't - // content all of that data - // if there are any data for endPadding, it begins at endPaddingStartIndex. - val endPaddingStartIndex = (startPadding + rangeSize).coerceAtMost( - bytes.size.toLong() + val bytes = license.decrypt(encryptedData) + .getOrElse { + return Try.failure( + ReadError.Decoding( + DebugError( + "Can't decrypt the content for resource with key: ${resource.sourceUrl}", + it + ) ) - startPadding.toInt() until endPaddingStartIndex.toInt() - } + ) + } - return Try.success(bytes.sliceArray(dataSlice)) - } + check(bytes.size == encryptedData.size - AES_BLOCK_SIZE) - /** - * Reads encrypted data using the cache when suitable. - */ - private suspend fun getEncryptedData(range: LongRange): Try { - val cacheStartIndex = _cache.startIndex - ?.takeIf { cacheStart -> - val cacheEnd = cacheStart + _cache.data.size - range.first in cacheStart until cacheEnd && cacheEnd <= range.last + 1 - } ?: return resource.read(range) - - val bytes = ByteArray(range.last.toInt() - range.first.toInt() + 1) - val offsetInCache = (range.first - cacheStartIndex).toInt() - val fromCacheLength = _cache.data.size - offsetInCache - - return resource.read(range.first + fromCacheLength..range.last).map { - _cache.data.copyInto(bytes, 0, offsetInCache) - it.copyInto(bytes, fromCacheLength) - bytes - }.onSuccess { result -> - if (result.size >= _cache.data.size) { - // cache the three last encrypted blocks that have been read for future use - val cacheStart = result.size - _cache.data.size - _cache.startIndex = (range.last + 1 - _cache.data.size).toInt() - result.copyInto(_cache.data, 0, cacheStart) - } + val builtinPaddingLength = + if (dataIncludesBuiltinPadding) { + bytes.last().toInt().also { this.builtinPaddingLength = it } + } else { + 0 } - } - companion object { - private const val AES_BLOCK_SIZE = 16 // bytes - } + val correctedEndPadding = (endPadding.toInt() + AES_BLOCK_SIZE - missingEndSize) + .coerceAtLeast(builtinPaddingLength) + + val dataSlice = startPadding.toInt() until bytes.size - correctedEndPadding + + return Try.success(bytes.sliceArray(dataSlice)) + } + + companion object { + private const val AES_BLOCK_SIZE = 16 // bytes } } @@ -300,6 +249,10 @@ private suspend fun LcpLicense.decryptFully( isDeflated: Boolean ): Try = data.flatMap { encryptedData -> + if (encryptedData.isEmpty()) { + return Try.success(encryptedData) + } + // Decrypts the resource. var bytes = decrypt(encryptedData) .getOrElse { @@ -342,6 +295,92 @@ private suspend fun LcpLicense.decryptFully( Try.success(bytes) } +/** + * A resource caching the last bytes read on range requests. + * + * Decryption needs to look one block back into the ciphered data. This means that in case of + * contiguous read requests such as those you can expect from audio players, the data fetched from + * the underlying resource are not contiguous: every range request to the underlying resource starts + * one block before the end of the previous one. + * + * This is an issue with remote publications as you have to make a new HTTP request for each range + * instead of reusing the previous one. To alleviate this, we cache the last bytes read in each + * request and reuse them in the next one if possible. + */ +private class CachingRangeTailResource( + private val resource: Resource, + private val cacheLength: Int +) : Resource by resource { + + private class Cache( + var startIndex: Int?, + val data: ByteArray + ) + + private val cache: Cache = Cache(null, ByteArray(cacheLength)) + + override suspend fun read(range: LongRange?): Try { + if (range == null) { + return resource.read(null) + } + + @Suppress("NAME_SHADOWING") + val range = range + .coerceFirstNonNegative() + .requireLengthFitInt() + + if (range.isEmpty()) { + return Try.success(ByteArray(0)) + } + + // cache start index or null if we can't read from the cache + val cacheStartIndex = cache.startIndex + ?.takeIf { cacheStart -> + val cacheEndExclusive = cacheStart + cacheLength + val rangeBeginsInCache = range.first in cacheStart until cacheEndExclusive + val rangeGoesBeyondCache = cacheEndExclusive <= range.last + 1 + rangeBeginsInCache && rangeGoesBeyondCache + } + + val result = + if (cacheStartIndex == null) { + resource.read(range) + } else { + readWithCache(cacheStartIndex, cache.data, range) + } + + // Cache the end of result if it's big enough + if (result is Try.Success && result.value.size >= cacheLength) { + val offsetInResult = result.value.size - cacheLength + cache.startIndex = (range.last + 1 - cacheLength).toInt() + result.value.copyInto(cache.data, 0, offsetInResult) + } + + return result + } + + private suspend fun readWithCache( + cacheStartIndex: Int, + cachedData: ByteArray, + range: LongRange + ): Try { + require(range.first >= cacheStartIndex) + require(range.last + 1 >= cacheStartIndex + cachedData.size) + + val offsetInCache = (range.first - cacheStartIndex).toInt() + val fromCacheLength = cachedData.size - offsetInCache + + val newData = resource.read(range.first + fromCacheLength..range.last) + .getOrElse { return Try.failure((it)) } + + val result = ByteArray(fromCacheLength + newData.size) + cachedData.copyInto(result, 0, offsetInCache) + newData.copyInto(result, fromCacheLength) + + return Try.success(result) + } +} + private val Encryption.isDeflated: Boolean get() = compression?.lowercase(java.util.Locale.ROOT) == "deflate" diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileZipLicenseContainer.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileZipLicenseContainer.kt index c30897b07c..f676010271 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileZipLicenseContainer.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/license/container/FileZipLicenseContainer.kt @@ -31,9 +31,11 @@ internal class FileZipLicenseContainer( } catch (e: Exception) { throw LcpException(LcpError.Container.OpenFailed) } + val entry = try { archive.getEntry(pathInZIP.toString())!! } catch (e: Exception) { + archive.close() throw LcpException(LcpError.Container.FileNotFound(pathInZIP)) } @@ -41,6 +43,8 @@ internal class FileZipLicenseContainer( archive.getInputStream(entry).readBytes() } catch (e: Exception) { throw LcpException(LcpError.Container.ReadFailed(pathInZIP)) + } finally { + archive.close() } } @@ -49,12 +53,15 @@ internal class FileZipLicenseContainer( val source = File(zip) val tmpZip = File("$zip.tmp") val zipFile = ZipFile(source) - zipFile.addOrReplaceEntry( - pathInZIP.toString(), - ByteArrayInputStream(license.toByteArray()), - tmpZip - ) - zipFile.close() + try { + zipFile.addOrReplaceEntry( + pathInZIP.toString(), + ByteArrayInputStream(license.toByteArray()), + tmpZip + ) + } finally { + zipFile.close() + } tmpZip.moveTo(source) } catch (e: Exception) { throw LcpException(LcpError.Container.WriteFailed(pathInZIP)) diff --git a/readium/lcp/src/test/java/org/readium/r2/lcp/LcpDecryptorTest.kt b/readium/lcp/src/test/java/org/readium/r2/lcp/LcpDecryptorTest.kt new file mode 100644 index 0000000000..58c8a99911 --- /dev/null +++ b/readium/lcp/src/test/java/org/readium/r2/lcp/LcpDecryptorTest.kt @@ -0,0 +1,130 @@ +package org.readium.r2.lcp + +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.slot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.readium.r2.shared.util.Try +import org.readium.r2.shared.util.checkSuccess +import org.readium.r2.shared.util.resource.InMemoryResource +import org.readium.r2.shared.util.resource.Resource +import org.robolectric.ParameterizedRobolectricTestRunner + +@OptIn(ExperimentalStdlibApi::class) +@RunWith(ParameterizedRobolectricTestRunner::class) +class LcpDecryptorTest(val testCase: TestCase) { + + data class TestCase( + val range: LongRange, + val paddingLength: Int + ) + + companion object { + + @ParameterizedRobolectricTestRunner.Parameters + @JvmStatic + fun testCases(): List = buildList { + add(TestCase(0 until 16L, 1)) + add(TestCase(5 until 16L, 1)) + add(TestCase(5 until 18L, 1)) + add(TestCase(17 until 61L, 1)) + add(TestCase(45 until 55L, 1)) + add(TestCase(50 until 55L, 1)) + + add(TestCase(0 until 16L, 5)) + add(TestCase(5 until 16L, 5)) + add(TestCase(5 until 18L, 5)) + add(TestCase(17 until 61L, 5)) + add(TestCase(45 until 55L, 5)) + add(TestCase(50 until 55L, 5)) + + add(TestCase(0 until 16L, 16)) + add(TestCase(5 until 16L, 16)) + add(TestCase(5 until 18L, 16)) + add(TestCase(17 until 61L, 16)) + add(TestCase(45 until 55L, 16)) + add(TestCase(50 until 55L, 16)) + + // Exhaustive testing, too long for CI + /*for (padding in 1 until 16) { + for (end in 0 until 60L) { + for (start in 0 until 100) { + add( + TestCase( + range = start until end, + paddingLength = padding + ) + ) + } + } + }*/ + } + } + + // length = 48 bytes + private val originalContent: String = + "e7820056c8d9d4955270d7e0e06b85e5d4e0ae0c415d704a1e5035040b0aeb71955afdd796f8453d22ed41572e30ce39" + + // length = 16 bytes + private val iv: String = + "69d18631eb38909efc0835299e70b9e2" + + @Test + fun `length and range decryption are ok`() = runTest { + val slot = slot() + val license = mockk() + + coEvery { license.decrypt(capture(slot)) } answers { + // Assume the encryption function is the identity and decrypt only removes IV + Try.success(slot.captured.sliceArray(16 until slot.captured.size)) + } + + val padding = + testCase.paddingLength.toByte().toHexString().repeat(testCase.paddingLength) + + val truncatedContent = + originalContent.substring(0, originalContent.length - 2 * testCase.paddingLength) + + val encryptedContent: String = + iv + truncatedContent + padding + + val encryptedResource = InMemoryResource( + sourceUrl = null, + properties = Resource.Properties(), + bytes = { Try.success(encryptedContent.hexToByteArray()) } + ) + + val cbcResource = CbcLcpResource( + resource = encryptedResource, + originalLength = null, // we want to test length from padding + license = license + ) + + val expectedEnd = (2 * (testCase.range.last + 1)) + .toInt() + .coerceAtMost(truncatedContent.length) + + val expectedStart = (2 * testCase.range.first) + .toInt() + .coerceAtMost(expectedEnd) + + assertEquals( + truncatedContent.length / 2, + cbcResource.length().checkSuccess().toInt() + ) + + assertEquals( + truncatedContent.substring(expectedStart, expectedEnd), + cbcResource.read(testCase.range).checkSuccess().toHexString() + ) + + // Length computation follows a different path after a range request + assertEquals( + truncatedContent.length / 2, + cbcResource.length().checkSuccess().toInt() + ) + } +} diff --git a/test-app/src/main/java/org/readium/r2/testapp/reader/MediaService.kt b/test-app/src/main/java/org/readium/r2/testapp/reader/MediaService.kt index e3eaaeaf43..7ac87cbaf7 100644 --- a/test-app/src/main/java/org/readium/r2/testapp/reader/MediaService.kt +++ b/test-app/src/main/java/org/readium/r2/testapp/reader/MediaService.kt @@ -94,7 +94,7 @@ class MediaService : MediaSessionService() { navigator.currentLocator .sample(3000) .onEach { locator -> - Timber.d("Saving TTS progression $locator") + Timber.d("Saving progression $locator") app.bookRepository.saveProgression(locator, bookId) }.launchIn(session.coroutineScope) }