Skip to content

Commit

Permalink
perf: Migrate epub caching system from JSON to Protocol Buffers (#225)
Browse files Browse the repository at this point in the history
Fixes OOM errors in some cases and enhances the efficiency of caching mechanism, significantly reducing serialization/deserialization times, memory footprint as well as size of cached data on disk.

Signed-off-by: starry-shivam <[email protected]>
  • Loading branch information
starry-shivam authored Sep 26, 2024
1 parent 5b9b6b9 commit 1ff2526
Show file tree
Hide file tree
Showing 8 changed files with 111 additions and 46 deletions.
3 changes: 2 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ dependencies {
// Android 12+ splash API.
implementation 'androidx.core:core-splashscreen:1.0.1'
// KotlinX Serialization library.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.7.3"
// OkHttp library.
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation "com.squareup.okhttp3:logging-interceptor:4.12.0"
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/starry/myne/epub/EpubParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import com.starry.myne.epub.cache.EpubCache
import com.starry.myne.epub.models.EpubBook
import com.starry.myne.epub.models.EpubChapter
import com.starry.myne.epub.models.EpubImage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,36 +14,38 @@
* limitations under the License.
*/

package com.starry.myne.epub
package com.starry.myne.epub.cache

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.builtins.ByteArraySerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.io.ByteArrayOutputStream

/**
* A [KSerializer] for [Bitmap] objects.
* It serializes the bitmap to a byte array and deserializes it back to a bitmap.
*/
object BitmapSerializer : KSerializer<Bitmap> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("Bitmap", PrimitiveKind.STRING)
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Bitmap") {
element<ByteArray>("bytes")
}

override fun serialize(encoder: Encoder, value: Bitmap) {
val stream = ByteArrayOutputStream()
value.compress(Bitmap.CompressFormat.PNG, 100, stream)
val byteArray = stream.toByteArray()
encoder.encodeString(
android.util.Base64.encodeToString(
byteArray, android.util.Base64.DEFAULT
)
)
encoder.encodeSerializableValue(ByteArraySerializer(), byteArray)
}

override fun deserialize(decoder: Decoder): Bitmap {
val byteArray =
android.util.Base64.decode(decoder.decodeString(), android.util.Base64.DEFAULT)
val byteArray = decoder.decodeSerializableValue(ByteArraySerializer())
return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,21 @@
* limitations under the License.
*/

package com.starry.myne.epub
package com.starry.myne.epub.cache

import android.content.Context
import android.util.Log
import com.starry.myne.epub.models.EpubBook
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
import kotlinx.serialization.protobuf.schema.ProtoBufSchemaGenerator
import java.io.File

/**
* A cache for storing epub books.
* A cache storage based on Protocol Buffers for storing [EpubBook] objects.
* The cache is stored in the app's cache directory.
*
* @param context The context.
* @param context The context of the application.
*/
class EpubCache(private val context: Context) {

Expand All @@ -36,14 +38,17 @@ class EpubCache(private val context: Context) {
private const val CACHE_VERSION_FILE = "cache_version"

// Increment this if the cache format changes.
private const val EPUB_CACHE_VERSION = 1
private const val EPUB_CACHE_VERSION = 2
}

init {
create()
checkCacheVersion()
}

@OptIn(ExperimentalSerializationApi::class)
private val protobuf = ProtoBuf { encodeDefaults = true }

private fun getPath(): String {
return context.cacheDir.absolutePath + File.separator + EPUB_CACHE
}
Expand All @@ -52,6 +57,7 @@ class EpubCache(private val context: Context) {
return getPath() + File.separator + CACHE_VERSION_FILE
}

// Checks if the cache version matches the current version.
private fun checkCacheVersion() {
Log.d(TAG, "Checking cache version")
val versionFile = File(getVersionFilePath())
Expand All @@ -69,6 +75,7 @@ class EpubCache(private val context: Context) {
}
}

// Saves the current cache version.
private fun saveCacheVersion() {
Log.d(TAG, "Saving cache version")
val versionFile = File(getVersionFilePath())
Expand All @@ -91,35 +98,45 @@ class EpubCache(private val context: Context) {
}
}

// Used for debugging purposes.
@Suppress("unused")
@OptIn(ExperimentalSerializationApi::class)
private fun printSchema() {
val protoSchema =
ProtoBufSchemaGenerator.generateSchemaText(EpubBook.serializer().descriptor)
Log.d(TAG, "Proto schema: $protoSchema")
}

/**
* Adds a book to the cache.
* Inserts a book into the cache.
*
* @param book The book to add.
* @param book The book to insert.
* @param filepath The path to the book file.
*/
@OptIn(ExperimentalSerializationApi::class)
fun put(book: EpubBook, filepath: String) {
Log.d(TAG, "Inserting book into cache: ${book.title}")
val fileName = File(filepath).nameWithoutExtension
val bookFile = File(getPath(), "$fileName.json")
val jsonString = Json.encodeToString(book)
bookFile.writeText(jsonString)
val bookFile = File(getPath(), "$fileName.protobuf")
val protoBytes = protobuf.encodeToByteArray(EpubBook.serializer(), book)
bookFile.writeBytes(protoBytes)
}

/**
* Gets a book from the cache.
* If the book is not cached, null is returned.
*
* @param filepath The path to the book file.
* @return The book if it is cached, null otherwise.
*/
@OptIn(ExperimentalSerializationApi::class)
fun get(filepath: String): EpubBook? {
Log.d(TAG, "Getting book from cache: $filepath")
val fileName = File(filepath).nameWithoutExtension
val bookFile = File(getPath(), "$fileName.json")
val bookFile = File(getPath(), "$fileName.protobuf")
return if (bookFile.exists()) {
Log.d(TAG, "Book found in cache: $filepath")
val jsonString = bookFile.readText()
Json.decodeFromString(jsonString)
val protoBytes = bookFile.readBytes()
protobuf.decodeFromByteArray(EpubBook.serializer(), protoBytes)
} else {
Log.d(TAG, "Book not found in cache: $filepath")
null
Expand All @@ -134,7 +151,7 @@ class EpubCache(private val context: Context) {
fun remove(filepath: String): Boolean {
Log.d(TAG, "Removing book from cache: $filepath")
val fileName = File(filepath).nameWithoutExtension
val bookFile = File(getPath(), "$fileName.json")
val bookFile = File(getPath(), "$fileName.protobuf")
return if (bookFile.exists()) {
bookFile.delete()
} else {
Expand All @@ -150,7 +167,7 @@ class EpubCache(private val context: Context) {
*/
fun isCached(filepath: String): Boolean {
val fileName = File(filepath).nameWithoutExtension
val bookFile = File(getPath(), "$fileName.json")
val bookFile = File(getPath(), "$fileName.protobuf")
return bookFile.exists()
}
}
36 changes: 36 additions & 0 deletions app/src/main/java/com/starry/myne/epub/cache/epub_cache.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Generated schema file for 'com.starry.myne.epub.models.EpubBook'
// For reference only, not actually used to generate protobuf code

syntax = "proto2";

// serial name 'com.starry.myne.epub.models.EpubBook'
message EpubBook {
required string fileName = 1;
required string title = 2;
required string author = 3;
required string language = 4;
optional Bitmap coverImage = 5;
// WARNING: a default value decoded when value is missing
repeated EpubChapter chapters = 6;
// WARNING: a default value decoded when value is missing
repeated EpubImage images = 7;
}

// serial name 'Bitmap?'
message Bitmap {
required bytes bytes = 1;
}

// serial name 'com.starry.myne.epub.models.EpubChapter'
message EpubChapter {
required string chapterId = 1;
required string absPath = 2;
required string title = 3;
required string body = 4;
}

// serial name 'com.starry.myne.epub.models.EpubImage'
message EpubImage {
required string absPath = 1;
required bytes image = 2;
}
21 changes: 11 additions & 10 deletions app/src/main/java/com/starry/myne/epub/models/EpubBook.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
package com.starry.myne.epub.models

import android.graphics.Bitmap
import com.starry.myne.epub.BitmapSerializer
import com.starry.myne.epub.cache.BitmapSerializer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber

/**
* Represents an epub book.
Expand All @@ -33,13 +35,12 @@ import kotlinx.serialization.Serializable
* @param images The list of images in the book.
*/
@Serializable
data class EpubBook(
val fileName: String,
val title: String,
val author: String,
val language: String,
@Serializable(with = BitmapSerializer::class)
val coverImage: Bitmap?,
val chapters: List<EpubChapter>,
val images: List<EpubImage>
data class EpubBook @OptIn(ExperimentalSerializationApi::class) constructor(
@ProtoNumber(1) val fileName: String,
@ProtoNumber(2) val title: String,
@ProtoNumber(3) val author: String,
@ProtoNumber(4) val language: String,
@ProtoNumber(5) @Serializable(with = BitmapSerializer::class) val coverImage: Bitmap?,
@ProtoNumber(6) val chapters: List<EpubChapter> = emptyList(),
@ProtoNumber(7) val images: List<EpubImage> = emptyList()
)
12 changes: 7 additions & 5 deletions app/src/main/java/com/starry/myne/epub/models/EpubChapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@

package com.starry.myne.epub.models

import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber

/**
* Represents a chapter in an epub book.
Expand All @@ -27,9 +29,9 @@ import kotlinx.serialization.Serializable
* @param body The body of the chapter.
*/
@Serializable
data class EpubChapter(
val chapterId: String,
val absPath: String,
val title: String,
val body: String
data class EpubChapter @OptIn(ExperimentalSerializationApi::class) constructor(
@ProtoNumber(1) val chapterId: String,
@ProtoNumber(2) val absPath: String,
@ProtoNumber(3) val title: String,
@ProtoNumber(4) val body: String
)
7 changes: 6 additions & 1 deletion app/src/main/java/com/starry/myne/epub/models/EpubImage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@

package com.starry.myne.epub.models

import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber

/**
* Represents an image in an epub book.
Expand All @@ -26,7 +28,10 @@ import kotlinx.serialization.Serializable
* @param image The image data.
*/
@Serializable
data class EpubImage(val absPath: String, val image: ByteArray) {
data class EpubImage @OptIn(ExperimentalSerializationApi::class) constructor(
@ProtoNumber(1) val absPath: String,
@ProtoNumber(2) val image: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
Expand Down

0 comments on commit 1ff2526

Please sign in to comment.