General fixes (#19)
* fix OOM crash by refactoring EpubImage to use lazy loading - Removed raw image ByteArray from EpubImage data class to prevent OutOfMemoryErrors on large books. - Updated EpubParser and MobiParser to only store image paths in the EpubBook object. - Images are now loaded on-demand from disk via file URIs in WebView and Coil, reducing initial heap allocation. * fix(tts): resolve OutOfMemoryError by removing large strings from state bundle - Removed 'currentText' from the TtsState bundle inside MediaSession custom layout. - Updated TtsController to retrieve the current text chunk via MediaItem metadata. - This prevents heap exhaustion caused by high-frequency serialization of large text strings during word tracking and polling updates.
This commit is contained in:
parent
a81df6921d
commit
d6837aa4df
5 changed files with 23 additions and 47 deletions
|
|
@ -27,29 +27,9 @@ import kotlinx.serialization.protobuf.ProtoNumber
|
||||||
* Represents an image in an epub book.
|
* Represents an image in an epub book.
|
||||||
*
|
*
|
||||||
* @param absPath The absolute path of the image.
|
* @param absPath The absolute path of the image.
|
||||||
* @param image The image data.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
@Serializable
|
@Serializable
|
||||||
data class EpubImage @OptIn(ExperimentalSerializationApi::class) constructor(
|
data class EpubImage @OptIn(ExperimentalSerializationApi::class) constructor(
|
||||||
@ProtoNumber(1) val absPath: String,
|
@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
|
|
||||||
|
|
||||||
other as EpubImage
|
|
||||||
|
|
||||||
if (absPath != other.absPath) return false
|
|
||||||
if (!image.contentEquals(other.image)) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
var result = absPath.hashCode()
|
|
||||||
result = 31 * result + image.contentHashCode()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -577,30 +577,29 @@ class EpubParser(private val context: Context) {
|
||||||
private fun parseEpubImages(
|
private fun parseEpubImages(
|
||||||
manifestItems: Map<String, EpubManifestItem>,
|
manifestItems: Map<String, EpubManifestItem>,
|
||||||
filesContentMap: Map<String, EpubFile>,
|
filesContentMap: Map<String, EpubFile>,
|
||||||
extractionRoot: File // Add this param
|
@Suppress("UNUSED_PARAMETER") extractionRoot: File
|
||||||
): List<EpubImage> {
|
): List<EpubImage> {
|
||||||
val imageExtensions = listOf("png", "gif", "jpg", "jpeg", "webp", "svg").map { ".$it" }
|
val imageExtensions = setOf(".png", ".gif", ".jpg", ".jpeg", ".webp", ".svg")
|
||||||
|
|
||||||
val listedImages = manifestItems.values
|
val listedImages = manifestItems.values
|
||||||
.filter { it.mediaType.startsWith("image/") }
|
.filter { it.mediaType.startsWith("image/") }
|
||||||
.mapNotNull { manifestItem ->
|
.map { manifestItem ->
|
||||||
val bytes = filesContentMap[manifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
|
EpubImage(absPath = manifestItem.absPath)
|
||||||
?: File(extractionRoot, manifestItem.absPath).takeIf { it.exists() }?.readBytes()
|
|
||||||
|
|
||||||
bytes?.let { EpubImage(absPath = manifestItem.absPath, image = it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val unlistedImages = filesContentMap.asSequence()
|
val listedPaths = listedImages.map { it.absPath }.toSet()
|
||||||
.filter { (path, _) -> imageExtensions.any { path.endsWith(it, ignoreCase = true) } }
|
|
||||||
.filterNot { (path, _) -> listedImages.any { it.absPath == path } }
|
|
||||||
.mapNotNull { (path, file) ->
|
|
||||||
val bytes = file.data.takeIf { it.isNotEmpty() }
|
|
||||||
?: File(extractionRoot, path).takeIf { it.exists() }?.readBytes()
|
|
||||||
|
|
||||||
bytes?.let { EpubImage(absPath = path, image = it) }
|
val unlistedImages = filesContentMap.keys
|
||||||
|
.filter { path ->
|
||||||
|
val lowerPath = path.lowercase()
|
||||||
|
imageExtensions.any { lowerPath.endsWith(it) } && !listedPaths.contains(path)
|
||||||
|
}
|
||||||
|
.map { path ->
|
||||||
|
EpubImage(absPath = path)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (listedImages + unlistedImages).distinctBy { it.absPath }.toList()
|
Timber.d("Identified ${listedImages.size + unlistedImages.size} images (content not loaded into memory).")
|
||||||
|
return (listedImages + unlistedImages).distinctBy { it.absPath }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseCoverImage(
|
private fun parseCoverImage(
|
||||||
|
|
|
||||||
|
|
@ -109,8 +109,6 @@ class MobiParser(private val context: Context) {
|
||||||
private external fun parseMobiFile(filePath: String): ParsedMobiData?
|
private external fun parseMobiFile(filePath: String): ParsedMobiData?
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MobiParser"
|
|
||||||
private const val AZW3_TAG = "AZW3_DEBUG"
|
|
||||||
const val EXTRACTED_EPUB_DIR_NAME = "extracted_epubs"
|
const val EXTRACTED_EPUB_DIR_NAME = "extracted_epubs"
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|
@ -135,7 +133,7 @@ class MobiParser(private val context: Context) {
|
||||||
}
|
}
|
||||||
Timber.d("MOBI stream saved to temporary file: ${tempFile.absolutePath}")
|
Timber.d("MOBI stream saved to temporary file: ${tempFile.absolutePath}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e("Failed to write InputStream to temporary file.", e)
|
Timber.e(e, "Failed to write InputStream to temporary file.")
|
||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
@ -143,7 +141,7 @@ class MobiParser(private val context: Context) {
|
||||||
val parsedData = try {
|
val parsedData = try {
|
||||||
parseMobiFile(tempFile.absolutePath)
|
parseMobiFile(tempFile.absolutePath)
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
Timber.e("JNI call failed. Is the native library loaded correctly?", e)
|
Timber.e(e, "JNI call failed. Is the native library loaded correctly?")
|
||||||
null
|
null
|
||||||
} finally {
|
} finally {
|
||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
|
|
@ -177,7 +175,7 @@ class MobiParser(private val context: Context) {
|
||||||
file.writeBytes(resource.data)
|
file.writeBytes(resource.data)
|
||||||
Timber.d("Wrote resource to disk -> Path: ${file.absolutePath}, Type: ${resource.mediaType}, Size: ${resource.data.size}")
|
Timber.d("Wrote resource to disk -> Path: ${file.absolutePath}, Type: ${resource.mediaType}, Size: ${resource.data.size}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e("Parser: FAILED to write resource to disk: ${resource.path}", e)
|
Timber.e(e, "Parser: FAILED to write resource to disk: ${resource.path}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,7 +227,7 @@ class MobiParser(private val context: Context) {
|
||||||
val chapterHtmlParts = mutableListOf<Pair<String, String>>()
|
val chapterHtmlParts = mutableListOf<Pair<String, String>>()
|
||||||
|
|
||||||
val sortedToc = parsedData.toc?.sorted()
|
val sortedToc = parsedData.toc?.sorted()
|
||||||
if (sortedToc != null && sortedToc.isNotEmpty()) {
|
if (!sortedToc.isNullOrEmpty()) {
|
||||||
Timber.d("Splitting content using TOC (${sortedToc.size} entries).")
|
Timber.d("Splitting content using TOC (${sortedToc.size} entries).")
|
||||||
for (i in sortedToc.indices) {
|
for (i in sortedToc.indices) {
|
||||||
val tocEntry = sortedToc[i]
|
val tocEntry = sortedToc[i]
|
||||||
|
|
@ -268,14 +266,14 @@ class MobiParser(private val context: Context) {
|
||||||
plainTextContent = doc.text()
|
plainTextContent = doc.text()
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e("Failed to process split chapter $index", e)
|
Timber.e(e, "Failed to process split chapter $index")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val images = parsedData.resources
|
val images = parsedData.resources
|
||||||
.filter { it.mediaType.startsWith("image/") }
|
.filter { it.mediaType.startsWith("image/") }
|
||||||
.map { EpubImage(absPath = it.path, image = it.data) }
|
.map { EpubImage(absPath = it.path) }
|
||||||
|
|
||||||
val cssContent = parsedData.resources
|
val cssContent = parsedData.resources
|
||||||
.filter { it.mediaType == "text/css" }
|
.filter { it.mediaType == "text/css" }
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,7 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
isPlaying = controller.isPlaying,
|
isPlaying = controller.isPlaying,
|
||||||
isLoading = isLoading,
|
isLoading = isLoading,
|
||||||
currentText = if (isPlaybackActive) {
|
currentText = if (isPlaybackActive) {
|
||||||
currentTextFromMediaItem ?: customState.getString("currentText")
|
currentTextFromMediaItem
|
||||||
} else {
|
} else {
|
||||||
if (isLoading) currentState.currentText else null
|
if (isLoading) currentState.currentText else null
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -615,7 +615,6 @@ class TtsPlaybackManager(
|
||||||
private fun createStateButton(state: TtsState): CommandButton {
|
private fun createStateButton(state: TtsState): CommandButton {
|
||||||
val bundle = Bundle().apply {
|
val bundle = Bundle().apply {
|
||||||
putBoolean("isLoading", state.isLoading)
|
putBoolean("isLoading", state.isLoading)
|
||||||
putString("currentText", state.currentText)
|
|
||||||
putString("errorMessage", state.errorMessage)
|
putString("errorMessage", state.errorMessage)
|
||||||
putString("speakerId", state.speakerId)
|
putString("speakerId", state.speakerId)
|
||||||
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
|
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue