Merge remote-tracking branch 'origin/main'

This commit is contained in:
Hosted Weblate 2026-05-04 18:25:47 +02:00
commit f275fea71e
No known key found for this signature in database
GPG key ID: A3FAAA06E6569B4C
126 changed files with 15287 additions and 3154 deletions

View file

@ -189,7 +189,7 @@
window.VIEWPORT_PADDING_BOTTOM = bottom || 0;
};
window.applyReaderTheme = function (isDark, bgHex, textHex, textureBase64) {
window.applyReaderTheme = function (isDark, bgHex, textHex, textureBase64, textureAlpha) {
var styleId = "readerThemeStyle";
var themeStyleElement = document.getElementById(styleId);
@ -207,8 +207,14 @@
var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF');
var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000');
var effectiveTextureAlpha = Math.max(0, Math.min(1, textureAlpha == null ? 0.55 : textureAlpha));
var bgMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(effectiveBg);
var bgRgb = bgMatch
? `${parseInt(bgMatch[1], 16)}, ${parseInt(bgMatch[2], 16)}, ${parseInt(bgMatch[3], 16)}`
: (isDark ? '18, 18, 18' : '255, 255, 255');
var textureCss = textureBase64
? `background-image: url('${textureBase64}'); background-repeat: repeat; background-blend-mode: multiply;`
? `background-image: linear-gradient(rgba(${bgRgb},${1 - effectiveTextureAlpha}), rgba(${bgRgb},${1 - effectiveTextureAlpha})), url('${textureBase64}'); background-repeat: repeat, repeat; background-blend-mode: normal, normal;`
: 'background-image: none;';
var css = `
@ -517,7 +523,65 @@
}
}, true);
window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap, imageSize, horizontalMargin) {
function getReaderImageElements() {
return Array.prototype.slice.call(document.querySelectorAll("img, svg, video, canvas, image"));
}
function rememberReaderImageAnchors() {
getReaderImageElements().forEach(function (image) {
if (image.getAttribute("data-reader-image-anchor")) return;
var parent = image.parentElement || document.body;
var imageRect = image.getBoundingClientRect();
var parentRect = parent.getBoundingClientRect();
var parentWidth = parentRect.width || document.documentElement.clientWidth || window.innerWidth || 0;
if (!parentWidth || imageRect.width <= 0) {
image.setAttribute("data-reader-image-anchor", "center");
return;
}
var imageCenter = imageRect.left + imageRect.width / 2;
var parentCenter = parentRect.left + parentWidth / 2;
var tolerance = Math.max(4, parentWidth * 0.08);
var anchor = "center";
if (Math.abs(imageCenter - parentCenter) <= tolerance || imageRect.width >= parentWidth - 2) {
anchor = "center";
} else if (imageCenter > parentCenter) {
anchor = "right";
} else {
anchor = "left";
}
image.setAttribute("data-reader-image-anchor", anchor);
});
}
function applyReaderImageAnchors() {
getReaderImageElements().forEach(function (image) {
var anchor = image.getAttribute("data-reader-image-anchor") || "center";
image.style.setProperty("display", "block", "important");
image.style.setProperty("height", "auto", "important");
image.style.setProperty("object-fit", "contain", "important");
if (anchor === "right") {
image.style.setProperty("float", "none", "important");
image.style.setProperty("margin-left", "auto", "important");
image.style.setProperty("margin-right", "0", "important");
} else if (anchor === "left") {
image.style.setProperty("margin-left", "0", "important");
image.style.setProperty("margin-right", "auto", "important");
} else {
image.style.setProperty("float", "none", "important");
image.style.setProperty("margin-left", "auto", "important");
image.style.setProperty("margin-right", "auto", "important");
}
});
}
window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap, imageSize, horizontalMargin, verticalMargin) {
var logTag = "ReaderFontDiagnosis";
console.log(
logTag +
@ -534,7 +598,9 @@
", ImageSize: " +
imageSize +
", HorizontalMargin: " +
horizontalMargin
horizontalMargin +
", VerticalMargin: " +
verticalMargin
);
var dynamicStyleId = "dynamicReaderStyles";
@ -551,12 +617,16 @@
var newGap = parseFloat(paragraphGap);
var newImageSize = parseFloat(imageSize);
var newHorizontalMargin = parseFloat(horizontalMargin);
var newVerticalMargin = parseFloat(verticalMargin);
if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0;
if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.0;
if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0;
if (isNaN(newImageSize) || newImageSize < 0.5 || newImageSize > 2.0) newImageSize = 1.0;
if (isNaN(newHorizontalMargin) || newHorizontalMargin < 0.0 || newHorizontalMargin > 3.0) newHorizontalMargin = 1.0;
if (isNaN(newVerticalMargin) || newVerticalMargin < 0.0 || newVerticalMargin > 3.0) newVerticalMargin = 1.0;
rememberReaderImageAnchors();
var fontCss = "";
if (fontFamily && fontFamily !== "Original" && fontFamily !== "") {
@ -609,11 +679,14 @@
}
var horizontalPaddingPx = Math.max(0, 16 * newHorizontalMargin);
var verticalPaddingPx = Math.max(0, 16 * newVerticalMargin);
var horizontalMarginCss = `
body {
box-sizing: border-box !important;
padding-left: ${horizontalPaddingPx}px !important;
padding-right: ${horizontalPaddingPx}px !important;
padding-top: ${verticalPaddingPx}px !important;
padding-bottom: ${verticalPaddingPx}px !important;
}
`;
@ -629,10 +702,22 @@
width: min(100%, calc(100% * var(--reader-image-size))) !important;
max-width: 100% !important;
height: auto !important;
display: block !important;
float: none !important;
margin-left: auto !important;
margin-right: auto !important;
object-fit: contain !important;
}
body p:has(> img:only-child),
body div:has(> img:only-child),
body figure {
text-align: center !important;
}
`;
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n");
applyReaderImageAnchors();
setTimeout(applyReaderImageAnchors, 80);
setTimeout(
function () {
@ -2170,6 +2255,7 @@
if (window.checkImagesForDiagnosis) {
setTimeout(window.checkImagesForDiagnosis, 100);
}
setTimeout(applyReaderImageAnchors, 80);
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -52,7 +52,7 @@ static FPDFLink_GetDest_t get_dest_func = nullptr;
static FPDFAction_GetDest_t get_action_dest_func = nullptr;
static FPDFDest_GetDestPageIndex_t get_dest_page_index_func = nullptr;
static FPDFAction_GetFilePath_t get_file_path_func = nullptr;
static std::mutex g_pdfium_mutex;
static std::recursive_mutex g_pdfium_mutex;
static FPDFLink_GetAnnot_t get_link_annot_func = nullptr;
static FPDFLink_GetAction_t get_link_action_func = nullptr;
static FPDFAction_GetType_t get_action_type_func = nullptr;
@ -169,20 +169,23 @@ static bool init_pdfium() {
extern "C" JNIEXPORT jdouble JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
if (!init_pdfium() || !get_font_size_func) return 0.0;
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_font_size_func || textPagePtr == 0 || index < 0) return 0.0;
return get_font_size_func(reinterpret_cast<void*>(textPagePtr), index);
}
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontWeight(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
if (!init_pdfium() || !get_font_weight_func) return 0;
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_font_weight_func || textPagePtr == 0 || index < 0) return 0;
return get_font_weight_func(reinterpret_cast<void*>(textPagePtr), index);
}
// Bulk extraction for blazing fast formatting processing
extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_font_size_func || count <= 0) return nullptr;
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_font_size_func || textPagePtr == 0 || count <= 0) return nullptr;
jfloatArray result = env->NewFloatArray(count);
jfloat *fill = new jfloat[count];
@ -196,7 +199,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclas
extern "C" JNIEXPORT jintArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_font_weight_func || count <= 0) return nullptr;
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_font_weight_func || textPagePtr == 0 || count <= 0) return nullptr;
jintArray result = env->NewIntArray(count);
jint *fill = new jint[count];
@ -210,7 +214,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jcl
extern "C" JNIEXPORT jintArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_font_info_func || count <= 0) return nullptr;
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_font_info_func || textPagePtr == 0 || count <= 0) return nullptr;
jintArray result = env->NewIntArray(count);
jint *fill = new jint[count];
@ -226,7 +231,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclas
extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
if (!init_pdfium() || !get_char_box_func || count <= 0) return nullptr;
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_char_box_func || textPagePtr == 0 || count <= 0) return nullptr;
const int stride = 4;
jfloatArray result = env->NewFloatArray(count * stride);
@ -247,7 +253,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclas
extern "C" JNIEXPORT jstring JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) {
std::lock_guard<std::mutex> lock(g_pdfium_mutex);
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_func || !get_annot_string_func || pagePtr == 0) return nullptr;
void* page = reinterpret_cast<void*>(pagePtr);
@ -294,24 +300,23 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
if (!init_pdfium() || !count_objects_func) return 0;
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !count_objects_func || pagePtr == 0) return 0;
return count_objects_func(reinterpret_cast<void*>(pagePtr));
}
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectType(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || pagePtr == 0 || index < 0) return 0;
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
if (index >= object_count) return 0;
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_object_func || !get_object_type_func || pagePtr == 0 || index < 0) return 0;
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
return obj ? get_object_type_func(obj) : 0;
}
extern "C" JNIEXPORT jboolean JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jfloatArray outRect) {
if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_bounds_func || pagePtr == 0 || index < 0 || outRect == nullptr) return JNI_FALSE;
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
if (index >= object_count) return JNI_FALSE;
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_object_func || !get_object_bounds_func || pagePtr == 0 || index < 0 || outRect == nullptr) return JNI_FALSE;
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
if (!obj) return JNI_FALSE;
@ -326,9 +331,13 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *en
extern "C" JNIEXPORT jintArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jintArray dimens) {
if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || !get_image_bitmap_func || !bitmap_get_buffer_func || pagePtr == 0 || index < 0 || dimens == nullptr) return nullptr;
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
if (index >= object_count) return nullptr;
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_object_func || !get_object_type_func || !get_image_bitmap_func ||
!bitmap_get_width_func || !bitmap_get_height_func || !bitmap_get_stride_func ||
!bitmap_get_buffer_func || !bitmap_destroy_func ||
pagePtr == 0 || index < 0 || dimens == nullptr) {
return nullptr;
}
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE
@ -379,6 +388,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jcl
extern "C" JNIEXPORT jboolean JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jclass clazz) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
init_pdfium();
// Return true if we have ANY way to handle actions
return (do_annot_action_func || get_link_action_func) ? JNI_TRUE : JNI_FALSE;
@ -386,6 +396,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jcl
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return -1;
void* page = reinterpret_cast<void*>(pagePtr);
@ -412,6 +423,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env,
extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_count_func || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
void* page = reinterpret_cast<void*>(pagePtr);
@ -432,13 +444,14 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jc
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
std::lock_guard<std::mutex> lock(g_pdfium_mutex);
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0;
return get_annot_count_func(reinterpret_cast<void*>(pagePtr));
}
extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func || pagePtr == 0) return 0;
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
return annot ? get_annot_subtype_func(annot) : 0;
@ -446,6 +459,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass
extern "C" JNIEXPORT jfloatArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
if (!annot) return nullptr;
@ -460,7 +474,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass cl
extern "C" JNIEXPORT jboolean JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard<std::mutex> lock(g_pdfium_mutex);
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || pagePtr == 0) return JNI_FALSE;
void* page = reinterpret_cast<void*>(pagePtr);
@ -527,7 +541,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl
extern "C" JNIEXPORT jstring JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getLinkInfoAtPoint(JNIEnv *env, jclass clazz, jlong docPtr, jlong pagePtr, jdouble x, jdouble y) {
std::lock_guard<std::mutex> lock(g_pdfium_mutex);
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium()) {
LOGE("PdfLinkDiagnostic: init_pdfium failed.");
return nullptr;

View file

@ -0,0 +1,314 @@
package com.aryan.reader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AiSettingsScreen(
onBackClick: () -> Unit
) {
val context = LocalContext.current
var settings by remember { mutableStateOf(loadAiByokSettings(context)) }
var selectedProvider by remember { mutableStateOf("gemini") }
var providerMenuExpanded by remember { mutableStateOf(false) }
var pendingKey by remember { mutableStateOf("") }
var showSaveConfirm by remember { mutableStateOf(false) }
var providerToDelete by remember { mutableStateOf<String?>(null) }
fun refresh() {
settings = loadAiByokSettings(context)
}
fun updateModels(newSettings: AiByokSettings) {
saveAiByokSettings(context, newSettings)
settings = loadAiByokSettings(context)
}
Scaffold(
modifier = Modifier.statusBarsPadding(),
topBar = {
CustomTopAppBar(
title = { Text("AI keys and models") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
SavedKeyRow("Gemini", maskedAiByokKey(context, "gemini"), onDelete = { providerToDelete = "gemini" })
SavedKeyRow("Groq", maskedAiByokKey(context, "groq"), onDelete = { providerToDelete = "groq" })
HorizontalDivider()
Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
ExposedDropdownMenuBox(
expanded = providerMenuExpanded,
onExpandedChange = { providerMenuExpanded = it },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = selectedProvider.replaceFirstChar { it.titlecase() },
onValueChange = {},
readOnly = true,
label = { Text("Provider") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = providerMenuExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor()
)
ExposedDropdownMenu(
expanded = providerMenuExpanded,
onDismissRequest = { providerMenuExpanded = false }
) {
listOf("gemini", "groq").forEach { provider ->
DropdownMenuItem(
text = { Text(provider.replaceFirstChar { it.titlecase() }) },
onClick = {
selectedProvider = provider
providerMenuExpanded = false
},
trailingIcon = if (provider == selectedProvider) {
{ Icon(Icons.Default.Check, contentDescription = null) }
} else null
)
}
}
}
OutlinedTextField(
value = pendingKey,
onValueChange = { pendingKey = it },
label = { Text("API key") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = { showSaveConfirm = true },
enabled = pendingKey.isNotBlank(),
modifier = Modifier.align(Alignment.End)
) {
Text("Save key")
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text("Use one model for all features", style = MaterialTheme.typography.titleMedium)
Text(
"When off, each reader AI feature uses its own selected model.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = settings.useOneModel,
onCheckedChange = { updateModels(settings.copy(useOneModel = it)) }
)
}
if (settings.useOneModel) {
ModelSelector(
title = "All AI features",
description = "Smart dictionary, summaries, and recaps all use this model.",
selectedId = settings.modelForAll,
onSelected = { updateModels(settings.copy(modelForAll = it)) }
)
} else {
ModelSelector(
title = "Smart dictionary",
description = "Used when defining selected words or phrases.",
selectedId = settings.defineModel,
onSelected = { updateModels(settings.copy(defineModel = it)) }
)
ModelSelector(
title = "Summaries",
description = "Used for EPUB summaries and PDF page summaries. PDF/image summaries need Gemini.",
selectedId = settings.summarizeModel,
onSelected = { updateModels(settings.copy(summarizeModel = it)) }
)
ModelSelector(
title = "Recaps",
description = "Used for story recap generation.",
selectedId = settings.recapModel,
onSelected = { updateModels(settings.copy(recapModel = it)) }
)
}
ModelSelector(
title = "Cloud TTS",
description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.",
selectedId = settings.ttsModel,
options = listOf(AiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)),
onSelected = { updateModels(settings.copy(ttsModel = it)) }
)
}
}
if (showSaveConfirm) {
AlertDialog(
onDismissRequest = { showSaveConfirm = false },
title = { Text("Save ${selectedProvider.replaceFirstChar { it.titlecase() }} key?") },
text = { Text("After saving, only the first 3 and last 3 characters will be visible. To change it later, replace or delete it.") },
confirmButton = {
TextButton(onClick = {
saveAiByokKey(context, selectedProvider, pendingKey)
pendingKey = ""
showSaveConfirm = false
refresh()
}) { Text("Save") }
},
dismissButton = {
TextButton(onClick = { showSaveConfirm = false }) { Text("Cancel") }
}
)
}
providerToDelete?.let { provider ->
AlertDialog(
onDismissRequest = { providerToDelete = null },
title = { Text("Delete ${provider.replaceFirstChar { it.titlecase() }} key?") },
text = { Text("Features using this provider will stop working until a new key is saved.") },
confirmButton = {
TextButton(onClick = {
deleteAiByokKey(context, provider)
providerToDelete = null
refresh()
}) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { providerToDelete = null }) { Text("Cancel") }
}
)
}
}
@Composable
private fun SavedKeyRow(
label: String,
maskedKey: String,
onDelete: () -> Unit
) {
ListItem(
headlineContent = { Text(label) },
supportingContent = {
Text(maskedKey.ifBlank { "No key saved" })
},
trailingContent = {
IconButton(onClick = onDelete, enabled = maskedKey.isNotBlank()) {
Icon(Icons.Default.Delete, contentDescription = "Delete $label key")
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ModelSelector(
title: String,
description: String,
selectedId: String,
options: List<AiModelOption> = aiByokModelOptions,
onSelected: (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val selected = options.firstOrNull { it.id == selectedId }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = selected?.label ?: "No model selected",
onValueChange = {},
readOnly = true,
label = { Text("Model") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("No model selected") },
onClick = {
onSelected("")
expanded = false
},
trailingIcon = if (selectedId.isBlank()) {
{ Icon(Icons.Default.Check, contentDescription = null) }
} else null
)
options.forEach { option ->
DropdownMenuItem(
text = { Text(option.label) },
onClick = {
onSelected(option.id)
expanded = false
},
trailingIcon = if (option.id == selected?.id) {
{ Icon(Icons.Default.Check, contentDescription = null) }
} else null
)
}
}
}
}
}

View file

@ -0,0 +1,53 @@
package com.aryan.reader
import android.net.Uri
import androidx.core.net.toUri
import com.aryan.reader.data.RecentFileItem
import timber.log.Timber
class AndroidFolderPathResolver : FolderPathResolver {
override fun relativeFolderSegments(item: RecentFileItem): List<String> {
val documentUriString = item.uriString ?: return emptyList()
val rootFolderUriString = item.sourceFolderUri ?: return emptyList()
return try {
val documentUri = documentUriString.toUri()
val rootFolderUri = rootFolderUriString.toUri()
val rootDocId = rootFolderUri.treeDocumentIdOrNull() ?: return emptyList()
val documentId = documentUri.documentIdOrNull() ?: return emptyList()
val rootPath = rootDocId.substringAfter(':', "")
val documentPath = documentId.substringAfter(':', "")
val relativeDocumentPath = when {
rootPath.isBlank() -> documentPath
documentPath == rootPath -> ""
documentPath.startsWith("$rootPath/") -> documentPath.removePrefix("$rootPath/")
else -> documentPath
}
relativeDocumentPath
.substringBeforeLast('/', "")
.split('/')
.map { Uri.decode(it).trim() }
.filter { it.isNotEmpty() }
} catch (e: Exception) {
Timber.tag("FolderShelves").w(e, "Failed to derive relative folder path for ${item.displayName}")
emptyList()
}
}
private fun Uri.treeDocumentIdOrNull(): String? {
val segments = pathSegments
val treeIndex = segments.indexOf("tree")
return segments.getOrNull(treeIndex + 1)
}
private fun Uri.documentIdOrNull(): String? {
val segments = pathSegments
val documentIndex = segments.indexOf("document")
if (documentIndex >= 0) {
return segments.getOrNull(documentIndex + 1)
}
return treeDocumentIdOrNull()
}
}

View file

@ -43,13 +43,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import com.aryan.reader.epubreader.EpubReaderScreen
import com.aryan.reader.feedback.FeedbackScreen
import com.aryan.reader.feedback.SupportProjectScreen
import com.aryan.reader.pdf.PdfViewerScreen
import kotlinx.coroutines.delay
object AppDestinations {
const val MAIN_ROUTE = "main"
@ -57,19 +62,74 @@ object AppDestinations {
const val EPUB_READER_ROUTE = "epub_reader"
const val PRO_SCREEN_ROUTE = "pro_screen"
const val FEEDBACK_SCREEN_ROUTE = "feedback_screen_route"
const val SUPPORT_PROJECT_SCREEN_ROUTE = "support_project_screen_route"
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
}
private fun NavHostController.isReadyForBackStackChange(): Boolean {
return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
}
private suspend fun NavHostController.awaitReadyForBackStackChange() {
while (!isReadyForBackStackChange()) {
delay(32)
}
}
private fun NavHostController.navigateSingleTopTo(route: String) {
navigate(route) {
launchSingleTop = true
restoreState = true
popUpTo(graph.startDestinationId) {
saveState = true
if (!isReadyForBackStackChange()) {
Timber.d("Skipping navigation to $route because the current entry is not resumed yet.")
return
}
try {
navigate(route) {
launchSingleTop = true
popUpTo(graph.startDestinationId) {
saveState = false
}
}
} catch (e: IllegalStateException) {
Timber.w(e, "Navigation to $route ignored because the back stack is mid-transition.")
}
}
private fun NavHostController.navigateToMain() {
navigateSingleTopTo(AppDestinations.MAIN_ROUTE)
}
private fun NavHostController.navigateIfReady(route: String) {
if (currentDestination?.route == route) return
navigateSingleTopTo(route)
}
private fun NavHostController.popBackStackIfReady(): Boolean {
if (!isReadyForBackStackChange()) {
Timber.d("Skipping popBackStack because the current entry is not resumed yet.")
return false
}
return try {
popBackStack()
} catch (e: IllegalStateException) {
Timber.w(e, "popBackStack ignored because the back stack is mid-transition.")
false
}
}
private suspend fun NavHostController.syncRouteTo(route: String) {
awaitReadyForBackStackChange()
if (currentDestination?.route != route) {
if (route == AppDestinations.MAIN_ROUTE) {
navigateToMain()
} else {
navigateSingleTopTo(route)
}
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
@ -80,34 +140,31 @@ fun AppNavigation(
) {
Timber.d("AppNavigation composable invoked.")
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackStackEntry?.destination?.route
LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) {
try {
when (uiState.selectedFileType) {
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
if (uiState.selectedPdfUri != null) {
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
navController.navigateSingleTopTo(AppDestinations.PDF_VIEWER_ROUTE)
}
}
}
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> {
if (uiState.selectedEpubBook != null) {
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
navController.navigateSingleTopTo(AppDestinations.EPUB_READER_ROUTE)
}
}
}
null -> {
val currentRoute = navController.currentBackStackEntry?.destination?.route
if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) {
navController.navigateSingleTopTo(AppDestinations.MAIN_ROUTE)
when (uiState.selectedFileType) {
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
if (uiState.selectedPdfUri != null) {
if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) {
navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE)
}
}
}
} catch (e: IllegalStateException) {
Timber.w(e, "Navigation transition already in progress, ignoring.")
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> {
if (uiState.selectedEpubBook != null) {
if (currentRoute != AppDestinations.EPUB_READER_ROUTE) {
navController.syncRouteTo(AppDestinations.EPUB_READER_ROUTE)
}
}
}
null -> {
if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) {
navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
}
}
}
}
}
@ -153,7 +210,7 @@ fun AppNavigation(
}
},
onNavigateToPro = {
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE)
},
viewModel = viewModel
)
@ -226,7 +283,7 @@ fun AppNavigation(
}
},
onNavigateToPro = {
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE)
},
onRenderModeChange = viewModel::setRenderMode,
customFonts = customFonts,
@ -276,7 +333,7 @@ fun AppNavigation(
composable(route = AppDestinations.PRO_SCREEN_ROUTE) {
ProScreen(
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
onNavigateBack = { navController.popBackStackIfReady() }
)
}
@ -286,10 +343,22 @@ fun AppNavigation(
)
}
composable(route = AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) {
SupportProjectScreen(
navController = navController
)
}
composable(route = AppDestinations.FONTS_SCREEN_ROUTE) {
FontsScreen(
viewModel = viewModel,
onBackClick = { navController.popBackStack() }
onBackClick = { navController.popBackStackIfReady() }
)
}
composable(route = AppDestinations.AI_SETTINGS_SCREEN_ROUTE) {
AiSettingsScreen(
onBackClick = { navController.popBackStackIfReady() }
)
}
}

View file

@ -0,0 +1,127 @@
package com.aryan.reader
import android.net.Uri
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.epub.CalibreBundleResult
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.paginatedreader.Locator
import java.util.Date
data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false)
data class ImportResult(
val internalUri: Uri,
val bookId: String,
val type: FileType,
val bundleResult: CalibreBundleResult? = null
)
data class UserData(
val uid: String,
val displayName: String?,
val photoUrl: String?,
val email: String?
)
data class NavigationEvent(
val route: String,
val bookId: String? = null,
val uri: Uri? = null
)
enum class AppThemeMode {
SYSTEM,
LIGHT,
DARK
}
enum class AppContrastOption(val value: Double) {
STANDARD(0.0),
MEDIUM(0.5),
HIGH(1.0)
}
data class CustomAppTheme(
val id: String,
val name: String,
val seedColor: androidx.compose.ui.graphics.Color
)
data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?)
data class DeviceLimitReachedState(
val isLimitReached: Boolean = false,
val registeredDevices: List<DeviceItem> = emptyList()
)
data class ReaderScreenState(
val selectedPdfUri: Uri? = null,
val selectedBookId: String? = null,
val selectedEpubBook: EpubBook? = null,
val selectedEpubUri: Uri? = null,
val selectedFileType: FileType? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val contextualActionItems: Set<RecentFileItem> = emptySet(),
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
val sortOrder: SortOrder = SortOrder.RECENT,
val initialLocator: Locator? = null,
val initialCfi: String? = null,
val initialBookmarksJson: String? = null,
val initialHighlightsJson: String? = null,
val initialPageInBook: Int? = null,
val shelves: List<Shelf> = emptyList(),
val viewingShelfId: String? = null,
val isAddingBooksToShelf: Boolean = false,
val showCreateShelfDialog: Boolean = false,
val mainScreenStartPage: Int = 0,
val libraryScreenStartPage: Int = 0,
val showRenameShelfDialogFor: String? = null,
val showDeleteShelfDialogFor: String? = null,
val addBooksSource: AddBooksSource = AddBooksSource.UNSHELVED,
val booksSelectedForAdding: Set<String> = emptySet(),
val booksAvailableForAdding: List<RecentFileItem> = emptyList(),
val contextualActionShelfIds: Set<String> = emptySet(),
val currentUser: UserData? = null,
val isAuthMenuExpanded: Boolean = false,
val isProUser: Boolean = false,
val credits: Int = 0,
val isSyncEnabled: Boolean = false,
val isFolderSyncEnabled: Boolean = false,
val bannerMessage: BannerMessage? = null,
val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(),
val isReplacingDevice: Boolean = false,
val isRequestingDrivePermission: Boolean = false,
val downloadingBookIds: Set<String> = emptySet(),
val uploadingBookIds: Set<String> = emptySet(),
val syncedFolders: List<SyncedFolder> = emptyList(),
val lastFolderScanTime: Long? = null,
val hasUnreadFeedback: Boolean = false,
val searchQuery: String = "",
val isSearchActive: Boolean = false,
val isRefreshing: Boolean = false,
val reflowProgress: Float? = null,
val recentFiles: List<RecentFileItem> = emptyList(),
val allRecentFiles: List<RecentFileItem> = emptyList(),
val rawLibraryFiles: List<RecentFileItem> = emptyList(),
val pinnedHomeBookIds: Set<String> = emptySet(),
val pinnedLibraryBookIds: Set<String> = emptySet(),
val libraryFilters: LibraryFilters = LibraryFilters(),
val recentFilesLimit: Int = 0,
val isTabsEnabled: Boolean = false,
val openTabIds: List<String> = emptyList(),
val openTabs: List<RecentFileItem> = emptyList(),
val activeTabBookId: String? = null,
val showExternalFileSavePromptFor: String? = null,
val externalFileBehavior: String = "ASK",
val useStrictFileFilter: Boolean = false,
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
val appTextDimFactorLight: Float = 1.0f,
val appTextDimFactorDark: Float = 1.0f,
val appSeedColor: androidx.compose.ui.graphics.Color? = null,
val customAppThemes: List<CustomAppTheme> = emptyList(),
val allTags: List<TagEntity> = emptyList(),
val showTagSelectionDialogFor: Set<String> = emptySet(),
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,94 @@
package com.aryan.reader
private val codeOrDataExtensions = setOf(
"csv",
"tsv",
"json",
"xml",
"log",
"java",
"kt",
"py",
"js",
"cpp",
"c",
"cs",
"rb",
"go"
)
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val effectiveName = lowerName.withTransparentTextSuffix()
return when {
effectiveName.endsWith(".cbz") -> FileType.CBZ
effectiveName.endsWith(".cbr") -> FileType.CBR
effectiveName.endsWith(".cb7") -> FileType.CB7
effectiveName.endsWith(".pdf") -> FileType.PDF
effectiveName.endsWith(".epub") -> FileType.EPUB
effectiveName.endsWith(".mobi") || effectiveName.endsWith(".azw3") || effectiveName.endsWith(".prc") -> FileType.MOBI
effectiveName.endsWith(".fb2") || effectiveName.endsWith(".fb2.zip") -> FileType.FB2
effectiveName.endsWith(".md") || effectiveName.endsWith(".markdown") -> FileType.MD
effectiveName.endsWith(".html") || effectiveName.endsWith(".xhtml") || effectiveName.endsWith(".htm") -> FileType.HTML
effectiveName.endsWith(".docx") -> FileType.DOCX
effectiveName.endsWith(".odt") -> FileType.ODT
effectiveName.endsWith(".fodt") -> FileType.FODT
effectiveName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML
effectiveName.endsWith(".txt") -> FileType.TXT
else -> null
}
}
internal fun isCodeOrDataFileName(fileName: String): Boolean {
return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
}
internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val effectiveName = lowerName.withTransparentTextSuffix()
val effectiveSuffix = when {
effectiveName.endsWith(".fb2.zip") -> ".fb2.zip"
effectiveName.endsWith(".markdown") -> ".markdown"
effectiveName.endsWith(".xhtml") -> ".xhtml"
effectiveName.extensionAfterLastDot() != null && resolveFileTypeFromName(effectiveName) != null -> ".${effectiveName.extensionAfterLastDot()}"
else -> null
} ?: return null
return if (effectiveName != lowerName && lowerName.endsWith(".txt")) {
"$effectiveSuffix.txt"
} else {
effectiveSuffix
}
}
private fun String.withTransparentTextSuffix(): String {
if (!endsWith(".txt")) return this
val innerName = removeSuffix(".txt")
if (innerName.isBlank() || !innerName.contains('.')) return this
return if (resolveFileTypeFromNameWithoutTransparentText(innerName) != null) innerName else this
}
private fun resolveFileTypeFromNameWithoutTransparentText(fileName: String): FileType? {
return when {
fileName.endsWith(".cbz") -> FileType.CBZ
fileName.endsWith(".cbr") -> FileType.CBR
fileName.endsWith(".cb7") -> FileType.CB7
fileName.endsWith(".pdf") -> FileType.PDF
fileName.endsWith(".epub") -> FileType.EPUB
fileName.endsWith(".mobi") || fileName.endsWith(".azw3") || fileName.endsWith(".prc") -> FileType.MOBI
fileName.endsWith(".fb2") || fileName.endsWith(".fb2.zip") -> FileType.FB2
fileName.endsWith(".md") || fileName.endsWith(".markdown") -> FileType.MD
fileName.endsWith(".html") || fileName.endsWith(".xhtml") || fileName.endsWith(".htm") -> FileType.HTML
fileName.endsWith(".docx") -> FileType.DOCX
fileName.endsWith(".odt") -> FileType.ODT
fileName.endsWith(".fodt") -> FileType.FODT
fileName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML
else -> null
}
}
private fun String.extensionAfterLastDot(): String? {
val dotIndex = lastIndexOf('.')
return if (dotIndex in 0..<lastIndex) substring(dotIndex + 1) else null
}

View file

@ -24,11 +24,11 @@ import android.content.Context
import timber.log.Timber
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters
import androidx.work.WorkManager
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository
import kotlinx.coroutines.Dispatchers
@ -53,11 +53,15 @@ class FolderSyncWorker(
const val WORK_NAME = "FolderSyncWorker"
const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime"
const val KEY_METADATA_ONLY = "key_metadata_only"
const val KEY_TARGET_FOLDER_URI = "key_target_folder_uri"
private const val SCAN_DB_BATCH_SIZE = 600
private val syncMutex = Mutex()
}
override suspend fun doWork(): Result {
val workerStart = ReaderPerfLog.nowNanos()
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val jsonString = prefs.getString("synced_folders_list_json", null)
@ -87,17 +91,31 @@ class FolderSyncWorker(
}
if (folders.isEmpty()) {
Timber.tag("FolderSync").w("Worker: No folders linked. Aborting.")
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
return Result.success()
}
Timber.tag("FolderSync").d("Worker: processing ${folders.size} folders.")
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
folders
} else {
folders.filter { it.first == targetFolderUri }
}
if (foldersToProcess.isEmpty()) {
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked target=$targetFolderUri")
return Result.success()
}
ReaderPerfLog.d(
"FolderSync worker start folders=${foldersToProcess.size}/${folders.size} " +
"target=${targetFolderUri ?: "ALL"} metadataOnly=$isMetadataOnly"
)
return withContext(Dispatchers.IO) {
syncMutex.withLock {
var allSuccess = true
for ((uriString, allowedTypes) in folders) {
for ((uriString, allowedTypes) in foldersToProcess) {
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
if (!success) allSuccess = false
}
@ -107,12 +125,21 @@ class FolderSyncWorker(
val array = org.json.JSONArray(jsonString)
val now = System.currentTimeMillis()
for (i in 0 until array.length()) {
array.getJSONObject(i).put("lastScanTime", now)
val obj = array.getJSONObject(i)
if (targetFolderUri.isNullOrBlank() || obj.optString("uri") == targetFolderUri) {
obj.put("lastScanTime", now)
}
}
prefs.edit { putString("synced_folders_list_json", array.toString()) }
} catch (_: Exception) {}
}
val elapsed = ReaderPerfLog.elapsedMs(workerStart)
ReaderPerfLog.i(
"FolderSync worker finished status=${if (allSuccess) "success" else "failure"} " +
"folders=${foldersToProcess.size} elapsed=${elapsed}ms"
)
if (allSuccess) Result.success() else Result.failure()
}
}
@ -121,8 +148,24 @@ class FolderSyncWorker(
private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
if (folderUriString.isBlank()) return true
val folderUri = folderUriString.toUri()
val folderStart = ReaderPerfLog.nowNanos()
var dirsScanned = 0
var filesSeen = 0
var supportedBooksSeen = 0
var newBooks = 0
var updatedBooks = 0
var unchangedBooks = 0
var dbFlushes = 0
var scanDbFlushes = 0
var sidecarsImported = 0
var stoppedForUnlinkedFolder = false
try {
if (!isFolderStillLinked(folderUriString)) {
ReaderPerfLog.w("FolderSync folder skipped: no longer linked folder=$folderUriString")
return true
}
try {
appContext.contentResolver.takePersistableUriPermission(
folderUri,
@ -137,17 +180,31 @@ class FolderSyncWorker(
return false
}
Timber.tag("FolderSync").d("Phase 0: Migrating legacy root sidecars to subfolder...")
LocalSyncUtils.migrateLegacySidecarsToSubfolder(appContext, documentTree)
ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration skipped")
Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...")
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
val folderMetadataMap = ReaderPerfLog.measureSuspend(
name = "FolderSync phase metadata-sidecars",
minLogMs = 25L,
details = { "metadataOnly=$metadataOnly" }
) {
LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
}
ReaderPerfLog.d(
"FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString"
)
Timber.tag("FolderSync").d("Phase 1.5: Preloading annotation sidecars...")
val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree).toMutableMap()
val preloadedSidecars = mutableMapOf<String, Pair<Long, String>>()
val existingFolderBooks = ReaderPerfLog.measureSuspend(
name = "FolderSync phase load-existing-db",
minLogMs = 25L
) {
recentFilesRepository.getFilesBySourceFolder(folderUriString)
}
val existingFolderBooksById = existingFolderBooks.associateBy { it.bookId }
val remoteMetadataUpdates = mutableListOf<RecentFileItem>()
folderMetadataMap.forEach { (bookId, remoteMeta) ->
val existingItem = recentFilesRepository.getFileByBookId(bookId)
val existingItem = existingFolderBooksById[bookId]
if (existingItem != null) {
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
@ -166,40 +223,28 @@ class FolderSyncWorker(
isRecent = remoteMeta.isRecent || existingItem.isRecent,
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
)
recentFilesRepository.addRecentFile(itemToUpdate)
remoteMetadataUpdates.add(itemToUpdate)
} else {
Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}")
}
}
}
Timber.tag("FolderAnnotationSync").d("Phase 1.5: Checking annotation sidecars for existing local books...")
val processedBookIds = mutableSetOf<String>()
val existingFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
if (remoteMetadataUpdates.isNotEmpty()) {
recentFilesRepository.addRecentFiles(remoteMetadataUpdates)
dbFlushes++
ReaderPerfLog.d(
"FolderSync applied remote metadata updates count=${remoteMetadataUpdates.size} folder=$folderUriString"
)
}
for (book in existingFolderBooks) {
processedBookIds.add(book.bookId)
val sidecarData = preloadedSidecars[book.bookId]
if (sidecarData != null) {
val (remoteTs, jsonPayload) = sidecarData
val localFiles = listOf(
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"),
File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json")
)
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
if (remoteTs > (localTs + 1000)) {
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
} else {
Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.")
}
}
if (metadataOnly) {
sidecarsImported += importAnnotationSidecarsForBooks(
folderUri = folderUri,
folderUriString = folderUriString,
books = existingFolderBooks,
phase = "metadata-only"
)
}
if (!metadataOnly) {
@ -207,7 +252,17 @@ class FolderSyncWorker(
val contentResolver = appContext.contentResolver
val foundBookIds = mutableSetOf<String>()
val newOrUpdatedItems = mutableListOf<RecentFileItem>()
val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
val existingItemsMap = existingFolderBooksById.toMutableMap()
val existingItemsByUri = existingFolderBooks
.mapNotNull { item -> item.uriString?.let { uri -> uri to item } }
.toMap()
val legacyItemsByName = existingFolderBooks
.asSequence()
.filter { it.bookId.startsWith("local_${it.displayName}_") }
.groupBy { it.displayName }
.mapValues { entry ->
ArrayDeque<RecentFileItem>().apply { addAll(entry.value) }
}
val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
val dirQueue = ArrayDeque<String>()
@ -223,7 +278,13 @@ class FolderSyncWorker(
while (dirQueue.isNotEmpty()) {
if (isStopped) break
if (!isFolderStillLinked(folderUriString)) {
ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString")
stoppedForUnlinkedFolder = true
break
}
val currentDocId = dirQueue.removeFirst()
dirsScanned++
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
try {
@ -234,10 +295,17 @@ class FolderSyncWorker(
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
while (cursor.moveToNext() && !isStopped) {
while (cursor.moveToNext() && !isStopped && !stoppedForUnlinkedFolder) {
val docId = cursor.getString(idCol)
val name = cursor.getString(nameCol) ?: ""
val mimeType = cursor.getString(mimeCol)
filesSeen++
if (filesSeen % 100 == 0 && !isFolderStillLinked(folderUriString)) {
ReaderPerfLog.w("FolderSync folder abort: folder unlinked after entries=$filesSeen folder=$folderUriString")
stoppedForUnlinkedFolder = true
break
}
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
if (!name.startsWith(".") && name != "EpistemeSyncData") {
@ -249,13 +317,15 @@ class FolderSyncWorker(
val type = getFileType(name, mimeType)
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
supportedBooksSeen++
val stableId = buildStableBookId(name, rootDocId, docId)
foundBookIds.add(stableId)
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
val docUriString = docUri.toString()
var existingItem = existingItemsMap[stableId]
if (existingItem != null && existingItem.uriString != docUri.toString()) {
if (existingItem != null && existingItem.uriString != docUriString) {
val collidedItem = existingItem
val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId)
if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) {
@ -273,12 +343,10 @@ class FolderSyncWorker(
}
if (existingItem == null) {
val oldItem = existingItemsMap.values.find {
it.bookId != stableId && (
it.uriString == docUri.toString() ||
it.bookId.startsWith("local_${name}_")
)
}
val oldItem = existingItemsByUri[docUriString]?.takeIf { it.bookId != stableId }
?: legacyItemsByName[name]?.firstOrNull {
it.bookId != stableId
}
if (oldItem != null) {
val oldId = oldItem.bookId
Timber.tag("FolderSync").i("Migrating book ID for $name from $oldId to $stableId")
@ -291,6 +359,7 @@ class FolderSyncWorker(
preloadedSidecars = preloadedSidecars,
existingItemsMap = existingItemsMap
)
legacyItemsByName[name]?.remove(oldItem)
existingItem = existingItemsMap[stableId]
}
}
@ -324,6 +393,7 @@ class FolderSyncWorker(
fileSize = size
)
newOrUpdatedItems.add(newItem)
newBooks++
} else {
var needsUpdate = false
var updatedItem = existingItem
@ -331,7 +401,11 @@ class FolderSyncWorker(
if (existingItem.fileSize > 0L && size > 0L && existingItem.fileSize != size) {
Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).")
recentFilesRepository.clearLocalCachesForBook(stableId)
updatedItem = updatedItem.copy(fileSize = size, lastModifiedTimestamp = lastModified)
updatedItem = updatedItem.copy(
fileSize = size,
lastModifiedTimestamp = lastModified,
folderTextMetadataParsed = false
)
needsUpdate = true
}
@ -347,32 +421,26 @@ class FolderSyncWorker(
if (needsUpdate) {
newOrUpdatedItems.add(updatedItem)
updatedBooks++
} else {
unchangedBooks++
}
}
if (newOrUpdatedItems.size >= 50) {
val batchLimit = if (scanDbFlushes == 0) 40 else SCAN_DB_BATCH_SIZE
if (newOrUpdatedItems.size >= batchLimit) {
if (!isFolderStillLinked(folderUriString)) {
ReaderPerfLog.w("FolderSync batch dropped: folder unlinked pending=${newOrUpdatedItems.size} folder=$folderUriString")
newOrUpdatedItems.clear()
stoppedForUnlinkedFolder = true
break
}
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
dbFlushes++
scanDbFlushes++
newOrUpdatedItems.clear()
}
if (!processedBookIds.contains(stableId)) {
val sidecarData = preloadedSidecars[stableId]
if (sidecarData != null) {
val (remoteTs, jsonPayload) = sidecarData
val localFiles = listOf(
File(appContext.filesDir, "annotations/annotation_$stableId.json"),
File(appContext.filesDir, "pdf_rich_text/text_$stableId.json"),
File(appContext.filesDir, "page_layouts/layout_$stableId.json"),
File(appContext.filesDir, "pdf_text_boxes/boxes_$stableId.json")
)
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
if (remoteTs > (localTs + 1000)) {
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for new book $stableId. Importing.")
recentFilesRepository.importAnnotationBundle(stableId, jsonPayload)
}
}
}
}
}
}
@ -380,16 +448,19 @@ class FolderSyncWorker(
} catch (e: Exception) {
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
}
if (stoppedForUnlinkedFolder) break
}
if (newOrUpdatedItems.isNotEmpty()) {
if (!stoppedForUnlinkedFolder && newOrUpdatedItems.isNotEmpty()) {
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
dbFlushes++
scanDbFlushes++
newOrUpdatedItems.clear()
}
if (!isStopped) {
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
if (!isStopped && !stoppedForUnlinkedFolder) {
val idsToRemove = existingItemsMap.keys.filter { it !in foundBookIds }
if (idsToRemove.isNotEmpty()) {
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
@ -398,16 +469,50 @@ class FolderSyncWorker(
}
}
if (!isStopped) {
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
WorkManager.getInstance(appContext).enqueueUniqueWork(
MetadataExtractionWorker.WORK_NAME,
ExistingWorkPolicy.APPEND_OR_REPLACE,
metaRequest
if (!metadataOnly && !isStopped && !stoppedForUnlinkedFolder) {
val booksForAnnotationSync = ReaderPerfLog.measureSuspend(
name = "FolderSync phase load-post-scan-db",
minLogMs = 25L
) {
recentFilesRepository.getFilesBySourceFolder(folderUriString)
}
sidecarsImported += importAnnotationSidecarsForBooks(
folderUri = folderUri,
folderUriString = folderUriString,
books = booksForAnnotationSync,
phase = "post-scan"
)
}
val elapsed = ReaderPerfLog.elapsedMs(folderStart)
ReaderPerfLog.i(
"FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " +
"dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " +
"new=$newBooks updated=$updatedBooks unchanged=$unchangedBooks " +
"dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " +
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
)
if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
ReaderPerfLog.i("FolderSync enqueue text metadata extraction folder=$folderUriString")
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
.setInputData(
androidx.work.Data.Builder()
.putString(MetadataExtractionWorker.KEY_SOURCE_FOLDER_URI, folderUriString)
.build()
)
.build()
WorkManager.getInstance(appContext).enqueueUniqueWork(
MetadataExtractionWorker.WORK_NAME,
ExistingWorkPolicy.REPLACE,
metaRequest
)
} else {
ReaderPerfLog.d("FolderSync text metadata extraction skipped: no pending books folder=$folderUriString")
}
}
return true
} catch (e: Exception) {
@ -416,23 +521,87 @@ class FolderSyncWorker(
}
}
private suspend fun importAnnotationSidecarsForBooks(
folderUri: android.net.Uri,
folderUriString: String,
books: List<RecentFileItem>,
phase: String
): Int {
if (books.isEmpty()) {
ReaderPerfLog.d("FolderSync phase annotation-sidecars skipped phase=$phase reason=no-books folder=$folderUriString")
return 0
}
val preloadedSidecars = ReaderPerfLog.measureSuspend(
name = "FolderSync phase annotation-sidecars",
minLogMs = 25L,
details = { "phase=$phase" }
) {
LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri)
}
ReaderPerfLog.d(
"FolderSync annotation-sidecars records=${preloadedSidecars.size} books=${books.size} phase=$phase folder=$folderUriString"
)
if (preloadedSidecars.isEmpty()) return 0
var imported = 0
Timber.tag("FolderAnnotationSync").d("Checking annotation sidecars phase=$phase for ${books.size} books...")
for (book in books) {
if (isStopped || !isFolderStillLinked(folderUriString)) break
val sidecarData = preloadedSidecars[book.bookId] ?: continue
val (remoteTs, jsonPayload) = sidecarData
val localFiles = listOf(
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"),
File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json")
)
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
if (remoteTs > (localTs + 1000)) {
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
imported++
} else {
Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.")
}
}
ReaderPerfLog.i(
"FolderSync annotation-sidecars imported=$imported records=${preloadedSidecars.size} phase=$phase folder=$folderUriString"
)
return imported
}
private fun isFolderStillLinked(folderUriString: String): Boolean {
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val jsonString = prefs.getString("synced_folders_list_json", null)
if (jsonString != null) {
return try {
val array = org.json.JSONArray(jsonString)
(0 until array.length()).any { index ->
array.getJSONObject(index).optString("uri") == folderUriString
}
} catch (_: Exception) {
false
}
}
return prefs.getString("synced_folder_uri", null) == folderUriString
}
private fun getFileType(name: String, mimeType: String?): FileType? {
val lowerName = name.lowercase()
return when {
mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF
mimeType == "application/epub+zip" || lowerName.endsWith(".epub") -> FileType.EPUB
mimeType == "application/vnd.oasis.opendocument.text" || lowerName.endsWith(".odt") -> FileType.ODT
mimeType == "application/x-vnd.oasis.opendocument.text-flat-xml" || lowerName.endsWith(".fodt") -> FileType.FODT
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || lowerName.endsWith(".docx") -> FileType.DOCX
lowerName.endsWith(".mobi") || lowerName.endsWith(".azw3") || lowerName.endsWith(".prc") -> FileType.MOBI
lowerName.endsWith(".fb2") || lowerName.endsWith(".fb2.zip") -> FileType.FB2
lowerName.endsWith(".cbz") -> FileType.CBZ
lowerName.endsWith(".cbr") -> FileType.CBR
lowerName.endsWith(".cb7") -> FileType.CB7
lowerName.endsWith(".md") || lowerName.endsWith(".markdown") -> FileType.MD
lowerName.endsWith(".txt") -> FileType.TXT
mimeType == "text/html" || lowerName.endsWith(".html") || lowerName.endsWith(".xhtml") || lowerName.endsWith(".htm") -> FileType.HTML
else -> null
return when (mimeType) {
"application/pdf" -> FileType.PDF
"application/epub+zip" -> FileType.EPUB
"application/vnd.oasis.opendocument.text" -> FileType.ODT
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
"text/html", "application/xhtml+xml" -> FileType.HTML
else -> resolveFileTypeFromName(name)
}
}

View file

@ -73,6 +73,7 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
@ -134,6 +135,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.request.ImageRequest
@ -151,11 +153,17 @@ internal fun Context.findActivity(): Activity? = when (this) {
else -> null
}
@UnstableApi
@androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
viewModel: MainViewModel, windowSizeClass: WindowSizeClass, navController: NavHostController
) {
val compStart = remember { System.currentTimeMillis() }
LaunchedEffect(Unit) {
ReaderPerfLog.d("HomeScreen initial composition ${System.currentTimeMillis() - compStart}ms")
}
val context = LocalContext.current
val customTabUriHandler = remember { CustomTabUriHandler(context) }
var showCloseAllTabsDialog by remember { mutableStateOf(false) }
@ -163,14 +171,15 @@ fun HomeScreen(
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val recentFilesForHome = uiState.recentFiles.filter { it.isRecent }
val openTabs = uiState.openTabs
val selectedContextItems = uiState.contextualActionItems
val isContextualModeActive = selectedContextItems.isNotEmpty()
val screenModel = remember(uiState) { uiState.toHomeScreenModel() }
val recentFilesForHome = screenModel.recentFiles
val openTabs = screenModel.openTabs
val selectedContextItems = screenModel.selectedItems
val isContextualModeActive = screenModel.isContextualModeActive
val scope = rememberCoroutineScope()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val snackbarHostState = remember { SnackbarHostState() }
val deviceLimitState = uiState.deviceLimitState
val deviceLimitState = screenModel.deviceLimitState
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
var showClearCloudDataDialog by remember { mutableStateOf(false) }
@ -304,6 +313,12 @@ fun HomeScreen(
navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE)
}
},
onAiSettingsClick = {
scope.launch {
drawerState.close()
navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE)
}
},
navController = navController,
onFolderSyncToggle = viewModel::setFolderSyncEnabled
)
@ -341,7 +356,10 @@ fun HomeScreen(
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
onLanguageClick = { showLanguageDialog = true },
onExportLogsClick = { viewModel.exportLogsToFile(context) }
onExportLogsClick = { viewModel.exportLogsToFile(context) },
onToggleHideReaderAi = {
saveHideReaderAiFeatures(context, !loadHideReaderAiFeatures(context))
}
)
} else {
ContextualTopAppBar(
@ -367,8 +385,8 @@ fun HomeScreen(
.fillMaxSize()
.padding(paddingValues)
) {
if (recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty())) {
if (uiState.recentFiles.isEmpty()) {
if (screenModel.isEmpty) {
if (screenModel.isLibraryEmpty) {
EmptyState(
title = stringResource(R.string.your_library_empty),
message = stringResource(R.string.your_library_empty_desc),
@ -528,7 +546,8 @@ fun HomeScreen(
uiState = uiState,
onThemeModeChanged = viewModel::setAppThemeMode,
onContrastOptionChanged = viewModel::setAppContrastOption,
onTextDimFactorChanged = viewModel::setAppTextDimFactor,
onTextDimFactorLightChanged = viewModel::setAppTextDimFactorLight,
onTextDimFactorDarkChanged = viewModel::setAppTextDimFactorDark,
onSeedColorChanged = viewModel::setAppSeedColor,
onCustomThemeAdded = viewModel::addCustomAppTheme,
onCustomThemeDeleted = viewModel::deleteCustomAppTheme,
@ -596,6 +615,9 @@ private fun RecentFilesContent(
hasSyncedFolder: Boolean
) {
val canRefresh = isSyncEnabled || hasSyncedFolder
val selectedItemUris = remember(selectedContextItems) {
selectedContextItems.mapNotNullTo(mutableSetOf()) { it.uriString }
}
val content = @Composable {
Box(modifier = Modifier.fillMaxSize()) {
@ -608,7 +630,7 @@ private fun RecentFilesContent(
isTabsEnabled = isTabsEnabled,
onTabCloseClick = onTabCloseClick,
onCloseAllTabsClick = onCloseAllTabsClick,
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
selectedItemUris = selectedItemUris,
pinnedHomeBookIds = pinnedHomeBookIds,
onItemClick = onItemClick,
onItemLongClick = onItemLongClick,
@ -996,10 +1018,13 @@ fun DefaultTopAppBar(
onTestPanelDetectionClick: () -> Unit,
onTestSpeechBubbleDetectionClick: () -> Unit,
onLanguageClick: () -> Unit,
onExportLogsClick: () -> Unit
onExportLogsClick: () -> Unit,
onToggleHideReaderAi: () -> Unit
) {
var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu by remember { mutableStateOf(false) }
val context = LocalContext.current
var hideReaderAiFeatures by remember { mutableStateOf(loadHideReaderAiFeatures(context)) }
CustomTopAppBar(title = { }, navigationIcon = {
IconButton(onClick = onDrawerClick) {
@ -1086,6 +1111,20 @@ fun DefaultTopAppBar(
showOptionsMenu = false
})
DropdownMenuItem(
text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") },
onClick = {
onToggleHideReaderAi()
hideReaderAiFeatures = !hideReaderAiFeatures
showOptionsMenu = false
},
trailingIcon = {
if (hideReaderAiFeatures) {
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
}
}
)
HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
onClearCache()
@ -1141,6 +1180,7 @@ private fun AppDrawerContent(
onUpgradeClick: () -> Unit,
onSyncUpsellClick: () -> Unit,
onFontsClick: () -> Unit,
onAiSettingsClick: () -> Unit,
navController: NavHostController,
onFolderSyncToggle: (Boolean) -> Unit
) {
@ -1311,6 +1351,26 @@ private fun AppDrawerContent(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
if (isOss && !BuildConfig.IS_OFFLINE) {
NavigationDrawerItem(
icon = { Icon(painterResource(id = R.drawable.ai), contentDescription = null) },
label = { Text("AI keys and models") },
selected = false,
onClick = onAiSettingsClick,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
if (isOss) {
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.FavoriteBorder, contentDescription = null) },
label = { Text(stringResource(R.string.drawer_support_project)) },
selected = false,
onClick = { navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
NavigationDrawerItem(
icon = { Icon(painterResource(id = R.drawable.feedback), contentDescription = null) },
label = { Text(stringResource(R.string.drawer_help_feedback)) },
@ -1692,7 +1752,8 @@ fun AppThemeBottomSheet(
uiState: ReaderScreenState,
onThemeModeChanged: (AppThemeMode) -> Unit,
onContrastOptionChanged: (AppContrastOption) -> Unit,
onTextDimFactorChanged: (Float) -> Unit,
onTextDimFactorLightChanged: (Float) -> Unit,
onTextDimFactorDarkChanged: (Float) -> Unit,
onSeedColorChanged: (Color?) -> Unit,
onCustomThemeAdded: (CustomAppTheme) -> Unit,
onCustomThemeDeleted: (String) -> Unit,
@ -1756,24 +1817,68 @@ fun AppThemeBottomSheet(
Spacer(Modifier.height(24.dp))
Text(stringResource(R.string.app_theme_text_brightness), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
androidx.compose.material3.Slider(
value = uiState.appTextDimFactor,
onValueChange = onTextDimFactorChanged,
valueRange = 0.3f..1.0f,
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
)
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
if (uiState.appThemeMode == AppThemeMode.SYSTEM) {
Text("${stringResource(R.string.app_theme_text_brightness)} (Light)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
androidx.compose.material3.Slider(
value = uiState.appTextDimFactorLight,
onValueChange = onTextDimFactorLightChanged,
valueRange = 0.3f..1.0f,
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
)
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
}
Spacer(Modifier.height(16.dp))
Text("${stringResource(R.string.app_theme_text_brightness)} (Dark)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
androidx.compose.material3.Slider(
value = uiState.appTextDimFactorDark,
onValueChange = onTextDimFactorDarkChanged,
valueRange = 0.3f..1.0f,
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
)
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
}
} else {
Text(stringResource(R.string.app_theme_text_brightness), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
androidx.compose.material3.Slider(
value = if (uiState.appThemeMode == AppThemeMode.DARK) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight,
onValueChange = if (uiState.appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged,
valueRange = 0.3f..1.0f,
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
)
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
}
}
Spacer(Modifier.height(24.dp))

View file

@ -0,0 +1,86 @@
package com.aryan.reader
import com.aryan.reader.data.RecentFileItem
enum class AddBooksSource {
UNSHELVED,
ALL_BOOKS
}
enum class FileType {
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT
}
internal val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7)
internal val EPUB_READER_FILE_TYPES = setOf(
FileType.EPUB,
FileType.MOBI,
FileType.MD,
FileType.TXT,
FileType.HTML,
FileType.FB2,
FileType.DOCX,
FileType.ODT,
FileType.FODT
)
enum class RenderMode {
VERTICAL_SCROLL, PAGINATED
}
data class SyncedFolder(
val uriString: String,
val name: String,
val lastScanTime: Long,
val allowedFileTypes: Set<FileType> = FileType.entries.toSet()
)
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER }
data class Shelf(
val id: String,
val name: String,
val type: ShelfType,
val books: List<RecentFileItem>,
val directBooks: List<RecentFileItem> = books,
val parentShelfId: String? = null,
val childShelfIds: List<String> = emptyList(),
val depth: Int = 0,
val sortKey: String = name.lowercase()
) {
val bookCount: Int get() = books.size
val topBook: RecentFileItem? by lazy(LazyThreadSafetyMode.NONE) { books.maxByOrNull { it.timestamp } }
val directBookCount: Int get() = directBooks.size
val childShelfCount: Int get() = childShelfIds.size
}
enum class SortOrder {
RECENT,
TITLE_ASC,
AUTHOR_ASC,
PERCENT_ASC,
PERCENT_DESC,
SIZE_ASC,
SIZE_DESC
}
enum class ReadStatusFilter {
ALL,
UNREAD,
IN_PROGRESS,
COMPLETED
}
data class LibraryFilters(
val fileTypes: Set<FileType> = emptySet(),
val sourceFolders: Set<String> = emptySet(),
val readStatus: ReadStatusFilter = ReadStatusFilter.ALL,
val tagIds: Set<String> = emptySet()
) {
val isActive: Boolean
get() = fileTypes.isNotEmpty() ||
sourceFolders.isNotEmpty() ||
readStatus != ReadStatusFilter.ALL ||
tagIds.isNotEmpty()
}

View file

@ -56,6 +56,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
@ -131,6 +132,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem
@ -141,6 +143,8 @@ import com.aryan.reader.opds.OpdsEntry
import com.aryan.reader.opds.OpdsViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import org.jsoup.Jsoup
import timber.log.Timber
@ -154,20 +158,26 @@ private fun getBookCountString(count: Int): String {
return pluralStringResource(id = R.plurals.book_count, count, count)
}
@UnstableApi
@SuppressLint("LocalContextGetResourceValueCall")
@Composable
fun LibraryScreen(
viewModel: MainViewModel,
) {
val compStart = remember { System.currentTimeMillis() }
LaunchedEffect(Unit) {
ReaderPerfLog.d("LibraryScreen initial composition ${System.currentTimeMillis() - compStart}ms")
}
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedItems = uiState.contextualActionItems
val isContextualModeActive = selectedItems.isNotEmpty()
val selectedShelves = uiState.contextualActionShelfIds
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
val sortOrder = uiState.sortOrder
val shelves = uiState.shelves
val rawLibraryFiles = uiState.rawLibraryFiles
val screenModel = remember(uiState) { uiState.toLibraryScreenModel() }
val selectedItems = screenModel.selectedItems
val isContextualModeActive = screenModel.isContextualModeActive
val selectedShelves = screenModel.selectedShelves
val isShelfContextualModeActive = screenModel.isShelfContextualModeActive
val sortOrder = screenModel.sortOrder
val shelves = screenModel.shelves
val rawLibraryFiles = screenModel.rawLibraryFiles
val tabTitles = remember {
buildList {
add(context.getString(R.string.tab_all_books))
@ -183,21 +193,13 @@ fun LibraryScreen(
pageCount = { tabTitles.size }
)
val containsFolderItems = remember(selectedItems) {
selectedItems.any { it.sourceFolderUri != null }
}
LaunchedEffect(uiState.libraryScreenStartPage) {
if (pagerState.currentPage != uiState.libraryScreenStartPage) {
pagerState.animateScrollToPage(uiState.libraryScreenStartPage)
}
}
val containsFolderItems = screenModel.containsFolderItemsInSelection
val scope = rememberCoroutineScope()
var showFilterSheet by remember { mutableStateOf(false) }
val isSearchActive = uiState.isSearchActive
val searchQuery = uiState.searchQuery
val isSearchActive = screenModel.isSearchActive
val searchQuery = screenModel.searchQuery
val pickFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
@ -250,6 +252,8 @@ fun LibraryScreen(
LaunchedEffect(pagerState) {
androidx.compose.runtime.snapshotFlow { pagerState.settledPage }
.drop(1)
.distinctUntilChanged()
.collect { page ->
viewModel.setLibraryScreenPage(page)
}
@ -401,6 +405,7 @@ fun LibraryScreen(
}
}
@UnstableApi
@Composable
fun ShelfScreen(
viewModel: MainViewModel,
@ -577,6 +582,7 @@ fun LibraryScreenContent(
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
var showSortMenu by remember { mutableStateOf(false) }
val searchFocusRequester = remember { FocusRequester() }
val selectedBookIds = remember(selectedItems) { selectedItems.mapTo(mutableSetOf()) { it.bookId } }
var textFieldValue by remember(isSearchActive) {
mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
@ -712,7 +718,16 @@ fun LibraryScreenContent(
Tab(
selected = pagerState.currentPage == index,
onClick = {
scope.launch { pagerState.animateScrollToPage(index) }
ReaderPerfLog.d("LibraryPager click page=$index title=$title")
if (pagerState.currentPage != index) {
scope.launch {
val start = ReaderPerfLog.nowNanos()
pagerState.animateScrollToPage(index)
ReaderPerfLog.d(
"LibraryPager settled page=$index elapsed=${ReaderPerfLog.elapsedMs(start)}ms"
)
}
}
},
text = { Text(title) }
)
@ -798,6 +813,11 @@ fun LibraryScreenContent(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapPositionalThreshold = 0.25f
),
beyondViewportPageCount = 0,
key = { it }
) { page ->
when (page) {
@ -822,7 +842,7 @@ fun LibraryScreenContent(
items(recentFiles, key = { it.bookId }) { item ->
LibraryListItem(
item = item,
isSelected = selectedItems.any { it.bookId == item.bookId },
isSelected = item.bookId in selectedBookIds,
isPinned = item.bookId in pinnedLibraryBookIds,
onItemClick = { onItemClick(item) },
onItemLongClick = { onItemLongClick(item) },
@ -1876,6 +1896,18 @@ private fun FolderSyncScreen(
isLoading: Boolean
) {
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
val folderStatsByUri = remember(allRecentFiles) {
allRecentFiles
.asSequence()
.filter { it.sourceFolderUri != null }
.groupBy { it.sourceFolderUri!! }
.mapValues { (_, files) ->
FolderFileStats(
totalBooks = files.size,
countsByType = files.groupingBy { it.type }.eachCount()
)
}
}
Scaffold(
floatingActionButton = {
@ -1943,7 +1975,7 @@ private fun FolderSyncScreen(
items(syncedFolders, key = { it.uriString }) { folder ->
FolderCard(
folder = folder,
allRecentFiles = allRecentFiles,
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
onRemoveClick = onRemoveFolderClick,
onEditFiltersClick = { editingFolder = folder }
)
@ -1952,11 +1984,11 @@ private fun FolderSyncScreen(
}
}
if (editingFolder != null) {
editingFolder?.let { folder ->
EditFolderFiltersDialog(
folder = editingFolder!!,
folder = folder,
onConfirm = { newFilters ->
onEditFolderFiltersClick(editingFolder!!, newFilters)
onEditFolderFiltersClick(folder, newFilters)
editingFolder = null
},
onDismiss = { editingFolder = null }
@ -1964,11 +1996,20 @@ private fun FolderSyncScreen(
}
}
private data class FolderFileStats(
val totalBooks: Int,
val countsByType: Map<FileType, Int>
) {
companion object {
val Empty = FolderFileStats(totalBooks = 0, countsByType = emptyMap())
}
}
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun FolderCard(
folder: SyncedFolder,
allRecentFiles: List<RecentFileItem>,
stats: FolderFileStats,
onRemoveClick: (SyncedFolder) -> Unit,
onEditFiltersClick: (SyncedFolder) -> Unit
) {
@ -1976,14 +2017,6 @@ private fun FolderCard(
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
val lastScanText = if (folder.lastScanTime == 0L) stringResource(R.string.never) else dateFormat.format(Date(folder.lastScanTime))
val folderFiles = remember(allRecentFiles, folder.uriString) {
allRecentFiles.filter { it.sourceFolderUri == folder.uriString }
}
val totalBooks = folderFiles.size
val countsByType = remember(folderFiles) {
folderFiles.groupBy { it.type }.mapValues { it.value.size }
}
androidx.compose.material3.ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
@ -2058,18 +2091,18 @@ private fun FolderCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
)
Text(text = totalBooks.toString(), style = MaterialTheme.typography.bodyMedium)
Text(text = stats.totalBooks.toString(), style = MaterialTheme.typography.bodyMedium)
}
}
if (countsByType.isNotEmpty()) {
if (stats.countsByType.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
countsByType.forEach { (type, count) ->
stats.countsByType.forEach { (type, count) ->
AssistChip(
onClick = { },
label = { Text(stringResource(R.string.folder_filter_count, type.name, count)) }

View file

@ -0,0 +1,435 @@
package com.aryan.reader
import com.aryan.reader.data.BookShelfCrossRef
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity
import com.aryan.reader.data.SmartCollectionEngine
import com.aryan.reader.data.TagEntity
fun interface FolderPathResolver {
fun relativeFolderSegments(item: RecentFileItem): List<String>
}
object EmptyFolderPathResolver : FolderPathResolver {
override fun relativeFolderSegments(item: RecentFileItem): List<String> = emptyList()
}
data class LibraryProjectionInput(
val state: ReaderScreenState,
val recentFilesFromDb: List<RecentFileItem>,
val dbShelves: List<ShelfEntity>,
val shelfRefs: List<BookShelfCrossRef>,
val dbTags: List<TagEntity>,
val tagRefs: List<BookTagCrossRef>
)
class LibraryStateProjector(
private val folderPathResolver: FolderPathResolver = EmptyFolderPathResolver
) {
private var cachedProjection: CachedProjection? = null
fun project(input: LibraryProjectionInput): ReaderScreenState {
val start = ReaderPerfLog.nowNanos()
val internalState = input.state
val cacheKey = ProjectionCacheKey(
recentFilesFromDb = input.recentFilesFromDb,
dbShelves = input.dbShelves,
shelfRefs = input.shelfRefs,
dbTags = input.dbTags,
tagRefs = input.tagRefs,
folderKeys = internalState.syncedFolders.map { SyncedFolderProjectionKey(it.uriString, it.name) },
sortOrder = internalState.sortOrder,
searchQuery = internalState.searchQuery,
libraryFilters = internalState.libraryFilters,
recentFilesLimit = internalState.recentFilesLimit
)
cachedProjection?.takeIf { it.key == cacheKey }?.let { cache ->
val result = buildStateFromCache(internalState, cache)
val elapsed = ReaderPerfLog.elapsedMs(start)
if (elapsed >= 8L) {
ReaderPerfLog.d(
"LibraryProject cache-hit took ${elapsed}ms books=${cache.allLibraryFiles.size} shelves=${cache.shelfProjection.shelves.size}"
)
}
return result
}
val tagsById = input.dbTags.associateBy { it.id }
val bookTagsMap = input.tagRefs.groupBy { it.bookId }.mapValues { entry ->
entry.value.mapNotNull { tagsById[it.tagId] }
}
val allLibraryFiles = input.recentFilesFromDb
.filterNot { it.bookId.endsWith("_reflow") }
.map { item ->
item.copy(tags = bookTagsMap[item.bookId] ?: emptyList())
}
val allLibraryFilesById = allLibraryFiles.associateBy { it.bookId }
val rawFilteredByQuery = filterBySearch(allLibraryFiles, internalState.searchQuery)
val libraryFiltered = applyLibraryFilters(rawFilteredByQuery, internalState.libraryFilters)
val sortedLibraryFiles = if (internalState.sortOrder == SortOrder.RECENT) {
libraryFiltered
} else {
sortFiles(libraryFiltered, internalState.sortOrder)
}
val recentLimit = if (internalState.recentFilesLimit > 0) internalState.recentFilesLimit else Int.MAX_VALUE
val visibleRecentFiles = if (internalState.sortOrder == SortOrder.RECENT) {
allLibraryFiles
.asSequence()
.filter { it.isRecent }
.take(recentLimit)
.toList()
} else {
sortFiles(
allLibraryFiles.filter { it.isRecent },
internalState.sortOrder
).take(recentLimit)
}
val shelfProjection = buildShelves(
allLibraryFiles = allLibraryFiles,
dbShelves = input.dbShelves,
shelfRefs = input.shelfRefs,
dbTags = input.dbTags,
sortOrder = internalState.sortOrder,
syncedFolders = internalState.syncedFolders
)
val cache = CachedProjection(
key = cacheKey,
allLibraryFiles = allLibraryFiles,
allLibraryFilesById = allLibraryFilesById,
sortedLibraryFiles = sortedLibraryFiles,
visibleRecentFiles = visibleRecentFiles,
shelfProjection = shelfProjection,
validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id },
dbTags = input.dbTags
)
cachedProjection = cache
val elapsed = ReaderPerfLog.elapsedMs(start)
if (elapsed >= 16L || allLibraryFiles.size >= 500) {
ReaderPerfLog.d(
"LibraryProject recompute took ${elapsed}ms books=${allLibraryFiles.size} " +
"visible=${sortedLibraryFiles.size} shelves=${shelfProjection.shelves.size} " +
"tags=${input.dbTags.size} shelfRefs=${input.shelfRefs.size} tagRefs=${input.tagRefs.size}"
)
}
return buildStateFromCache(internalState, cache)
}
private fun buildStateFromCache(
internalState: ReaderScreenState,
cache: CachedProjection
): ReaderScreenState {
val viewingShelfId = internalState.viewingShelfId?.takeIf { it in cache.validShelfIds }
val selectedShelfIds = internalState.contextualActionShelfIds.filterTo(mutableSetOf()) { it in cache.validShelfIds }
val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && viewingShelfId != null) {
val currentShelfBookIds = cache.shelfProjection.shelves
.find { it.id == viewingShelfId }
?.books
?.mapTo(mutableSetOf()) { it.bookId }
?: emptySet()
when (internalState.addBooksSource) {
AddBooksSource.UNSHELVED -> cache.shelfProjection.unshelvedBooks
AddBooksSource.ALL_BOOKS -> cache.allLibraryFiles.filter { it.bookId !in currentShelfBookIds }
}
} else {
emptyList()
}
return internalState.copy(
recentFiles = cache.visibleRecentFiles,
allRecentFiles = cache.sortedLibraryFiles,
rawLibraryFiles = cache.allLibraryFiles,
viewingShelfId = viewingShelfId,
isAddingBooksToShelf = internalState.isAddingBooksToShelf && viewingShelfId != null,
contextualActionShelfIds = selectedShelfIds,
contextualActionItems = internalState.contextualActionItems
.mapNotNull { ctx -> cache.allLibraryFilesById[ctx.bookId] }
.toSet(),
shelves = cache.shelfProjection.shelves,
openTabs = internalState.openTabIds.mapNotNull { tabId -> cache.allLibraryFilesById[tabId] },
booksAvailableForAdding = booksAvailableForAdding,
allTags = cache.dbTags
)
}
private fun buildShelves(
allLibraryFiles: List<RecentFileItem>,
dbShelves: List<ShelfEntity>,
shelfRefs: List<BookShelfCrossRef>,
dbTags: List<TagEntity>,
sortOrder: SortOrder,
syncedFolders: List<SyncedFolder>
): ShelfProjection {
val allShelves = mutableListOf<Shelf>()
val shelvedBookIds = mutableSetOf<String>()
val baseFilesMap = allLibraryFiles.associateBy { it.bookId }
val shelfRefsByShelfId = shelfRefs.groupBy { it.shelfId }
val taggedBookIdsByTagId = mutableMapOf<String, MutableList<String>>()
allLibraryFiles.forEach { item ->
item.tags.forEach { tag ->
taggedBookIdsByTagId.getOrPut(tag.id) { mutableListOf() }.add(item.bookId)
}
}
dbShelves.forEach { shelfEntity ->
if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) {
val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson)
if (rules != null) {
val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it, rules) }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks, sortOrder)))
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
}
} else {
val bookIdsInShelf = shelfRefsByShelfId[shelfEntity.id].orEmpty()
.sortedBy { it.addedAt }
.map { it.bookId }
val booksInShelf = bookIdsInShelf.mapNotNull { baseFilesMap[it] }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.MANUAL, sortFiles(booksInShelf, sortOrder)))
shelvedBookIds.addAll(bookIdsInShelf)
}
}
val tagShelves = dbTags.mapNotNull { tag ->
val taggedBooks = taggedBookIdsByTagId[tag.id].orEmpty().mapNotNull { baseFilesMap[it] }
if (taggedBooks.isEmpty()) {
null
} else {
Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortFiles(taggedBooks, sortOrder))
}
}
allShelves.addAll(tagShelves)
val seriesShelves = allLibraryFiles
.filter { !it.seriesName.isNullOrBlank() }
.groupBy { it.seriesName!! }
.filter { it.value.size >= 2 }
.map { (series, books) ->
val sortedSeries = books.sortedBy { it.seriesIndex ?: 999.0 }
shelvedBookIds.addAll(books.map { it.bookId })
Shelf("series_$series", series, ShelfType.SERIES, sortedSeries)
}
allShelves.addAll(seriesShelves)
val folderShelves = buildFolderShelves(
allLibraryFiles = allLibraryFiles,
syncedFolders = syncedFolders,
sortOrder = sortOrder
).also { shelves ->
shelves.forEach { shelf ->
shelvedBookIds.addAll(shelf.books.map { it.bookId })
}
}
allShelves.addAll(folderShelves)
val unshelvedBooks = allLibraryFiles.filter { it.bookId !in shelvedBookIds }
allShelves.add(Shelf("unshelved", "Unshelved", ShelfType.MANUAL, sortFiles(unshelvedBooks, sortOrder)))
allShelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey }))
return ShelfProjection(shelves = allShelves, unshelvedBooks = unshelvedBooks)
}
private fun buildFolderShelves(
allLibraryFiles: List<RecentFileItem>,
syncedFolders: List<SyncedFolder>,
sortOrder: SortOrder
): List<Shelf> {
val folderNamesByUri = syncedFolders.associate { it.uriString to it.name }
val folderSegmentsByBookId = allLibraryFiles
.asSequence()
.filter { it.sourceFolderUri != null }
.associate { it.bookId to folderPathResolver.relativeFolderSegments(it) }
return allLibraryFiles
.filter { it.sourceFolderUri != null }
.groupBy { it.sourceFolderUri!! }
.flatMap { (folderUri, books) ->
val rootName = folderNamesByUri[folderUri] ?: "Local Folder"
val rootShelfId = "folder_$folderUri"
val rootAccumulator = FolderShelfAccumulator(
id = rootShelfId,
name = rootName,
depth = 0,
parentShelfId = null,
sortPath = ""
)
val rootShelf = Shelf(
id = rootShelfId,
name = rootName,
type = ShelfType.FOLDER,
books = sortFiles(books, sortOrder),
directBooks = emptyList(),
childShelfIds = emptyList(),
depth = 0,
sortKey = "folder:${rootName.lowercase()}:"
)
val nestedShelves = linkedMapOf<String, FolderShelfAccumulator>()
val nestedShelvesById = mutableMapOf<String, FolderShelfAccumulator>()
books.forEach { book ->
rootAccumulator.books.add(book)
val segments = folderSegmentsByBookId[book.bookId].orEmpty()
if (segments.isEmpty()) {
rootAccumulator.directBooks.add(book)
}
var currentPath = ""
var parentShelfId = rootShelfId
segments.forEachIndexed { index, segment ->
currentPath = if (currentPath.isEmpty()) segment else "$currentPath/$segment"
val shelfId = "folder_$folderUri::$currentPath"
val accumulator = nestedShelves.getOrPut(currentPath) {
val newShelf = FolderShelfAccumulator(
id = shelfId,
name = segment,
depth = index + 1,
parentShelfId = parentShelfId,
sortPath = currentPath.lowercase()
)
if (parentShelfId == rootShelfId) {
rootAccumulator.childShelfIds.add(shelfId)
} else {
nestedShelvesById[parentShelfId]?.childShelfIds?.add(shelfId)
}
nestedShelvesById[shelfId] = newShelf
newShelf
}
accumulator.books.add(book)
if (index == segments.lastIndex) {
accumulator.directBooks.add(book)
}
parentShelfId = shelfId
}
}
val sortedNestedShelves = nestedShelves
.values
.sortedBy { it.sortPath }
.map { shelf ->
Shelf(
id = shelf.id,
name = shelf.name,
type = ShelfType.FOLDER,
books = sortFiles(shelf.books, sortOrder),
directBooks = sortFiles(shelf.directBooks, sortOrder),
parentShelfId = shelf.parentShelfId,
childShelfIds = shelf.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() },
depth = shelf.depth,
sortKey = "folder:${rootName.lowercase()}:${shelf.sortPath}"
)
}
listOf(
rootShelf.copy(
directBooks = sortFiles(rootAccumulator.directBooks, sortOrder),
childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() }
)
) + sortedNestedShelves
}
}
private data class FolderShelfAccumulator(
val id: String,
val name: String,
val depth: Int,
val parentShelfId: String?,
val sortPath: String,
val books: MutableList<RecentFileItem> = mutableListOf(),
val directBooks: MutableList<RecentFileItem> = mutableListOf(),
val childShelfIds: MutableList<String> = mutableListOf()
)
private data class ShelfProjection(
val shelves: List<Shelf>,
val unshelvedBooks: List<RecentFileItem>
)
private data class ProjectionCacheKey(
val recentFilesFromDb: List<RecentFileItem>,
val dbShelves: List<ShelfEntity>,
val shelfRefs: List<BookShelfCrossRef>,
val dbTags: List<TagEntity>,
val tagRefs: List<BookTagCrossRef>,
val folderKeys: List<SyncedFolderProjectionKey>,
val sortOrder: SortOrder,
val searchQuery: String,
val libraryFilters: LibraryFilters,
val recentFilesLimit: Int
)
private data class SyncedFolderProjectionKey(
val uriString: String,
val name: String
)
private data class CachedProjection(
val key: ProjectionCacheKey,
val allLibraryFiles: List<RecentFileItem>,
val allLibraryFilesById: Map<String, RecentFileItem>,
val sortedLibraryFiles: List<RecentFileItem>,
val visibleRecentFiles: List<RecentFileItem>,
val shelfProjection: ShelfProjection,
val validShelfIds: Set<String>,
val dbTags: List<TagEntity>
)
}
fun filterBySearch(files: List<RecentFileItem>, searchQuery: String): List<RecentFileItem> {
val query = searchQuery.trim()
return if (query.isBlank()) {
files
} else {
files.filter { item ->
item.displayName.contains(query, ignoreCase = true) ||
item.title?.contains(query, ignoreCase = true) == true ||
item.author?.contains(query, ignoreCase = true) == true ||
item.tags.any { tag -> tag.name.contains(query, ignoreCase = true) }
}
}
}
fun applyLibraryFilters(files: List<RecentFileItem>, filters: LibraryFilters): List<RecentFileItem> {
return files.filter { item ->
val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true
val matchFolder = if (filters.sourceFolders.isNotEmpty()) {
val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") &&
item.sourceFolderUri == null &&
item.uriString?.startsWith("opds-pse") != true
val matchesSynced = item.sourceFolderUri in filters.sourceFolders
matchesInApp || matchesSynced
} else {
true
}
val progress = item.progressPercentage ?: 0f
val matchStatus = when (filters.readStatus) {
ReadStatusFilter.ALL -> true
ReadStatusFilter.UNREAD -> progress == 0f
ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f
ReadStatusFilter.COMPLETED -> progress >= 100f
}
val matchTags = if (filters.tagIds.isNotEmpty()) {
item.tags.any { it.id in filters.tagIds }
} else {
true
}
matchType && matchFolder && matchStatus && matchTags
}
}
fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFileItem> {
return when (sortOrder) {
SortOrder.RECENT -> files.sortedByDescending { it.timestamp }
SortOrder.TITLE_ASC -> files.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f }
SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f }
SortOrder.SIZE_ASC -> files.sortedBy { it.fileSize }
SortOrder.SIZE_DESC -> files.sortedByDescending { it.fileSize }
}
}

View file

@ -45,7 +45,9 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
@UnstableApi
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
@ -91,12 +93,14 @@ class MainActivity : AppCompatActivity() {
AppThemeMode.SYSTEM -> isSystemInDarkTheme()
}
val textDimFactor = if (darkTheme) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight
AppTheme(
darkTheme = darkTheme,
dynamicColor = uiState.appSeedColor == null,
seedColor = uiState.appSeedColor,
contrastLevel = uiState.appContrastOption.value,
textDimFactor = uiState.appTextDimFactor
textDimFactor = textDimFactor
) {
Surface(
modifier = Modifier.fillMaxSize(),

View file

@ -0,0 +1,14 @@
package com.aryan.reader
internal const val KEY_RENDER_MODE = "render_mode"
internal const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
internal const val KEY_MAIN_SCREEN_START_PAGE = "main_screen_start_page"
internal const val KEY_LIBRARY_SCREEN_START_PAGE = "library_screen_start_page"
internal const val KEY_LAST_VIEWING_SHELF_ID = "last_viewing_shelf_id"
internal const val KEY_LAST_ADDING_BOOKS_TO_SHELF = "last_adding_books_to_shelf"
internal const val KEY_FILTER_FILE_TYPES = "filter_file_types"
internal const val KEY_FILTER_FOLDERS = "filter_folders"
internal const val KEY_FILTER_READ_STATUS = "filter_read_status"
internal const val KEY_FILTER_TAG_IDS = "filter_tag_ids"
internal const val KEY_DEFAULT_TAGS_SEEDED = "default_tags_seeded"

View file

@ -22,10 +22,9 @@ package com.aryan.reader
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
@ -33,17 +32,15 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
sealed class BottomBarScreen(val route: String, val stringResId: Int, val iconResId: Int) {
object Home : BottomBarScreen("home", R.string.nav_home, R.drawable.home)
@ -55,6 +52,7 @@ private val bottomBarItems = listOf(
BottomBarScreen.Library,
)
@OptIn(UnstableApi::class)
@Composable
fun MainScreen(
viewModel: MainViewModel,
@ -75,21 +73,7 @@ fun MainScreen(
if (viewingShelfName != null) {
ShelfScreen(viewModel = viewModel)
} else {
val pagerState = rememberPagerState(
initialPage = uiState.mainScreenStartPage,
pageCount = { bottomBarItems.size }
)
val scope = rememberCoroutineScope()
LaunchedEffect(uiState.mainScreenStartPage) {
if (pagerState.currentPage != uiState.mainScreenStartPage) {
pagerState.animateScrollToPage(uiState.mainScreenStartPage)
}
}
LaunchedEffect(pagerState.currentPage) {
viewModel.setMainScreenPage(pagerState.currentPage)
}
val selectedPage = uiState.mainScreenStartPage.coerceIn(0, bottomBarItems.lastIndex)
Scaffold(
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
@ -99,23 +83,28 @@ fun MainScreen(
NavigationBarItem(
icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) },
label = { Text(stringResource(screen.stringResId)) },
selected = pagerState.currentPage == index,
onClick = { scope.launch { pagerState.animateScrollToPage(index) } }
selected = selectedPage == index,
onClick = {
ReaderPerfLog.d("MainPager click page=$index route=${screen.route}")
if (selectedPage != index) {
val animStart = ReaderPerfLog.nowNanos()
viewModel.setMainScreenPage(index)
ReaderPerfLog.d(
"MainPager settled page=$index elapsed=${ReaderPerfLog.elapsedMs(animStart)}ms"
)
}
}
)
}
}
}
) { innerPadding ->
HorizontalPager(
state = pagerState,
androidx.compose.foundation.layout.Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
key = { bottomBarItems[it].route },
beyondViewportPageCount = 1,
userScrollEnabled = false
) { page ->
when (page) {
.padding(innerPadding)
) {
when (selectedPage) {
0 -> HomeScreen(
viewModel = viewModel,
windowSizeClass = windowSizeClass,

View file

@ -38,7 +38,7 @@ import kotlinx.serialization.protobuf.ProtoBuf
import com.aryan.reader.paginatedreader.semanticBlockModule
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.annotation.StringRes
import androidx.annotation.OptIn
import androidx.compose.ui.graphics.toArgb
import androidx.core.content.edit
import androidx.core.graphics.createBitmap
@ -48,6 +48,7 @@ import androidx.credentials.exceptions.NoCredentialException
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
@ -64,8 +65,8 @@ import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.data.RemoteConfigRepository
import com.aryan.reader.data.ShelfMetadata
import com.aryan.reader.data.SmartCollectionEngine
import com.aryan.reader.data.TagEntity
import com.aryan.reader.data.getUri
import com.aryan.reader.data.toBookMetadata
import com.aryan.reader.data.toRecentFileItem
import com.aryan.reader.epub.CalibreBundleExtractor
@ -75,6 +76,7 @@ import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.ImportedFileCache
import com.aryan.reader.epub.MobiParser
import com.aryan.reader.epub.SingleFileImporter
import com.aryan.reader.epub.hasReadableExtractedContent
import com.aryan.reader.ml.ISpeechBubbleDetector
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.paginatedreader.Locator
@ -120,60 +122,18 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.ExperimentalSerializationApi
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.util.Date
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CancellationException
import java.util.concurrent.Executors.newSingleThreadExecutor
import java.util.concurrent.TimeUnit
private const val KEY_RENDER_MODE = "render_mode"
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
private const val KEY_MAIN_SCREEN_START_PAGE = "main_screen_start_page"
private const val KEY_LIBRARY_SCREEN_START_PAGE = "library_screen_start_page"
private const val KEY_LAST_VIEWING_SHELF_ID = "last_viewing_shelf_id"
private const val KEY_LAST_ADDING_BOOKS_TO_SHELF = "last_adding_books_to_shelf"
private const val KEY_FILTER_FILE_TYPES = "filter_file_types"
private const val KEY_FILTER_FOLDERS = "filter_folders"
private const val KEY_FILTER_READ_STATUS = "filter_read_status"
private const val KEY_FILTER_TAG_IDS = "filter_tag_ids"
private const val KEY_DEFAULT_TAGS_SEEDED = "default_tags_seeded"
private val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7)
private val EPUB_READER_FILE_TYPES = setOf(
FileType.EPUB,
FileType.MOBI,
FileType.MD,
FileType.TXT,
FileType.HTML,
FileType.FB2,
FileType.DOCX,
FileType.ODT,
FileType.FODT
)
data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false)
data class ImportResult(
val internalUri: Uri,
val bookId: String,
val type: FileType,
val bundleResult: CalibreBundleResult? = null
)
data class UserData(
val uid: String, val displayName: String?, val photoUrl: String?, val email: String?
)
data class NavigationEvent(
val route: String, val bookId: String? = null, val uri: Uri? = null
)
private data class SpeechBubbleCacheKey(
val documentId: String,
val pageIndex: Int
@ -187,166 +147,8 @@ private data class CachedSpeechBubble(
val maskBitmap: Bitmap?
)
enum class AddBooksSource(@StringRes val labelRes: Int) {
UNSHELVED(R.string.add_books_source_unshelved),
ALL_BOOKS(R.string.add_books_source_all_books)
}
enum class AppThemeMode(@StringRes val labelRes: Int) {
SYSTEM(R.string.app_theme_mode_system),
LIGHT(R.string.app_theme_mode_light),
DARK(R.string.app_theme_mode_dark)
}
enum class AppContrastOption(@StringRes val labelRes: Int, val value: Double) {
STANDARD(R.string.app_contrast_standard, 0.0),
MEDIUM(R.string.app_contrast_medium, 0.5),
HIGH(R.string.app_contrast_high, 1.0)
}
data class CustomAppTheme(
val id: String,
val name: String,
val seedColor: androidx.compose.ui.graphics.Color
)
enum class FileType {
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT
}
enum class RenderMode {
VERTICAL_SCROLL, PAGINATED
}
data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?)
data class DeviceLimitReachedState(
val isLimitReached: Boolean = false, val registeredDevices: List<DeviceItem> = emptyList()
)
data class SyncedFolder(
val uriString: String, val name: String, val lastScanTime: Long, val allowedFileTypes: Set<FileType> = FileType.entries.toSet()
)
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER }
data class Shelf(
val id: String,
val name: String,
val type: ShelfType,
val books: List<RecentFileItem>,
val directBooks: List<RecentFileItem> = books,
val parentShelfId: String? = null,
val childShelfIds: List<String> = emptyList(),
val depth: Int = 0,
val sortKey: String = name.lowercase()
) {
val bookCount: Int get() = books.size
val topBook: RecentFileItem? get() = books.maxByOrNull { it.timestamp }
val directBookCount: Int get() = directBooks.size
val childShelfCount: Int get() = childShelfIds.size
}
enum class SortOrder(@StringRes val labelRes: Int) {
RECENT(R.string.sort_recent),
TITLE_ASC(R.string.sort_title_az),
AUTHOR_ASC(R.string.sort_author_az),
PERCENT_ASC(R.string.sort_percent_asc),
PERCENT_DESC(R.string.sort_percent_desc),
SIZE_ASC(R.string.sort_size_smallest),
SIZE_DESC(R.string.sort_size_biggest)
}
enum class ReadStatusFilter(@StringRes val labelRes: Int) {
ALL(R.string.read_status_all),
UNREAD(R.string.read_status_unread),
IN_PROGRESS(R.string.read_status_in_progress),
COMPLETED(R.string.read_status_completed)
}
data class LibraryFilters(
val fileTypes: Set<FileType> = emptySet(),
val sourceFolders: Set<String> = emptySet(),
val readStatus: ReadStatusFilter = ReadStatusFilter.ALL,
val tagIds: Set<String> = emptySet()
) {
val isActive: Boolean
get() = fileTypes.isNotEmpty() ||
sourceFolders.isNotEmpty() ||
readStatus != ReadStatusFilter.ALL ||
tagIds.isNotEmpty()
}
data class ReaderScreenState(
val selectedPdfUri: Uri? = null,
val selectedBookId: String? = null,
val selectedEpubBook: EpubBook? = null,
val selectedEpubUri: Uri? = null,
val selectedFileType: FileType? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val contextualActionItems: Set<RecentFileItem> = emptySet(),
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
val sortOrder: SortOrder = SortOrder.RECENT,
val initialLocator: Locator? = null,
val initialCfi: String? = null,
val initialBookmarksJson: String? = null,
val initialHighlightsJson: String? = null,
val initialPageInBook: Int? = null,
val shelves: List<Shelf> = emptyList(),
val viewingShelfId: String? = null,
val isAddingBooksToShelf: Boolean = false,
val showCreateShelfDialog: Boolean = false,
val mainScreenStartPage: Int = 0,
val libraryScreenStartPage: Int = 0,
val showRenameShelfDialogFor: String? = null,
val showDeleteShelfDialogFor: String? = null,
val addBooksSource: AddBooksSource = AddBooksSource.UNSHELVED,
val booksSelectedForAdding: Set<String> = emptySet(),
val booksAvailableForAdding: List<RecentFileItem> = emptyList(),
val contextualActionShelfIds: Set<String> = emptySet(),
val currentUser: UserData? = null,
val isAuthMenuExpanded: Boolean = false,
val isProUser: Boolean = false,
val credits: Int = 0,
val isSyncEnabled: Boolean = false,
val isFolderSyncEnabled: Boolean = false,
val bannerMessage: BannerMessage? = null,
val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(),
val isReplacingDevice: Boolean = false,
val isRequestingDrivePermission: Boolean = false,
val downloadingBookIds: Set<String> = emptySet(),
val uploadingBookIds: Set<String> = emptySet(),
val syncedFolders: List<SyncedFolder> = emptyList(),
val lastFolderScanTime: Long? = null,
val hasUnreadFeedback: Boolean = false,
val searchQuery: String = "",
val isSearchActive: Boolean = false,
val isRefreshing: Boolean = false,
val reflowProgress: Float? = null,
val recentFiles: List<RecentFileItem> = emptyList(),
val allRecentFiles: List<RecentFileItem> = emptyList(),
val rawLibraryFiles: List<RecentFileItem> = emptyList(),
val pinnedHomeBookIds: Set<String> = emptySet(),
val pinnedLibraryBookIds: Set<String> = emptySet(),
val libraryFilters: LibraryFilters = LibraryFilters(),
val recentFilesLimit: Int = 0,
val isTabsEnabled: Boolean = false,
val openTabIds: List<String> = emptyList(),
val openTabs: List<RecentFileItem> = emptyList(),
val activeTabBookId: String? = null,
val showExternalFileSavePromptFor: String? = null,
val externalFileBehavior: String = "ASK",
val useStrictFileFilter: Boolean = false,
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
val appTextDimFactor: Float = 1.0f,
val appSeedColor: androidx.compose.ui.graphics.Color? = null,
val customAppThemes: List<CustomAppTheme> = emptyList(),
val allTags: List<TagEntity> = emptyList(),
val showTagSelectionDialogFor: Set<String> = emptySet(),
)
@kotlin.OptIn(ExperimentalSerializationApi::class)
@UnstableApi
open class MainViewModel(application: Application) : AndroidViewModel(application) {
private val appContext: Context = application.applicationContext
private val authRepository = AuthRepository(appContext)
@ -380,6 +182,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val _prefsUpdateFlow = MutableStateFlow(0L)
private val prefsListener: SharedPreferences.OnSharedPreferenceChangeListener
private val feedbackRepository = FeedbackRepository(appContext)
private val libraryStateProjector = LibraryStateProjector(AndroidFolderPathResolver())
private var feedbackListener: Any? = null
private val importMutex = Mutex()
private val epubRecoveryMutex = Mutex()
@ -611,7 +414,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val totalFileLength = if (contentLength != -1L) downloadedBytes + contentLength else -1L
val input = connection.inputStream
val output = java.io.FileOutputStream(tempFile, isPartial)
val output = FileOutputStream(tempFile, isPartial)
val data = ByteArray(16 * 1024)
var count: Int
@ -744,7 +547,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
appContrastOption = try {
AppContrastOption.valueOf(prefs.getString(KEY_APP_CONTRAST_OPTION, AppContrastOption.STANDARD.name) ?: AppContrastOption.STANDARD.name)
} catch (_: Exception) { AppContrastOption.STANDARD },
appTextDimFactor = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f),
appTextDimFactorLight = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f)),
appTextDimFactorDark = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f)),
appSeedColor = if (prefs.contains(KEY_APP_SEED_COLOR)) androidx.compose.ui.graphics.Color(prefs.getInt(KEY_APP_SEED_COLOR, 0)) else null,
customAppThemes = loadCustomAppThemes(prefs)
)
@ -808,299 +612,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
open val uiState: StateFlow<ReaderScreenState> = combine(
_internalState, libraryFlow, tagFlow
) { internalState, (recentFilesFromDb, dbShelves, shelfRefs), (dbTags, tagRefs) ->
val tagsById = dbTags.associateBy { it.id }
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
entry.value.mapNotNull { tagsById[it.tagId] }
withContext(Dispatchers.Default) {
libraryStateProjector.project(
LibraryProjectionInput(
state = internalState,
recentFilesFromDb = recentFilesFromDb,
dbShelves = dbShelves,
shelfRefs = shelfRefs,
dbTags = dbTags,
tagRefs = tagRefs
)
)
}
val allLibraryFiles = recentFilesFromDb
.filterNot { it.bookId.endsWith("_reflow") }
.map { item ->
item.copy(tags = bookTagsMap[item.bookId] ?: emptyList())
}
val query = internalState.searchQuery.trim()
val rawFilteredByQuery = if (query.isBlank()) {
allLibraryFiles
} else {
allLibraryFiles.filter { item ->
item.displayName.contains(query, ignoreCase = true) ||
item.title?.contains(query, ignoreCase = true) == true ||
item.author?.contains(query, ignoreCase = true) == true ||
item.tags.any { tag -> tag.name.contains(query, ignoreCase = true) }
}
}
val filters = internalState.libraryFilters
val libraryFiltered = rawFilteredByQuery.filter { item ->
val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true
val matchFolder = if (filters.sourceFolders.isNotEmpty()) {
val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") && item.sourceFolderUri == null && item.uriString?.startsWith("opds-pse") != true
val matchesSynced = item.sourceFolderUri in filters.sourceFolders
matchesInApp || matchesSynced
} else true
val progress = item.progressPercentage ?: 0f
val matchStatus = when (filters.readStatus) {
ReadStatusFilter.ALL -> true
ReadStatusFilter.UNREAD -> progress == 0f
ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f
ReadStatusFilter.COMPLETED -> progress >= 100f
}
val matchTags = if (filters.tagIds.isNotEmpty()) {
item.tags.any { it.id in filters.tagIds }
} else true
matchType && matchFolder && matchStatus && matchTags
}
fun sortFiles(files: List<RecentFileItem>): List<RecentFileItem> {
return when (internalState.sortOrder) {
SortOrder.RECENT -> files.sortedByDescending { it.timestamp }
SortOrder.TITLE_ASC -> files.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f }
SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f }
SortOrder.SIZE_ASC -> files.sortedBy { it.fileSize }
SortOrder.SIZE_DESC -> files.sortedByDescending { it.fileSize }
}
}
val sortedLibraryFiles = sortFiles(libraryFiltered)
val visibleRecentFiles = sortFiles(allLibraryFiles.filter { it.isRecent }).take(
if (internalState.recentFilesLimit > 0) internalState.recentFilesLimit else Int.MAX_VALUE
)
val openTabsList = internalState.openTabIds.mapNotNull { tabId -> allLibraryFiles.find { it.bookId == tabId } }
val allShelves = mutableListOf<Shelf>()
val shelvedBookIds = mutableSetOf<String>()
val baseFilesMap = allLibraryFiles.associateBy { it.bookId }
dbShelves.forEach { shelfEntity ->
if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) {
val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson)
if (rules != null) {
val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it, rules) }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks)))
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
}
} else {
val bookIdsInShelf = shelfRefs.filter { it.shelfId == shelfEntity.id }.sortedBy { it.addedAt }.map { it.bookId }
val booksInShelf = bookIdsInShelf.mapNotNull { baseFilesMap[it] }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.MANUAL, sortFiles(booksInShelf)))
shelvedBookIds.addAll(bookIdsInShelf)
}
}
val tagShelves = dbTags.mapNotNull { tag ->
val taggedBooks = allLibraryFiles.filter { item -> item.tags.any { it.id == tag.id } }
if (taggedBooks.isEmpty()) {
null
} else {
Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortFiles(taggedBooks))
}
}
allShelves.addAll(tagShelves)
val seriesShelves = allLibraryFiles
.filter { !it.seriesName.isNullOrBlank() }
.groupBy { it.seriesName!! }
.filter { it.value.size >= 2 }
.map { (series, books) ->
val sortedSeries = books.sortedBy { it.seriesIndex ?: 999.0 }
shelvedBookIds.addAll(books.map { it.bookId })
Shelf("series_$series", series, ShelfType.SERIES, sortedSeries)
}
allShelves.addAll(seriesShelves)
val folderShelves = buildFolderShelves(
allLibraryFiles = allLibraryFiles,
syncedFolders = internalState.syncedFolders,
sortFiles = ::sortFiles
).also { shelves ->
shelves.forEach { shelf ->
shelvedBookIds.addAll(shelf.books.map { it.bookId })
}
}
allShelves.addAll(folderShelves)
val unshelvedBooks = allLibraryFiles.filter { it.bookId !in shelvedBookIds }
allShelves.add(Shelf("unshelved", "Unshelved", ShelfType.MANUAL, sortFiles(unshelvedBooks)))
allShelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey }))
val validShelfIds = allShelves.mapTo(mutableSetOf()) { it.id }
val viewingShelfId = internalState.viewingShelfId?.takeIf { it in validShelfIds }
val selectedShelfIds = internalState.contextualActionShelfIds.filterTo(mutableSetOf()) { it in validShelfIds }
val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && viewingShelfId != null) {
val currentShelfBookIds = allShelves
.find { it.id == viewingShelfId }
?.books
?.map { it.bookId }
?.toSet()
?: emptySet()
when (internalState.addBooksSource) {
AddBooksSource.UNSHELVED -> unshelvedBooks
AddBooksSource.ALL_BOOKS -> allLibraryFiles.filter { it.bookId !in currentShelfBookIds }
}
} else emptyList()
internalState.copy(
recentFiles = visibleRecentFiles,
allRecentFiles = sortedLibraryFiles,
rawLibraryFiles = allLibraryFiles,
viewingShelfId = viewingShelfId,
isAddingBooksToShelf = internalState.isAddingBooksToShelf && viewingShelfId != null,
contextualActionShelfIds = selectedShelfIds,
contextualActionItems = internalState.contextualActionItems.mapNotNull { ctx -> allLibraryFiles.find { it.bookId == ctx.bookId } }.toSet(),
shelves = allShelves,
openTabs = openTabsList,
booksAvailableForAdding = booksAvailableForAdding,
allTags = dbTags
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = _internalState.value
)
private data class FolderShelfAccumulator(
val id: String,
val name: String,
val depth: Int,
val parentShelfId: String?,
val sortPath: String,
val books: MutableList<RecentFileItem> = mutableListOf(),
val directBooks: MutableList<RecentFileItem> = mutableListOf(),
val childShelfIds: MutableList<String> = mutableListOf()
)
private fun buildFolderShelves(
allLibraryFiles: List<RecentFileItem>,
syncedFolders: List<SyncedFolder>,
sortFiles: (List<RecentFileItem>) -> List<RecentFileItem>
): List<Shelf> {
val folderNamesByUri = syncedFolders.associate { it.uriString to it.name }
return allLibraryFiles
.filter { it.sourceFolderUri != null }
.groupBy { it.sourceFolderUri!! }
.flatMap { (folderUri, books) ->
val rootName = folderNamesByUri[folderUri] ?: "Local Folder"
val rootShelfId = "folder_$folderUri"
val rootAccumulator = FolderShelfAccumulator(
id = rootShelfId,
name = rootName,
depth = 0,
parentShelfId = null,
sortPath = ""
)
val rootShelf = Shelf(
id = rootShelfId,
name = rootName,
type = ShelfType.FOLDER,
books = sortFiles(books),
directBooks = mutableListOf<RecentFileItem>().also { direct ->
direct.addAll(books.filter { getRelativeFolderSegments(it).isEmpty() })
},
childShelfIds = emptyList(),
depth = 0,
sortKey = "folder:${rootName.lowercase()}:"
)
val nestedShelves = linkedMapOf<String, FolderShelfAccumulator>()
books.forEach { book ->
rootAccumulator.books.add(book)
val segments = getRelativeFolderSegments(book)
if (segments.isEmpty()) {
rootAccumulator.directBooks.add(book)
}
var currentPath = ""
var parentShelfId = rootShelfId
segments.forEachIndexed { index, segment ->
currentPath = if (currentPath.isEmpty()) segment else "$currentPath/$segment"
val shelfId = "folder_$folderUri::$currentPath"
val accumulator = nestedShelves.getOrPut(currentPath) {
val newShelf = FolderShelfAccumulator(
id = shelfId,
name = segment,
depth = index + 1,
parentShelfId = parentShelfId,
sortPath = currentPath.lowercase()
)
if (parentShelfId == rootShelfId) {
rootAccumulator.childShelfIds.add(shelfId)
} else {
nestedShelves.values.find { it.id == parentShelfId }?.childShelfIds?.add(shelfId)
}
newShelf
}
accumulator.books.add(book)
if (index == segments.lastIndex) {
accumulator.directBooks.add(book)
}
parentShelfId = shelfId
}
}
val sortedNestedShelves = nestedShelves
.values
.sortedBy { it.sortPath }
.map { shelf ->
Shelf(
id = shelf.id,
name = shelf.name,
type = ShelfType.FOLDER,
books = sortFiles(shelf.books),
directBooks = sortFiles(shelf.directBooks),
parentShelfId = shelf.parentShelfId,
childShelfIds = shelf.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() },
depth = shelf.depth,
sortKey = "folder:${rootName.lowercase()}:${shelf.sortPath}"
)
}
listOf(
rootShelf.copy(
directBooks = sortFiles(rootAccumulator.directBooks),
childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() }
)
) + sortedNestedShelves
}
}
private fun getRelativeFolderSegments(item: RecentFileItem): List<String> {
val documentUriString = item.uriString ?: return emptyList()
val rootFolderUriString = item.sourceFolderUri ?: return emptyList()
return try {
val documentUri = documentUriString.toUri()
val rootFolderUri = rootFolderUriString.toUri()
val rootDocId = DocumentsContract.getTreeDocumentId(rootFolderUri)
val documentId = when {
DocumentsContract.isDocumentUri(appContext, documentUri) -> DocumentsContract.getDocumentId(documentUri)
DocumentsContract.isTreeUri(documentUri) -> DocumentsContract.getTreeDocumentId(documentUri)
else -> return emptyList()
}
val rootPath = rootDocId.substringAfter(':', "")
val documentPath = documentId.substringAfter(':', "")
val relativeDocumentPath = when {
rootPath.isBlank() -> documentPath
documentPath == rootPath -> ""
documentPath.startsWith("$rootPath/") -> documentPath.removePrefix("$rootPath/")
else -> documentPath
}
relativeDocumentPath
.substringBeforeLast('/', "")
.split('/')
.map { Uri.decode(it).trim() }
.filter { it.isNotEmpty() }
} catch (e: Exception) {
Timber.tag("FolderShelves").w(e, "Failed to derive relative folder path for ${item.displayName}")
emptyList()
}
}
fun setTabsEnabled(enabled: Boolean) {
prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) }
_internalState.update { it.copy(isTabsEnabled = enabled) }
@ -1529,7 +1058,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d("ViewModel instance created.")
WorkManager.getInstance(application).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
// --- ADD THIS BLOCK ---
val locatorConverter = LocatorConverter(
bookCacheDao,
ProtoBuf { serializersModule = semanticBlockModule },
@ -1814,7 +1342,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
inputStream = inputStream,
bookId = bookId,
originalBookNameHint = displayName
) ?: throw Exception("MobiParser returned null. The file might be DRM-protected or invalid.")
) ?: throw Exception(
if (MobiParser.isNativeParserAvailable) {
"MobiParser returned null. The file might be DRM-protected or invalid."
} else {
MobiParser.nativeParserUnavailableMessage()
}
)
FileType.FB2 -> fb2Parser.createFb2Book(
inputStream = inputStream,
@ -1860,10 +1394,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (latestState.selectedBookId != bookId || latestState.selectedEpubUri != uri) {
return@withLock
}
if (latestState.selectedEpubBook?.extractionBasePath?.let { path ->
path.isNotBlank() && File(path).exists()
} == true
) {
if (latestState.selectedEpubBook?.hasReadableExtractedContent() == true) {
return@withLock
}
@ -2822,7 +2353,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
scanSyncedFolder()
triggerFolderSyncWorker(
metadataOnly = false,
showFeedback = true,
targetFolderUriString = newFolder.uriString
)
showBanner(appContext.getString(R.string.banner_folder_added, name))
@ -2835,6 +2370,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun removeSyncedFolder(folder: SyncedFolder) {
viewModelScope.launch {
val workManager = WorkManager.getInstance(appContext)
ReaderPerfLog.d("FolderRemove request folder=${folder.uriString}")
workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME)
workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
val currentFolders = _internalState.value.syncedFolders.toMutableList()
currentFolders.removeAll { it.uriString == folder.uriString }
@ -2842,9 +2382,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(syncedFolders = currentFolders) }
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
try {
appContext.contentResolver.releasePersistableUriPermission(
folder.uriString.toUri(),
@ -2855,7 +2394,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
if (currentFolders.isEmpty()) {
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME)
}
showBanner(appContext.getString(R.string.banner_folder_removed))
@ -2870,16 +2409,33 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
triggerFolderSyncWorker(metadataOnly = false, showFeedback = true)
}
private fun triggerFolderSyncWorker(metadataOnly: Boolean, showFeedback: Boolean) {
private fun triggerFolderSyncWorker(
metadataOnly: Boolean,
showFeedback: Boolean,
targetFolderUriString: String? = null
) {
val folders = _internalState.value.syncedFolders
if (folders.isEmpty()) return
Timber.tag("FolderSync")
.d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly, feedback=$showFeedback)")
val targetFolderName = targetFolderUriString
?.let { target -> folders.firstOrNull { it.uriString == target }?.name ?: target }
ReaderPerfLog.d(
"FolderSync request folders=${folders.size} target=${targetFolderName ?: "ALL"} " +
"metadataOnly=$metadataOnly feedback=$showFeedback"
)
val workManager = WorkManager.getInstance(appContext)
if (!metadataOnly) {
workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
}
val data = androidx.work.Data.Builder()
.putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly).build()
.putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly)
.apply {
if (!targetFolderUriString.isNullOrBlank()) {
putString(FolderSyncWorker.KEY_TARGET_FOLDER_URI, targetFolderUriString)
}
}
.build()
val request = OneTimeWorkRequestBuilder<FolderSyncWorker>().setInputData(data).build()
@ -2958,7 +2514,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
withContext(Dispatchers.Main) {
scanSyncedFolder()
triggerFolderSyncWorker(
metadataOnly = false,
showFeedback = true,
targetFolderUriString = folder.uriString
)
}
}
}
@ -2966,12 +2526,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun disconnectAllSyncedFolders() {
viewModelScope.launch {
val workManager = WorkManager.getInstance(appContext)
ReaderPerfLog.d("FolderRemove disconnect all folders=${_internalState.value.syncedFolders.size}")
workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME)
workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME)
workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
val folders = _internalState.value.syncedFolders
folders.forEach { folder ->
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
try {
appContext.contentResolver.releasePersistableUriPermission(
folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION
@ -2985,8 +2550,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
remove(KEY_SYNCED_FOLDER_URI)
}
_internalState.update { it.copy(syncedFolders = emptyList()) }
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
}
}
@ -3869,6 +3432,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)
val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet()
val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet()
val validActiveBookCacheDirs = allDbIds.mapTo(mutableSetOf()) {
ImportedFileCache.activeBookDirName(it)
}
ImportedFileCache.deleteStaleTemporaryBookDirs(appContext, TimeUnit.HOURS.toMillis(1))
cacheDir.listFiles()?.forEach { file ->
@ -3879,10 +3445,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (deleted) Timber.d("Sweeper cleaned old temp file: $name")
}
} else if (ImportedFileCache.isActiveBookDir(name)) {
val bookId = name.removePrefix("imported_file_")
if (bookId !in allDbIds) {
val legacyBookId = name.removePrefix("imported_file_")
if (name !in validActiveBookCacheDirs && legacyBookId !in allDbIds && file.lastModified() < oneHourAgo) {
val deleted = file.deleteRecursively()
if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache for: $bookId")
if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache: $name")
}
} else if (name.startsWith("opds_stream_")) {
val bookIdHash = name.removePrefix("opds_stream_")
@ -4274,6 +3840,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null
) {
val openBookStartTime = System.currentTimeMillis()
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
Timber.tag("FileOpenPerf")
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
@ -4341,12 +3908,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId)
if (recentItem?.sourceFolderUri != null) {
launch(Dispatchers.IO) {
recentFilesRepository.syncLocalMetadataToFolder(bookId)
}
}
Timber.tag("FileOpenPerf")
.d("[$bookId] Branch: PDF | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
_internalState.update {
@ -4357,6 +3918,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isLoading = false
)
}
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
persistReaderSession(bookId, type)
addFileToRecent(
uri,
@ -4378,11 +3940,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) {
viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId)
if (recentItem?.sourceFolderUri != null) {
launch(Dispatchers.IO) {
recentFilesRepository.syncLocalMetadataToFolder(bookId)
}
}
Timber.tag("FileOpenPerf")
.d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
val locator =
@ -4405,6 +3962,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
initialHighlightsJson = recentItem?.highlightsJson,
)
}
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
persistReaderSession(bookId, type)
if (!suppressNavigation) {
@ -4617,87 +4175,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
"text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML
"text/plain" -> {
if (fileName?.endsWith(".md", ignoreCase = true) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true) {
FileType.MD
} else if (fileName?.let {
it.endsWith(".csv", ignoreCase = true) || it.endsWith(".tsv", ignoreCase = true) ||
it.endsWith(".json", ignoreCase = true) || it.endsWith(".xml", ignoreCase = true) ||
it.endsWith(".log", ignoreCase = true) || it.endsWith(".java", ignoreCase = true) ||
it.endsWith(".kt", ignoreCase = true) || it.endsWith(".py", ignoreCase = true) ||
it.endsWith(".js", ignoreCase = true) || it.endsWith(".cpp", ignoreCase = true) ||
it.endsWith(".c", ignoreCase = true) || it.endsWith(".cs", ignoreCase = true) ||
it.endsWith(".rb", ignoreCase = true) || it.endsWith(".go", ignoreCase = true)
} == true) {
FileType.HTML
} else {
FileType.TXT
}
resolveFileTypeFromName(fileName) ?: FileType.TXT
}
else -> {
when {
fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ
fileName?.endsWith(".cbr", ignoreCase = true) == true -> FileType.CBR
fileName?.endsWith(".cb7", ignoreCase = true) == true -> FileType.CB7
fileName?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF
fileName?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB
fileName?.endsWith(
".mobi",
ignoreCase = true
) == true || fileName?.endsWith(
".azw3",
ignoreCase = true
) == true || fileName?.endsWith(
".prc",
ignoreCase = true
) == true -> FileType.MOBI
fileName?.endsWith(
".md",
ignoreCase = true
) == true || fileName?.endsWith(
".markdown",
ignoreCase = true
) == true -> FileType.MD
fileName?.endsWith(".txt", ignoreCase = true) == true -> FileType.TXT
fileName?.endsWith(
".fb2",
ignoreCase = true
) == true || fileName?.endsWith(
".fb2.zip",
ignoreCase = true
) == true -> FileType.FB2
fileName?.endsWith(
".html",
ignoreCase = true
) == true || fileName?.endsWith(
".xhtml",
ignoreCase = true
) == true || fileName?.endsWith(
".htm",
ignoreCase = true
) == true -> FileType.HTML
fileName?.endsWith(".docx", ignoreCase = true) == true -> FileType.DOCX
fileName?.endsWith(".odt", ignoreCase = true) == true -> FileType.ODT
fileName?.endsWith(".fodt", ignoreCase = true) == true -> FileType.FODT
fileName?.endsWith(".csv", ignoreCase = true) == true ||
fileName?.endsWith(".tsv", ignoreCase = true) == true ||
fileName?.endsWith(".json", ignoreCase = true) == true ||
fileName?.endsWith(".xml", ignoreCase = true) == true ||
fileName?.endsWith(".log", ignoreCase = true) == true ||
fileName?.endsWith(".java", ignoreCase = true) == true ||
fileName?.endsWith(".kt", ignoreCase = true) == true ||
fileName?.endsWith(".py", ignoreCase = true) == true ||
fileName?.endsWith(".js", ignoreCase = true) == true ||
fileName?.endsWith(".cpp", ignoreCase = true) == true ||
fileName?.endsWith(".c", ignoreCase = true) == true ||
fileName?.endsWith(".cs", ignoreCase = true) == true ||
fileName?.endsWith(".rb", ignoreCase = true) == true ||
fileName?.endsWith(".go", ignoreCase = true) == true -> FileType.HTML
else -> null
}
resolveFileTypeFromName(fileName)
}
}
}
@ -4739,7 +4221,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
} else {
throw Exception(
"MobiParser returned null. The file might be DRM-protected or invalid."
if (MobiParser.isNativeParserAvailable) {
"MobiParser returned null. The file might be DRM-protected or invalid."
} else {
MobiParser.nativeParserUnavailableMessage()
}
)
}
} catch (e: Exception) {
@ -4945,6 +4431,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun onRecentFileClicked(item: RecentFileItem) {
ReaderPerfLog.d("FileOpen click bookId=${item.bookId} name=${item.displayName}")
val currentSelection = _internalState.value.contextualActionItems
if (currentSelection.isNotEmpty()) {
Timber.d("Toggling selection for: ${item.displayName}")
@ -5125,6 +4612,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun setMainScreenPage(page: Int) {
val sanitizedPage = page.coerceIn(0, 1)
if (_internalState.value.mainScreenStartPage == sanitizedPage) return
_internalState.update { it.copy(mainScreenStartPage = sanitizedPage) }
persistLibraryLandingState()
}
@ -5132,6 +4620,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun setLibraryScreenPage(page: Int) {
val maxLibraryPage = if (BuildConfig.IS_OFFLINE) 2 else 3
val sanitizedPage = page.coerceIn(0, maxLibraryPage)
if (_internalState.value.libraryScreenStartPage == sanitizedPage) return
_internalState.update {
it.copy(libraryScreenStartPage = sanitizedPage)
}
@ -5701,9 +5190,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) }
}
fun setAppTextDimFactor(factor: Float) {
_internalState.update { it.copy(appTextDimFactor = factor) }
prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR, factor) }
fun setAppTextDimFactorLight(factor: Float) {
_internalState.update { it.copy(appTextDimFactorLight = factor) }
prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, factor) }
}
fun setAppTextDimFactorDark(factor: Float) {
_internalState.update { it.copy(appTextDimFactorDark = factor) }
prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, factor) }
}
fun setAppSeedColor(color: androidx.compose.ui.graphics.Color?) {
@ -5948,6 +5442,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option"
private const val KEY_APP_SEED_COLOR = "app_seed_color"
private const val KEY_APP_TEXT_DIM_FACTOR = "app_text_dim_factor"
private const val KEY_APP_TEXT_DIM_FACTOR_LIGHT = "app_text_dim_factor_light"
private const val KEY_APP_TEXT_DIM_FACTOR_DARK = "app_text_dim_factor_dark"
private const val KEY_CUSTOM_APP_THEMES = "custom_app_themes"
val SUPPORTED_MIME_TYPES = arrayOf(

View file

@ -2,19 +2,20 @@
package com.aryan.reader
import android.content.Context
import android.provider.OpenableColumns
import android.util.Xml
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.ImportedFileCache
import com.aryan.reader.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator
import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser
import timber.log.Timber
import java.io.File
import java.util.zip.ZipInputStream
class MetadataExtractionWorker(
private val appContext: Context,
@ -22,179 +23,271 @@ class MetadataExtractionWorker(
) : CoroutineWorker(appContext, workerParams) {
private val recentFilesRepository = RecentFilesRepository(appContext)
private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext)
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
private val odtParser = com.aryan.reader.epub.OdtParser(appContext)
companion object {
const val WORK_NAME = "MetadataExtractionWorker"
const val KEY_SOURCE_FOLDER_URI = "key_source_folder_uri"
private const val METADATA_DB_BATCH_SIZE = 100
private const val METADATA_PROGRESS_LOG_EVERY = 250
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val workerStart = ReaderPerfLog.nowNanos()
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val hasLegacy = prefs.contains("synced_folder_uri")
val hasNew = prefs.contains("synced_folders_list_json")
if (!hasLegacy && !hasNew) {
Timber.tag("MetadataWorker").w("No folders linked. Stopping.")
ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
return@withContext Result.success()
}
try {
val filesToProcess = recentFilesRepository.getFolderBooksWithoutCovers()
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(sourceFolderUri)
if (filesToProcess.isEmpty()) {
ReaderPerfLog.d("MetadataWorker skipped: no text metadata pending folder=${sourceFolderUri ?: "ALL"}")
return@withContext Result.success()
}
Timber.tag("MetadataWorker").i("Starting background metadata extraction for ${filesToProcess.size} books.")
ReaderPerfLog.i(
"MetadataWorker start mode=text-only books=${filesToProcess.size} folder=${sourceFolderUri ?: "ALL"}"
)
val pendingUpdates = mutableListOf<RecentFileItem>()
var processed = 0
var updated = 0
var failed = 0
suspend fun flushUpdates() {
if (pendingUpdates.isEmpty()) return
val flushStart = ReaderPerfLog.nowNanos()
recentFilesRepository.updateExtractedMetadata(pendingUpdates)
ReaderPerfLog.d(
"MetadataWorker DB flush rows=${pendingUpdates.size} elapsed=${ReaderPerfLog.elapsedMs(flushStart)}ms"
)
pendingUpdates.clear()
}
filesToProcess.forEach { item ->
if (isStopped) return@forEach
if (item.sourceFolderUri == null) return@forEach
val tempExtractionDir =
if (item.type == FileType.EPUB || item.type == FileType.MOBI || item.type == FileType.ODT || item.type == FileType.FODT) {
ImportedFileCache.createTemporaryBookDir(appContext, item.bookId, "metadata")
} else {
null
}
try {
val uri = item.uriString?.toUri() ?: return@forEach
val type = item.type
var coverPath: String? = null
var title: String? = null
var author: String? = null
val fileSize = try {
if (uri.scheme == "file") {
uri.path?.let { File(it).length() } ?: 0L
} else {
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
} else 0L
} ?: 0L
}
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to get file size for ${item.displayName}")
0L
val fileSize = item.fileSize.takeIf { it > 0L } ?: queryFileSize(uri)
val metadata = when (item.type) {
FileType.EPUB -> parseEpubTextMetadata(uri)
FileType.PDF -> parsePdfTextMetadata(uri)
FileType.ODT -> parseZipTextMetadata(uri, "meta.xml")
FileType.FODT -> parseFlatXmlTextMetadata(uri)
FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml")
else -> TextMetadata()
}
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
when (type) {
FileType.EPUB -> {
val book = epubParser.createEpubBook(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
parseContent = false,
extractionDirOverride = tempExtractionDir
)
title = book.title.takeIf { it.isNotBlank() && it != "content" }
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
}
FileType.MOBI -> {
val book = mobiParser.createMobiBook(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
parseContent = false,
extractionDirOverride = tempExtractionDir
)
book?.let {
title = it.title.takeIf { t -> t.isNotBlank() && t != "content" }
author = it.author.takeIf { a -> a.isNotBlank() && !a.equals("Unknown", ignoreCase = true) }
it.coverImage?.let { img -> coverPath = recentFilesRepository.saveCoverToCache(img, uri) }
}
}
FileType.PDF -> {
pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
title = item.displayName
val title = sanitizeTitle(metadata.title)
val author = sanitizeAuthor(metadata.author)
val sizeChanged = fileSize > 0L && fileSize != item.fileSize
val titleChanged = title != null && title != item.title
val authorChanged = author != null && author != item.author
try {
val pdfiumCore = PdfiumCore(appContext)
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val pdfDocument = pdfiumCore.newDocument(pfd)
val meta = pdfiumCore.getDocumentMeta(pdfDocument)
val extractedTitle = meta.title
if (!extractedTitle.isNullOrBlank()) {
title = extractedTitle
}
val extractedAuthor = meta.author
if (!extractedAuthor.isNullOrBlank()) {
author = extractedAuthor
}
pdfiumCore.closeDocument(pdfDocument)
}
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract PDF metadata using PdfiumCore")
}
}
FileType.ODT, FileType.FODT -> {
val book = odtParser.createOdtBook(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
isFlat = type == FileType.FODT,
parseContent = false,
extractionDirOverride = tempExtractionDir
)
title = book.title.takeIf { it.isNotBlank() && it != "content" }
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
}
else -> {
title = item.displayName
}
}
}
if (coverPath != null || title != null || author != null || fileSize > 0L) {
val updatedItem = item.copy(
coverImagePath = coverPath ?: item.coverImagePath,
title = title ?: item.title ?: item.displayName,
author = author ?: item.author,
fileSize = if (fileSize > 0L) fileSize else item.fileSize
if (!item.folderTextMetadataParsed || sizeChanged || titleChanged || authorChanged) {
pendingUpdates.add(
item.copy(
title = title ?: item.title ?: item.displayName,
author = author ?: item.author,
fileSize = if (fileSize > 0L) fileSize else item.fileSize,
folderTextMetadataParsed = true
)
)
recentFilesRepository.addRecentFile(updatedItem)
Timber.tag("MetadataWorker").d("Updated local metadata/size for: ${item.displayName} ($fileSize bytes)")
if (sizeChanged || titleChanged || authorChanged) {
updated++
}
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
flushUpdates()
}
}
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}")
} finally {
try {
if (tempExtractionDir?.exists() == true) {
val deleted = tempExtractionDir.deleteRecursively()
if (deleted) {
Timber.tag("MetadataWorker")
.d("Cleaned up temporary extraction cache for ${item.bookId}")
}
}
} catch (e: Exception) {
Timber.tag("MetadataWorker")
.e(e, "Failed to clean up temporary extraction cache for ${item.bookId}")
processed++
if (processed % METADATA_PROGRESS_LOG_EVERY == 0) {
ReaderPerfLog.d(
"MetadataWorker progress mode=text-only processed=$processed updated=$updated failed=$failed"
)
}
} catch (e: Exception) {
failed++
Timber.tag("MetadataWorker").e(e, "Failed text metadata extraction for ${item.displayName}")
}
}
flushUpdates()
ReaderPerfLog.i(
"MetadataWorker finished mode=text-only processed=$processed updated=$updated failed=$failed " +
"elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
)
return@withContext Result.success()
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Metadata extraction failed")
Timber.tag("MetadataWorker").e(e, "Text metadata extraction failed")
return@withContext Result.failure()
}
}
private fun queryFileSize(uri: android.net.Uri): Long {
return try {
if (uri.scheme == "file") {
uri.path?.let { File(it).length() } ?: 0L
} else {
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) cursor.getLong(sizeIndex) else 0L
} else {
0L
}
} ?: 0L
}
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to query file size for $uri")
0L
}
}
private fun parseEpubTextMetadata(uri: android.net.Uri): TextMetadata {
val opfEntries = linkedMapOf<String, String>()
var containerXml: String? = null
appContext.contentResolver.openInputStream(uri)?.use { input ->
ZipInputStream(input.buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
if (entry.isDirectory) continue
val name = entry.name
when {
name == "META-INF/container.xml" -> containerXml = zip.readTextEntry()
name.endsWith(".opf", ignoreCase = true) -> opfEntries[name] = zip.readTextEntry()
}
zip.closeEntry()
}
}
}
val opfPath = containerXml?.let { parseEpubRootfilePath(it) }
val opfXml = opfPath?.let { opfEntries[it] } ?: opfEntries.values.firstOrNull()
return opfXml?.let { parseXmlTextMetadata(it) } ?: TextMetadata()
}
private fun parseZipTextMetadata(uri: android.net.Uri, targetEntryName: String): TextMetadata {
appContext.contentResolver.openInputStream(uri)?.use { input ->
ZipInputStream(input.buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
if (!entry.isDirectory && entry.name == targetEntryName) {
val xml = zip.readTextEntry()
return parseXmlTextMetadata(xml)
}
zip.closeEntry()
}
}
}
return TextMetadata()
}
private fun parseFlatXmlTextMetadata(uri: android.net.Uri): TextMetadata {
val xml = appContext.contentResolver.openInputStream(uri)?.use { input ->
input.bufferedReader(Charsets.UTF_8).use { it.readText() }
} ?: return TextMetadata()
return parseXmlTextMetadata(xml)
}
private fun parsePdfTextMetadata(uri: android.net.Uri): TextMetadata {
return try {
val pdfiumCore = PdfiumCore(appContext)
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val pdfDocument = pdfiumCore.newDocument(pfd)
try {
val meta = pdfiumCore.getDocumentMeta(pdfDocument)
TextMetadata(title = meta.title, author = meta.author)
} finally {
pdfiumCore.closeDocument(pdfDocument)
}
} ?: TextMetadata()
} catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to extract PDF text metadata")
TextMetadata()
}
}
private fun parseEpubRootfilePath(containerXml: String): String? {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
parser.setInput(containerXml.reader())
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG && parser.name.equals("rootfile", ignoreCase = true)) {
return parser.getAttributeValue(null, "full-path")?.takeIf { it.isNotBlank() }
}
event = parser.next()
}
return null
}
private fun parseXmlTextMetadata(xml: String): TextMetadata {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
parser.setInput(xml.reader())
var title: String? = null
var author: String? = null
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG) {
val name = parser.name.substringAfter(':').lowercase()
when {
title == null && name == "title" -> title = parser.nextTextOrNull()
author == null && (name == "creator" || name == "initial-creator") -> {
author = parser.nextTextOrNull()
}
}
}
event = parser.next()
}
return TextMetadata(title = title, author = author)
}
private fun XmlPullParser.nextTextOrNull(): String? {
return try {
nextText()?.trim()?.takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
}
private fun ZipInputStream.readTextEntry(): String {
return String(readBytes(), Charsets.UTF_8)
}
private fun sanitizeTitle(value: String?): String? {
return value
?.trim()
?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) }
}
private fun sanitizeAuthor(value: String?): String? {
return value
?.trim()
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
}
private data class TextMetadata(
val title: String? = null,
val author: String? = null
)
}

View file

@ -0,0 +1,54 @@
package com.aryan.reader
import com.aryan.reader.data.RecentFileItem
data class HomeScreenModel(
val recentFiles: List<RecentFileItem>,
val openTabs: List<RecentFileItem>,
val selectedItems: Set<RecentFileItem>,
val isContextualModeActive: Boolean,
val deviceLimitState: DeviceLimitReachedState,
val isEmpty: Boolean,
val isLibraryEmpty: Boolean
)
fun ReaderScreenState.toHomeScreenModel(): HomeScreenModel {
val homeRecentFiles = recentFiles
return HomeScreenModel(
recentFiles = homeRecentFiles,
openTabs = openTabs,
selectedItems = contextualActionItems,
isContextualModeActive = contextualActionItems.isNotEmpty(),
deviceLimitState = deviceLimitState,
isEmpty = homeRecentFiles.isEmpty() && (!isTabsEnabled || openTabs.isEmpty()),
isLibraryEmpty = recentFiles.isEmpty()
)
}
data class LibraryScreenModel(
val selectedItems: Set<RecentFileItem>,
val isContextualModeActive: Boolean,
val selectedShelves: Set<String>,
val isShelfContextualModeActive: Boolean,
val sortOrder: SortOrder,
val shelves: List<Shelf>,
val rawLibraryFiles: List<RecentFileItem>,
val containsFolderItemsInSelection: Boolean,
val isSearchActive: Boolean,
val searchQuery: String
)
fun ReaderScreenState.toLibraryScreenModel(): LibraryScreenModel {
return LibraryScreenModel(
selectedItems = contextualActionItems,
isContextualModeActive = contextualActionItems.isNotEmpty(),
selectedShelves = contextualActionShelfIds,
isShelfContextualModeActive = contextualActionShelfIds.isNotEmpty(),
sortOrder = sortOrder,
shelves = shelves,
rawLibraryFiles = rawLibraryFiles,
containsFolderItemsInSelection = contextualActionItems.any { it.sourceFolderUri != null },
isSearchActive = isSearchActive,
searchQuery = searchQuery
)
}

View file

@ -0,0 +1,59 @@
package com.aryan.reader
import timber.log.Timber
object ReaderPerfLog {
const val TAG = "ReaderPerf"
fun nowNanos(): Long = System.nanoTime()
fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000L
fun d(message: String) {
Timber.tag(TAG).d(message)
}
fun i(message: String) {
Timber.tag(TAG).i(message)
}
fun w(message: String) {
Timber.tag(TAG).w(message)
}
inline fun <T> measure(
name: String,
minLogMs: Long = 16L,
details: () -> String = { "" },
block: () -> T
): T {
val start = nowNanos()
try {
return block()
} finally {
val elapsed = elapsedMs(start)
if (elapsed >= minLogMs) {
val extra = details().takeIf { it.isNotBlank() }?.let { " $it" }.orEmpty()
d("$name took ${elapsed}ms$extra")
}
}
}
suspend inline fun <T> measureSuspend(
name: String,
minLogMs: Long = 16L,
details: () -> String = { "" },
crossinline block: suspend () -> T
): T {
val start = nowNanos()
try {
return block()
} finally {
val elapsed = elapsedMs(start)
if (elapsed >= minLogMs) {
val extra = details().takeIf { it.isNotBlank() }?.let { " $it" }.orEmpty()
d("$name took ${elapsed}ms$extra")
}
}
}
}

View file

@ -0,0 +1,220 @@
package com.aryan.reader
import com.aryan.reader.data.BookShelfCrossRef
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity
import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.AddBooksSource as SharedAddBooksSource
import com.aryan.reader.shared.AppContrastOption as SharedAppContrastOption
import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode
import com.aryan.reader.shared.BannerMessage as SharedBannerMessage
import com.aryan.reader.shared.BookItem as SharedBookItem
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
import com.aryan.reader.shared.CustomAppTheme as SharedCustomAppTheme
import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter
import com.aryan.reader.shared.RenderMode as SharedRenderMode
import com.aryan.reader.shared.SharedLibraryProjectionInput
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.shared.SortOrder as SharedSortOrder
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
import com.aryan.reader.shared.Tag as SharedTag
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
return SharedBookItem(
id = bookId,
path = uriString,
type = type.toSharedFileType(),
displayName = customName ?: displayName,
timestamp = timestamp,
title = title,
author = author,
progressPercentage = progressPercentage,
isRecent = isRecent,
fileSize = fileSize,
sourceFolder = sourceFolderUri,
seriesName = seriesName,
seriesIndex = seriesIndex,
tags = tags.map { it.toSharedTag() }
)
}
fun TagEntity.toSharedTag(): SharedTag {
return SharedTag(
id = id,
name = name,
color = color
)
}
fun ShelfEntity.toSharedShelfRecord(): ShelfRecord {
return ShelfRecord(
id = id,
name = name,
isSmart = isSmart,
smartRulesJson = smartRulesJson
)
}
fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
return SharedBookShelfRef(
bookId = bookId,
shelfId = shelfId,
addedAt = addedAt
)
}
fun ReaderScreenState.toSharedReaderScreenState(
rawBooks: List<RecentFileItem> = rawLibraryFiles,
dbTags: List<TagEntity> = allTags
): SharedReaderScreenState {
return SharedReaderScreenState(
selectedBookId = selectedBookId,
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
selectedFileType = selectedFileType?.toSharedFileType(),
isLoading = isLoading,
errorMessage = errorMessage,
renderMode = renderMode.toSharedRenderMode(),
sortOrder = sortOrder.toSharedSortOrder(),
viewingShelfId = viewingShelfId,
isAddingBooksToShelf = isAddingBooksToShelf,
showCreateShelfDialog = showCreateShelfDialog,
mainScreenStartPage = mainScreenStartPage,
libraryScreenStartPage = libraryScreenStartPage,
showRenameShelfDialogFor = showRenameShelfDialogFor,
showDeleteShelfDialogFor = showDeleteShelfDialogFor,
addBooksSource = addBooksSource.toSharedAddBooksSource(),
booksSelectedForAdding = booksSelectedForAdding,
selectedBookIds = contextualActionItems.mapTo(mutableSetOf()) { it.bookId },
selectedShelfIds = contextualActionShelfIds,
isProUser = isProUser,
credits = credits,
isSyncEnabled = isSyncEnabled,
isFolderSyncEnabled = isFolderSyncEnabled,
bannerMessage = bannerMessage?.toSharedBannerMessage(),
downloadingBookIds = downloadingBookIds,
uploadingBookIds = uploadingBookIds,
syncedFolders = syncedFolders.map { it.toSharedSyncedFolder() },
lastFolderScanTime = lastFolderScanTime,
hasUnreadFeedback = hasUnreadFeedback,
searchQuery = searchQuery,
isSearchActive = isSearchActive,
isRefreshing = isRefreshing,
reflowProgress = reflowProgress,
recentBooks = recentFiles.map { it.toSharedBookItem() },
libraryBooks = allRecentFiles.map { it.toSharedBookItem() },
rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
pinnedHomeBookIds = pinnedHomeBookIds,
pinnedLibraryBookIds = pinnedLibraryBookIds,
libraryFilters = libraryFilters.toSharedLibraryFilters(),
recentFilesLimit = recentFilesLimit,
isTabsEnabled = isTabsEnabled,
openTabIds = openTabIds,
openTabs = openTabs.map { it.toSharedBookItem() },
activeTabBookId = activeTabBookId,
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
externalFileBehavior = externalFileBehavior,
useStrictFileFilter = useStrictFileFilter,
appThemeMode = appThemeMode.toSharedAppThemeMode(),
appContrastOption = appContrastOption.toSharedAppContrastOption(),
appTextDimFactorLight = appTextDimFactorLight,
appTextDimFactorDark = appTextDimFactorDark,
appSeedColor = appSeedColor,
customAppThemes = customAppThemes.map { it.toSharedCustomAppTheme() },
allTags = dbTags.map { it.toSharedTag() },
showTagSelectionDialogFor = showTagSelectionDialogFor
)
}
fun ReaderScreenState.toSharedLibraryProjectionInput(
recentFilesFromDb: List<RecentFileItem>,
dbShelves: List<ShelfEntity>,
shelfRefs: List<BookShelfCrossRef>,
dbTags: List<TagEntity>,
tagRefs: List<BookTagCrossRef>
): SharedLibraryProjectionInput {
val tagsById = dbTags.associateBy { it.id }
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
entry.value.mapNotNull { tagsById[it.tagId] }
}
val taggedBooks = recentFilesFromDb.map { item ->
item.copy(tags = bookTagsMap[item.bookId].orEmpty())
}
return SharedLibraryProjectionInput(
state = toSharedReaderScreenState(
rawBooks = taggedBooks,
dbTags = dbTags
),
booksFromStore = taggedBooks
.filterNot { it.bookId.endsWith("_reflow") }
.map { it.toSharedBookItem() },
shelfRecords = dbShelves.map { it.toSharedShelfRecord() },
shelfRefs = shelfRefs.map { it.toSharedBookShelfRef() },
tags = dbTags.map { it.toSharedTag() }
)
}
fun FileType.toSharedFileType(): SharedFileType {
return runCatching { SharedFileType.valueOf(name) }.getOrDefault(SharedFileType.UNKNOWN)
}
private fun RenderMode.toSharedRenderMode(): SharedRenderMode {
return SharedRenderMode.valueOf(name)
}
private fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
return SharedAddBooksSource.valueOf(name)
}
private fun SortOrder.toSharedSortOrder(): SharedSortOrder {
return SharedSortOrder.valueOf(name)
}
private fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
return SharedReadStatusFilter.valueOf(name)
}
private fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
return SharedLibraryFilters(
fileTypes = fileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() },
sourceFolders = sourceFolders,
readStatus = readStatus.toSharedReadStatusFilter(),
tagIds = tagIds
)
}
private fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder {
return SharedSyncedFolder(
uriString = uriString,
name = name,
lastScanTime = lastScanTime,
allowedFileTypes = allowedFileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() }
)
}
private fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage {
return SharedBannerMessage(
message = message,
isError = isError,
isPersistent = isPersistent
)
}
private fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode {
return SharedAppThemeMode.valueOf(name)
}
private fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption {
return SharedAppContrastOption.valueOf(name)
}
private fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme {
return SharedCustomAppTheme(
id = id,
name = name,
seedColor = seedColor
)
}

View file

@ -0,0 +1,42 @@
package com.aryan.reader
import androidx.annotation.StringRes
val AddBooksSource.labelRes: Int
@StringRes get() = when (this) {
AddBooksSource.UNSHELVED -> R.string.add_books_source_unshelved
AddBooksSource.ALL_BOOKS -> R.string.add_books_source_all_books
}
val AppThemeMode.labelRes: Int
@StringRes get() = when (this) {
AppThemeMode.SYSTEM -> R.string.app_theme_mode_system
AppThemeMode.LIGHT -> R.string.app_theme_mode_light
AppThemeMode.DARK -> R.string.app_theme_mode_dark
}
val AppContrastOption.labelRes: Int
@StringRes get() = when (this) {
AppContrastOption.STANDARD -> R.string.app_contrast_standard
AppContrastOption.MEDIUM -> R.string.app_contrast_medium
AppContrastOption.HIGH -> R.string.app_contrast_high
}
val SortOrder.labelRes: Int
@StringRes get() = when (this) {
SortOrder.RECENT -> R.string.sort_recent
SortOrder.TITLE_ASC -> R.string.sort_title_az
SortOrder.AUTHOR_ASC -> R.string.sort_author_az
SortOrder.PERCENT_ASC -> R.string.sort_percent_asc
SortOrder.PERCENT_DESC -> R.string.sort_percent_desc
SortOrder.SIZE_ASC -> R.string.sort_size_smallest
SortOrder.SIZE_DESC -> R.string.sort_size_biggest
}
val ReadStatusFilter.labelRes: Int
@StringRes get() = when (this) {
ReadStatusFilter.ALL -> R.string.read_status_all
ReadStatusFilter.UNREAD -> R.string.read_status_unread
ReadStatusFilter.IN_PROGRESS -> R.string.read_status_in_progress
ReadStatusFilter.COMPLETED -> R.string.read_status_completed
}

View file

@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
TagEntity::class,
BookTagCrossRef::class
],
version = 18,
version = 19,
exportSchema = false
)
@TypeConverters(FileTypeConverter::class)
@ -251,6 +251,12 @@ abstract class AppDatabase : RoomDatabase() {
}
}
val MIGRATION_18_19 = object : Migration(18, 19) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN folderTextMetadataParsed INTEGER NOT NULL DEFAULT 0")
}
}
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
@ -263,7 +269,7 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
MIGRATION_16_17, MIGRATION_17_18
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19
)
.fallbackToDestructiveMigration(false)
.build()
@ -272,4 +278,4 @@ abstract class AppDatabase : RoomDatabase() {
}
}
}
}
}

View file

@ -6,6 +6,7 @@ import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import com.aryan.reader.ReaderPerfLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
@ -16,6 +17,116 @@ object LocalSyncUtils {
private const val ANNOTATION_SUFFIX = "_annotations"
private const val SYNC_SUBFOLDER_NAME = "EpistemeSyncData"
private data class SyncFileEntry(
val name: String,
val uri: Uri
)
private fun syncSubfolderDocId(rootDocId: String): String {
return if (rootDocId.endsWith("/$SYNC_SUBFOLDER_NAME")) {
rootDocId
} else if (rootDocId.endsWith(":")) {
rootDocId + SYNC_SUBFOLDER_NAME
} else {
"$rootDocId/$SYNC_SUBFOLDER_NAME"
}
}
private fun querySyncSubfolderFiles(context: Context, sourceFolderUri: Uri): List<SyncFileEntry> {
val start = ReaderPerfLog.nowNanos()
val resolver = context.contentResolver
val rootDocId = try {
DocumentsContract.getTreeDocumentId(sourceFolderUri)
} catch (_: Exception) {
ReaderPerfLog.w("LocalSync direct query skipped: invalid tree uri=$sourceFolderUri")
return emptyList()
}
val syncDocId = syncSubfolderDocId(rootDocId)
val syncDirUri = DocumentsContract.buildDocumentUriUsingTree(sourceFolderUri, syncDocId)
val documentProjection = arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE)
val isSyncDir = try {
resolver.query(syncDirUri, documentProjection, null, null, null)?.use { cursor ->
cursor.moveToFirst() &&
cursor.getString(0) == DocumentsContract.Document.MIME_TYPE_DIR
} == true
} catch (_: Exception) {
false
}
if (!isSyncDir) {
ReaderPerfLog.d(
"LocalSync direct query sync dir missing rootDocId=$rootDocId syncDocId=$syncDocId"
)
return querySyncSubfolderFilesFallback(context, sourceFolderUri, "missing-direct-sync-dir")
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(sourceFolderUri, syncDocId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
val entries = mutableListOf<SyncFileEntry>()
try {
resolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
while (cursor.moveToNext()) {
val mimeType = cursor.getString(mimeCol)
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) continue
val name = cursor.getString(nameCol) ?: continue
val docId = cursor.getString(idCol) ?: continue
entries.add(
SyncFileEntry(
name = name,
uri = DocumentsContract.buildDocumentUriUsingTree(sourceFolderUri, docId)
)
)
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to query sync subfolder directly")
return querySyncSubfolderFilesFallback(context, sourceFolderUri, "direct-query-error")
}
ReaderPerfLog.d(
"LocalSync direct query files=${entries.size} elapsed=${ReaderPerfLog.elapsedMs(start)}ms syncDocId=$syncDocId"
)
return entries
}
private fun querySyncSubfolderFilesFallback(
context: Context,
sourceFolderUri: Uri,
reason: String
): List<SyncFileEntry> {
val start = ReaderPerfLog.nowNanos()
return try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri)
val syncDir = rootTree?.findFile(SYNC_SUBFOLDER_NAME)
if (syncDir == null || !syncDir.isDirectory) {
ReaderPerfLog.w("LocalSync fallback query found no sync dir reason=$reason uri=$sourceFolderUri")
emptyList()
} else {
val entries = syncDir.listFiles()
.asSequence()
.filter { it.isFile }
.mapNotNull { file ->
val name = file.name ?: return@mapNotNull null
SyncFileEntry(name = name, uri = file.uri)
}
.toList()
ReaderPerfLog.d(
"LocalSync fallback query files=${entries.size} elapsed=${ReaderPerfLog.elapsedMs(start)}ms reason=$reason"
)
entries
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to query sync subfolder fallback")
emptyList()
}
}
private fun getOrCreateSyncDir(rootTree: DocumentFile): DocumentFile? {
val existing = rootTree.findFile(SYNC_SUBFOLDER_NAME)
if (existing != null && existing.isDirectory) return existing
@ -209,22 +320,22 @@ object LocalSyncUtils {
suspend fun preloadAnnotationSidecars(
context: Context,
rootTree: DocumentFile
sourceFolderUri: Uri
): Map<String, Pair<Long, String>> = withContext(Dispatchers.IO) {
val results = mutableMapOf<String, Pair<Long, String>>()
try {
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME)
if (syncDir == null || !syncDir.isDirectory) return@withContext results
val bookIds = syncDir.listFiles()
.mapNotNull { extractAnnotationBookId(it.name) }
.toSet()
for (bookId in bookIds) {
val best = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
if (best != null) {
results[bookId] = best
val groupedFiles = querySyncSubfolderFiles(context, sourceFolderUri)
.filter { file ->
val name = file.name
extractAnnotationBookId(name) != null &&
!name.contains(".syncthing.")
}
.groupBy { file -> extractAnnotationBookId(file.name).orEmpty() }
for ((bookId, files) in groupedFiles) {
val best = resolveAnnotationConflictsReadOnly(context, bookId, files)
if (best != null) results[bookId] = best
}
} catch (e: Exception) {
Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars")
@ -233,6 +344,44 @@ object LocalSyncUtils {
return@withContext results
}
private fun resolveAnnotationConflictsReadOnly(
context: Context,
bookId: String,
files: List<SyncFileEntry>
): Pair<Long, String>? {
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
var bestTs = -1L
var bestData: String? = null
for (file in files) {
val name = file.name
if (!((name.startsWith(basePattern) || name.startsWith(legacyPattern)) &&
name.endsWith(".json") &&
!name.endsWith(".tmp") &&
!name.contains(".syncthing."))
) {
continue
}
try {
val content = context.contentResolver.openInputStream(file.uri)?.use {
it.bufferedReader().readText()
} ?: continue
val json = JSONObject(content)
val ts = json.optLong("timestamp", 0L)
val data = json.optJSONObject("data")?.toString()
if (data != null && ts > bestTs) {
bestTs = ts
bestData = data
}
} catch (e: Exception) {
Timber.tag("FolderAnnotationSync").e(e, "Error parsing annotation sidecar: $name")
}
}
return bestData?.let { bestTs to it }
}
suspend fun getAnnotationSidecar(
context: Context,
sourceFolderUri: Uri,
@ -253,12 +402,13 @@ object LocalSyncUtils {
private fun resolveAndCleanAnnotationConflicts(
context: Context,
syncDir: DocumentFile,
bookId: String
bookId: String,
knownFiles: List<DocumentFile>? = null
): Pair<Long, String>? {
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
val allFiles = syncDir.listFiles()
val allFiles = knownFiles ?: syncDir.listFiles().asList()
val candidates = allFiles.filter { file ->
val name = file.name ?: ""
@ -475,21 +625,17 @@ object LocalSyncUtils {
val finalResults = mutableMapOf<String, FolderBookMetadata>()
try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME)
if (syncDir == null || !syncDir.isDirectory) return@withContext finalResults
val allFiles = syncDir.listFiles()
val allFiles = querySyncSubfolderFiles(context, sourceFolderUri)
val groupedFiles = allFiles
.filter {
val name = it.name ?: ""
val name = it.name
(name.endsWith(".json") || name.contains(".sync-conflict")) &&
!name.contains(ANNOTATION_SUFFIX) &&
!name.endsWith(".tmp") &&
!name.contains(".syncthing.")
}
.groupBy { file ->
var name = file.name ?: ""
var name = file.name
if (name.startsWith(".")) name = name.substring(1)
if (name.contains(".sync-conflict")) {
name.substringBefore(".sync-conflict")
@ -499,17 +645,47 @@ object LocalSyncUtils {
}
groupedFiles.forEach { (bookId, files) ->
val winner = resolveAndCleanConflicts(context, files, bookId)
val winner = resolveMetadataConflictsReadOnly(context, files, bookId)
if (winner != null) {
finalResults[bookId] = winner
}
}
Timber.tag(TAG).d("getAllFolderMetadata: Consolidated ${groupedFiles.size} book records from root.")
Timber.tag(TAG).d("getAllFolderMetadata: Read ${finalResults.size}/${groupedFiles.size} book records from sync data.")
ReaderPerfLog.d(
"LocalSync metadata read files=${allFiles.size} groups=${groupedFiles.size} records=${finalResults.size}"
)
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Error scanning root folder for metadata")
Timber.tag(TAG).e(e, "Error scanning sync data folder for metadata")
ReaderPerfLog.w("LocalSync metadata read failed uri=$sourceFolderUri")
}
return@withContext finalResults
}
private fun resolveMetadataConflictsReadOnly(
context: Context,
files: List<SyncFileEntry>,
bookId: String
): FolderBookMetadata? {
var bestMeta: FolderBookMetadata? = null
for (file in files) {
try {
val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input ->
input.bufferedReader().use { it.readText() }
}
if (jsonString != null) {
val meta = FolderBookMetadata.fromJsonString(jsonString)
if (meta.bookId == bookId &&
(bestMeta == null || meta.lastModifiedTimestamp > bestMeta!!.lastModifiedTimestamp)
) {
bestMeta = meta
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to parse metadata sidecar: ${file.name}")
}
}
return bestMeta
}
}

View file

@ -90,8 +90,59 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
suspend fun markAsNotRecent(bookIds: List<String>, timestamp: Long)
@Query("SELECT * FROM recent_files WHERE sourceFolderUri IS NOT NULL AND coverImagePath IS NULL AND isDeleted = 0")
suspend fun getFolderBooksWithoutCovers(): List<RecentFileEntity>
@Query("""
SELECT * FROM recent_files
WHERE sourceFolderUri IS NOT NULL
AND isDeleted = 0
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
AND folderTextMetadataParsed = 0
""")
suspend fun getFolderBooksNeedingTextMetadata(): List<RecentFileEntity>
@Query("""
SELECT * FROM recent_files
WHERE sourceFolderUri = :sourceFolderUri
AND isDeleted = 0
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
AND folderTextMetadataParsed = 0
""")
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String): List<RecentFileEntity>
@Query("""
SELECT COUNT(*) FROM recent_files
WHERE sourceFolderUri IS NOT NULL
AND isDeleted = 0
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
AND folderTextMetadataParsed = 0
""")
suspend fun countFolderBooksNeedingTextMetadata(): Int
@Query("""
SELECT COUNT(*) FROM recent_files
WHERE sourceFolderUri = :sourceFolderUri
AND isDeleted = 0
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
AND folderTextMetadataParsed = 0
""")
suspend fun countFolderBooksNeedingTextMetadata(sourceFolderUri: String): Int
@Query("""
UPDATE recent_files
SET
coverImagePath = COALESCE(:coverImagePath, coverImagePath),
title = COALESCE(:title, title),
author = COALESCE(:author, author),
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
folderTextMetadataParsed = 1
WHERE bookId = :bookId
""")
suspend fun updateExtractedMetadata(
bookId: String,
coverImagePath: String?,
title: String?,
author: String?,
fileSize: Long
)
@Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL")
suspend fun detachAllFolderBooks()

View file

@ -54,7 +54,8 @@ data class RecentFileEntity(
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
@ColumnInfo(defaultValue = "NULL") val description: String?
@ColumnInfo(defaultValue = "NULL") val description: String?,
@ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean
)
data class RecentFileSummary(
@ -83,4 +84,4 @@ data class RecentFileSummary(
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
@ColumnInfo(defaultValue = "NULL") val description: String?
)
)

View file

@ -19,9 +19,7 @@
*/
package com.aryan.reader.data
import android.net.Uri
import com.aryan.reader.FileType
import androidx.core.net.toUri
data class RecentFileItem(
val bookId: String,
@ -51,10 +49,9 @@ data class RecentFileItem(
val seriesName: String? = null,
val seriesIndex: Double? = null,
val description: String? = null,
val folderTextMetadataParsed: Boolean = false,
val tags: List<TagEntity> = emptyList()
) {
fun getUri(): Uri? = uriString?.toUri()
}
)
fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
return RecentFileItem(
@ -84,7 +81,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
fileSize = this.fileSize,
seriesName = this.seriesName,
seriesIndex = this.seriesIndex,
description = this.description
description = this.description,
folderTextMetadataParsed = this.folderTextMetadataParsed
)
}
@ -116,7 +114,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
fileSize = this.fileSize,
seriesName = this.seriesName,
seriesIndex = this.seriesIndex,
description = this.description
description = this.description,
folderTextMetadataParsed = this.folderTextMetadataParsed
)
}
@ -199,4 +198,4 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem {
seriesIndex = this.seriesIndex,
description = this.description
)
}
}

View file

@ -0,0 +1,6 @@
package com.aryan.reader.data
import android.net.Uri
import androidx.core.net.toUri
fun RecentFileItem.getUri(): Uri? = uriString?.toUri()

View file

@ -24,6 +24,7 @@ import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.core.net.toUri
import com.aryan.reader.ReaderPerfLog
import timber.log.Timber
import com.aryan.reader.BookImporter
import com.aryan.reader.paginatedreader.Locator
@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import androidx.room.withTransaction
import java.io.File
import java.io.FileOutputStream
import com.aryan.reader.pdf.data.PdfAnnotationRepository
@ -47,7 +49,8 @@ private const val COVER_CACHE_DIR = "cover_cache"
class RecentFilesRepository(private val context: Context) {
private val recentFileDao = AppDatabase.getDatabase(context).recentFileDao()
private val database = AppDatabase.getDatabase(context)
private val recentFileDao = database.recentFileDao()
private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR)
private val bookImporter = BookImporter(context)
@ -57,10 +60,10 @@ class RecentFilesRepository(private val context: Context) {
private val pdfTextBoxRepository = PdfTextBoxRepository(context)
private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context)
val activeShelvesFlow = AppDatabase.getDatabase(context).shelfDao().getAllActiveShelves()
val shelfCrossRefsFlow = AppDatabase.getDatabase(context).shelfDao().getAllBookShelfCrossRefs()
val tagsFlow = AppDatabase.getDatabase(context).tagDao().getAllTags()
val tagCrossRefsFlow = AppDatabase.getDatabase(context).tagDao().getAllBookTagCrossRefs()
val activeShelvesFlow = database.shelfDao().getAllActiveShelves()
val shelfCrossRefsFlow = database.shelfDao().getAllBookShelfCrossRefs()
val tagsFlow = database.tagDao().getAllTags()
val tagCrossRefsFlow = database.tagDao().getAllBookTagCrossRefs()
init {
if (!coverCacheDir.exists()) {
@ -184,7 +187,8 @@ class RecentFilesRepository(private val context: Context) {
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize,
seriesName = item.seriesName ?: existingItem.seriesName,
seriesIndex = item.seriesIndex ?: existingItem.seriesIndex,
description = item.description ?: existingItem.description
description = item.description ?: existingItem.description,
folderTextMetadataParsed = item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed
)
} else {
item.toRecentFileEntity()
@ -355,12 +359,25 @@ class RecentFilesRepository(private val context: Context) {
}
suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) {
val filesToRemove = getFilesBySourceFolder(folderUriString)
if (filesToRemove.isNotEmpty()) {
Timber.d("DeleteDebug: Cascading deletion for ${filesToRemove.size} files from folder.")
deleteFilePermanently(filesToRemove.map { it.bookId })
} else {
recentFileDao.deleteFilesBySourceFolder(folderUriString)
val start = ReaderPerfLog.nowNanos()
val filesToRemove = recentFileDao.getFilesBySourceFolder(folderUriString)
recentFileDao.deleteFilesBySourceFolder(folderUriString)
ReaderPerfLog.i(
"FolderRemove db delete books=${filesToRemove.size} elapsed=${ReaderPerfLog.elapsedMs(start)}ms folder=$folderUriString"
)
filesToRemove.forEach { item ->
item.coverImagePath?.let { deleteCachedCover(it) }
try {
pdfAnnotationRepository.getAnnotationFileForSync(item.bookId)?.delete()
pdfRichTextRepository.getFileForSync(item.bookId).delete()
pageLayoutRepository.getLayoutFile(item.bookId).delete()
pdfTextBoxRepository.getFileForSync(item.bookId).delete()
pdfHighlightRepository.getFileForSync(item.bookId).delete()
ImportedFileCache.clearBookCache(context, item.bookId)
} catch (e: Exception) {
Timber.e(e, "Error during local cleanup for detached folder book ${item.bookId}")
}
}
}
@ -381,8 +398,40 @@ class RecentFilesRepository(private val context: Context) {
}
}
suspend fun getFolderBooksWithoutCovers(): List<RecentFileItem> = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() }
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): List<RecentFileItem> = withContext(Dispatchers.IO) {
val entities = if (sourceFolderUri.isNullOrBlank()) {
recentFileDao.getFolderBooksNeedingTextMetadata()
} else {
recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri)
}
return@withContext entities.map { it.toRecentFileItem() }
}
suspend fun hasFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): Boolean = withContext(Dispatchers.IO) {
val count = if (sourceFolderUri.isNullOrBlank()) {
recentFileDao.countFolderBooksNeedingTextMetadata()
} else {
recentFileDao.countFolderBooksNeedingTextMetadata(sourceFolderUri)
}
return@withContext count > 0
}
suspend fun updateExtractedMetadata(items: List<RecentFileItem>) = withContext(Dispatchers.IO) {
if (items.isEmpty()) return@withContext
items.chunked(300).forEach { chunk ->
database.withTransaction {
chunk.forEach { item ->
recentFileDao.updateExtractedMetadata(
bookId = item.bookId,
coverImagePath = item.coverImagePath,
title = item.title,
author = item.author,
fileSize = item.fileSize
)
}
}
}
Timber.tag(ReaderPerfLog.TAG).d("Metadata extraction batch updated ${items.size} rows.")
}
suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) {

View file

@ -23,6 +23,7 @@ import android.graphics.Bitmap
import com.aryan.reader.epub.EpubParser.EpubPageTarget
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.io.File
@Serializable
data class EpubTocEntry(
@ -50,4 +51,15 @@ data class EpubBook(
val seriesName: String? = null,
val seriesIndex: Double? = null,
val description: String? = null,
)
)
fun EpubBook.hasReadableExtractedContent(): Boolean {
if (extractionBasePath.isBlank()) return false
val extractionDir = File(extractionBasePath)
if (!extractionDir.isDirectory) return false
if (chapters.isEmpty()) return extractionDir.list()?.isNotEmpty() == true
return chapters.all { chapter ->
File(extractionDir, chapter.htmlFilePath).isFile
}
}

View file

@ -9,8 +9,12 @@ object ImportedFileCache {
private const val TEMP_PREFIX = "imported_file_tmp_"
private val invalidSegmentChars = Regex("[^A-Za-z0-9._-]+")
fun activeBookDirName(bookId: String): String {
return "$ACTIVE_PREFIX${bookMarker(bookId)}"
}
fun activeBookDir(context: Context, bookId: String): File {
return File(context.cacheDir, "$ACTIVE_PREFIX$bookId")
return File(context.cacheDir, activeBookDirName(bookId))
}
fun prepareActiveBookDir(context: Context, bookId: String): File {
@ -39,6 +43,7 @@ object ImportedFileCache {
fun clearBookCache(context: Context, bookId: String) {
activeBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
legacyActiveBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
clearTemporaryBookDirs(context, bookId)
}
@ -69,6 +74,10 @@ object ImportedFileCache {
return name.startsWith(ACTIVE_PREFIX) && !isTemporaryBookDir(name)
}
private fun legacyActiveBookDir(context: Context, bookId: String): File {
return File(context.cacheDir, "$ACTIVE_PREFIX$bookId")
}
private fun bookMarker(bookId: String): String {
val normalized = bookId.toCacheSegment().ifBlank { "book" }.take(40)
val hash = bookId.hashCode().toLong() and 0xffffffffL

View file

@ -109,10 +109,20 @@ class MobiParser(private val context: Context) {
private external fun parseMobiFile(filePath: String): ParsedMobiData?
companion object {
init {
private val nativeLoadError: Throwable? = try {
System.loadLibrary("mobi")
System.loadLibrary("native-lib")
null
} catch (t: Throwable) {
Timber.e(t, "MOBI native parser is unavailable on this device.")
t
}
val isNativeParserAvailable: Boolean
get() = nativeLoadError == null
fun nativeParserUnavailableMessage(): String =
nativeLoadError?.message ?: "MOBI native parser is unavailable on this device."
}
suspend fun createMobiBook(
@ -122,6 +132,11 @@ class MobiParser(private val context: Context) {
parseContent: Boolean = true,
extractionDirOverride: File? = null
): EpubBook? = withContext(Dispatchers.IO) {
if (!isNativeParserAvailable) {
Timber.e("Skipping MOBI parsing: ${nativeParserUnavailableMessage()}")
return@withContext null
}
val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir)
try {
tempFile.outputStream().use { output ->

View file

@ -35,6 +35,8 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.safety.Safelist
import org.zwobble.mammoth.DocumentConverter
import timber.log.Timber
import java.io.File
@ -52,6 +54,15 @@ class SingleFileImporter(private val context: Context) {
private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L
}
private val htmlSafelist = Safelist.relaxed()
.addTags("article", "aside", "details", "div", "figcaption", "figure", "footer", "header", "main", "section", "summary")
.addAttributes(":all", "class", "dir", "id", "lang", "title")
.addAttributes("a", "name", "target")
.addProtocols("a", "href", "http", "https", "mailto", "tel", "#")
.addProtocols("img", "src", "http", "https", "data", "file", "content")
private val htmlOutputSettings = Document.OutputSettings().prettyPrint(false)
suspend fun importSingleFile(
inputStream: InputStream,
type: FileType,
@ -61,8 +72,9 @@ class SingleFileImporter(private val context: Context) {
): EpubBook {
val lowerHint = originalBookNameHint.lowercase()
val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv")
val isCodeOrData = listOf(".json", ".xml", ".log", ".java", ".kt", ".py", ".js", ".cpp", ".c", ".cs", ".rb", ".go").any { lowerHint.endsWith(it) }
val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv") ||
lowerHint.endsWith(".csv.txt") || lowerHint.endsWith(".tsv.txt")
val isCodeOrData = com.aryan.reader.isCodeOrDataFileName(originalBookNameHint)
if (type == FileType.HTML && (isCsv || isCodeOrData)) {
return parseDynamicContentToHtml(inputStream, originalBookNameHint, bookId, parseContent, isCsv)
@ -99,7 +111,7 @@ class SingleFileImporter(private val context: Context) {
writer.write("</head>\n<body>\n<pre><code>\n")
}
val delimiter = if (originalBookNameHint.lowercase().endsWith(".tsv")) '\t' else ','
val delimiter = if (originalBookNameHint.lowercase().let { it.endsWith(".tsv") || it.endsWith(".tsv.txt") }) '\t' else ','
inputStream.bufferedReader().use { reader ->
var line = reader.readLine()
@ -177,11 +189,7 @@ class SingleFileImporter(private val context: Context) {
)
}
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
@ -245,7 +253,7 @@ class SingleFileImporter(private val context: Context) {
val chapterTitle = "Page $pageNum"
val document = parser.parse(rawText)
val htmlBody = renderer.render(document)
val htmlBody = sanitizeHtmlFragment(renderer.render(document))
val fileName = "page_$pageNum.html"
val file = File(extractionDir, fileName)
@ -315,11 +323,7 @@ class SingleFileImporter(private val context: Context) {
)
}
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
@ -480,11 +484,7 @@ class SingleFileImporter(private val context: Context) {
)
}
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
@ -507,6 +507,7 @@ class SingleFileImporter(private val context: Context) {
val chapters = mutableListOf<EpubChapter>()
inputStream.bufferedReader().use { reader ->
var inScript = false
var inStyle = false
var inBody = false
var pageNum = 1
@ -516,6 +517,19 @@ class SingleFileImporter(private val context: Context) {
while (reader.readLine().also { line = it } != null) {
val trimmed = line!!.trim()
if (inScript) {
if (trimmed.contains("</script", ignoreCase = true)) {
inScript = false
}
continue
}
if (trimmed.startsWith("<script", ignoreCase = true)) {
if (!trimmed.contains("</script", ignoreCase = true)) {
inScript = true
}
continue
}
if (!inBody) {
if (trimmed.startsWith("<title", ignoreCase = true)) {
val t = trimmed.substringAfter(">").substringBefore("</title>")
@ -632,6 +646,10 @@ class SingleFileImporter(private val context: Context) {
return@withContext book
}
private fun sanitizeHtmlFragment(html: String): String {
return Jsoup.clean(html, "", htmlSafelist, htmlOutputSettings)
}
private suspend fun parseDocx(
inputStream: InputStream,
originalBookNameHint: String,
@ -654,11 +672,7 @@ class SingleFileImporter(private val context: Context) {
)
}
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
@ -754,8 +768,9 @@ class SingleFileImporter(private val context: Context) {
val chapterTitle = if (pageNum > 1 || bodyContent.contains("<page-break")) "Page $pageNum" else title
val fileName = "page_$pageNum.html"
val file = File(extractionDir, fileName)
val sanitizedBodyContent = sanitizeHtmlFragment(bodyContent)
val fullHtml = "<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n<title>${title.replace("\"", "&quot;")}</title>\n<style>${cssStyle}</style>\n</head>\n<body>\n${bodyContent.trim()}\n</body>\n</html>"
val fullHtml = "<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n<title>${title.replace("\"", "&quot;")}</title>\n<style>${cssStyle}</style>\n</head>\n<body>\n${sanitizedBodyContent.trim()}\n</body>\n</html>"
file.writeText(fullHtml)

View file

@ -26,10 +26,8 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.Color
import android.graphics.Rect
import android.util.Base64
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
@ -83,13 +81,12 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri
import com.aryan.reader.R
import com.aryan.reader.ReaderTexture
import com.aryan.reader.getReaderTextureDataUri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.json.JSONObject
import timber.log.Timber
import java.io.BufferedReader
import java.io.ByteArrayOutputStream
import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV"
@ -351,6 +348,7 @@ fun ChapterWebView(
currentParagraphGap: Float,
currentImageSize: Float,
currentHorizontalMargin: Float,
currentVerticalMargin: Float,
onChapterInitiallyScrolled: () -> Unit,
modifier: Modifier = Modifier,
onTap: () -> Unit,
@ -386,7 +384,8 @@ fun ChapterWebView(
activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit,
onInternalLinkClick: (String) -> Unit,
activeTextureId: String? = null
activeTextureId: String? = null,
activeTextureAlpha: Float = 0.55f
) {
Timber.d(
"RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget"
@ -406,17 +405,8 @@ fun ChapterWebView(
val textureBase64 by remember(activeTextureId) {
mutableStateOf(
activeTextureId?.let { id ->
ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
val bmp = BitmapFactory.decodeResource(context.resources, resId)
val out = ByteArrayOutputStream()
bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
"data:image/png;base64," + Base64.encodeToString(
out.toByteArray(),
Base64.NO_WRAP
)
}
})
getReaderTextureDataUri(context, activeTextureId)
)
}
val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady)
@ -476,10 +466,10 @@ fun ChapterWebView(
Box(modifier = modifier.fillMaxSize()) {
LaunchedEffect(isDarkTheme, effectiveBg, effectiveText, textureBase64) {
LaunchedEffect(isDarkTheme, effectiveBg, effectiveText, textureBase64, activeTextureAlpha) {
val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
val textHex = String.format("#%06X", (0xFFFFFF and effectiveText.toArgb()))
localWebViewRef?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"});", null)
localWebViewRef?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"}, ${activeTextureAlpha.coerceIn(0f, 1f)});", null)
}
key(
@ -489,6 +479,7 @@ fun ChapterWebView(
currentParagraphGap,
currentImageSize,
currentHorizontalMargin,
currentVerticalMargin,
currentFontFamily,
currentTextAlign
) {
@ -737,7 +728,7 @@ fun ChapterWebView(
val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
val textHex =
String.format("#%06X", (0xFFFFFF and effectiveText.toArgb()))
view?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"});",
view?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"}, ${activeTextureAlpha.coerceIn(0f, 1f)});",
null)
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
@ -782,7 +773,7 @@ fun ChapterWebView(
}
view?.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
null
)
@ -946,7 +937,7 @@ fun ChapterWebView(
)
webView.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
null
)

View file

@ -41,8 +41,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.graphics.drawable.toBitmap
import com.aryan.reader.BuildConfig
import com.aryan.reader.R
import com.aryan.reader.areReaderAiFeaturesEnabled
@Suppress("KotlinConstantConditions")
@OptIn(ExperimentalMaterial3Api::class)
@ -92,7 +92,7 @@ fun DictionarySettingsDialog(
)
// ── Dictionary ──
if (BuildConfig.FLAVOR != "oss") {
if (areReaderAiFeaturesEnabled(context)) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
@ -342,4 +342,4 @@ private fun AppSelectionDropdown(
}
}
}
}
}

View file

@ -32,11 +32,14 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiFeature
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.AiHubBottomSheet
import com.aryan.reader.BuildConfig
import com.aryan.reader.R
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.callByokTextAi
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.fetchRecap
import com.aryan.reader.paginatedreader.IPaginator
@ -55,6 +58,7 @@ import java.net.URL
*/
suspend fun summarizeBookContent(
content: String,
context: Context,
authToken: String?,
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> },
onUpdate: (String) -> Unit,
@ -68,6 +72,27 @@ suspend fun summarizeBookContent(
}
Timber.d("Starting summarization for content of length: ${content.length}")
@Suppress("KotlinConstantConditions")
if (BuildConfig.FLAVOR == "oss") {
if (BuildConfig.IS_OFFLINE) {
onError("AI features are unavailable in the offline OSS build.")
onFinish()
return
}
callByokTextAi(
context = context,
feature = AiFeature.SUMMARIZE,
systemInstruction = "You are an expert in analyzing written content. Provide a concise, easy-to-read summary of the provided chapter. Identify the main ideas, plot points, and themes. Do not add a preamble like 'Here is the summary:'",
userPrompt = content,
temperature = 0.2,
maxTokens = 8192,
onUpdate = onUpdate,
onError = onError
)
onFinish()
return
}
withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null
try {
@ -196,6 +221,7 @@ suspend fun executeRecapLogic(
summarizeBookContent(
content = textToSummarize,
context = context,
authToken = authToken,
onUsageReceived = { cost, _ ->
Timber.i("[AI-Billing] Background past chapter summary cost: $cost credits")
@ -387,4 +413,4 @@ fun EpubReaderAiOverlays(
}
)
}
}
}

View file

@ -123,7 +123,9 @@ fun VerticalScrollbar(
if (viewportRatio >= 1f) return@derivedStateOf null
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
val maxThumbHeight = viewportHeight / 2f
val minThumbHeight = minOf(80f, maxThumbHeight)
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight)
val firstItemIndex = listState.firstVisibleItemIndex
val firstItemOffset = listState.firstVisibleItemScrollOffset

View file

@ -19,7 +19,7 @@
*/
// EpubReaderScreen.kt
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead",
"UnusedVariable", "Unused", "SimplifyBooleanWithConstants"
"UnusedVariable", "Unused", "SimplifyBooleanWithConstants", "KotlinConstantConditions"
)
package com.aryan.reader.epubreader
@ -35,6 +35,8 @@ import android.graphics.Bitmap
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.view.RoundedCorner
import android.view.View
import android.webkit.WebView
import android.widget.Toast
import androidx.activity.compose.BackHandler
@ -119,9 +121,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@ -134,6 +141,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.edit
@ -158,12 +166,17 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.countWords
import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.hasReadableExtractedContent
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadReaderThemeId
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.CfiUtils
import com.aryan.reader.paginatedreader.HeaderBlock
@ -180,11 +193,11 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@ -218,12 +231,50 @@ private const val AUTO_SCROLL_LOCAL_MAX_PREFIX = "auto_scroll_local_max_"
private const val MUSICIAN_MODE_KEY = "musician_mode_enabled"
private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled"
private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools"
private const val TOOL_ORDER_KEY = "reader_tool_order"
private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools"
private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore"
private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume"
private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
private const val TAG_LINK_NAV = "LINK_NAV"
private fun View.bottomRoundedCornerRadiusPx(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
val insets = rootWindowInsets ?: return 0
return max(
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)?.radius ?: 0,
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)?.radius ?: 0
)
}
@Composable
private fun rememberBottomRoundedCornerPadding(view: View): Dp {
val density = LocalDensity.current
val configuration = LocalConfiguration.current
var radiusPx by remember(view) { mutableIntStateOf(view.bottomRoundedCornerRadiusPx()) }
DisposableEffect(
view,
configuration.orientation,
configuration.screenWidthDp,
configuration.screenHeightDp
) {
val listener = View.OnLayoutChangeListener { updatedView, _, _, _, _, _, _, _, _ ->
radiusPx = updatedView.bottomRoundedCornerRadiusPx()
}
view.addOnLayoutChangeListener(listener)
radiusPx = view.bottomRoundedCornerRadiusPx()
onDispose {
view.removeOnLayoutChangeListener(listener)
}
}
return with(density) { radiusPx.toDp() }
}
private fun saveHiddenTools(context: Context, hiddenTools: Set<String>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) }
@ -234,6 +285,34 @@ private fun loadHiddenTools(context: Context): Set<String> {
return prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
}
private fun saveToolOrder(context: Context, toolOrder: List<ReaderTool>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
}
private fun loadToolOrder(context: Context): List<ReaderTool> {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val savedTools = prefs.getString(TOOL_ORDER_KEY, null)
?.split(',')
?.filter { it.isNotBlank() }
?.mapNotNull { name -> ReaderTool.entries.firstOrNull { it.name == name } }
.orEmpty()
return (savedTools + ReaderTool.entries.filterNot { it in savedTools }).distinct()
}
private fun saveBottomTools(context: Context, bottomTools: Set<String>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putStringSet(BOTTOM_TOOLS_KEY, bottomTools) }
}
private fun loadBottomTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getStringSet(
BOTTOM_TOOLS_KEY,
ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
) ?: ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
}
private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(KEEP_SCREEN_ON_KEY, isEnabled) }
@ -338,7 +417,7 @@ private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private fun loadUseOnlineDict(context: Context): Boolean {
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) return false
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
}
@ -381,6 +460,7 @@ private fun saveExternalSearchPackage(context: Context, packageName: String) {
const val PREF_READER_THEME = "reader_theme_id"
const val PREF_CUSTOM_THEMES = "custom_themes_json"
@UnstableApi
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
fun EpubReaderScreen(
@ -416,8 +496,8 @@ fun EpubReaderScreen(
}
} else null
val hasValidExtractionBasePath = remember(epubBook.extractionBasePath) {
epubBook.extractionBasePath.isNotBlank() && File(epubBook.extractionBasePath).exists()
val hasValidExtractionBasePath = remember(epubBook.extractionBasePath, epubBook.chapters) {
epubBook.hasReadableExtractedContent()
}
var requestedContentRecovery by remember(epubBook.extractionBasePath, uiState.selectedBookId) {
mutableStateOf(false)
@ -563,6 +643,7 @@ fun EpubReaderHost(
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) }
var pageInfoPosition by remember { mutableStateOf(loadPageInfoPosition(context)) }
var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) }
var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) }
var showVisualOptionsSheet by remember { mutableStateOf(false) }
@ -582,7 +663,7 @@ fun EpubReaderHost(
var currentTtsMode by remember {
mutableStateOf(
loadTtsMode(context).let {
if (BuildConfig.FLAVOR == "oss") TtsPlaybackManager.TtsMode.BASE else it
if (BuildConfig.FLAVOR == "oss" && !isByokCloudTtsAvailable(context)) TtsPlaybackManager.TtsMode.BASE else it
}
)
}
@ -738,18 +819,19 @@ fun EpubReaderHost(
}
var hiddenTools by remember { mutableStateOf(loadHiddenTools(context)) }
var toolOrder by remember { mutableStateOf(loadToolOrder(context)) }
var bottomTools by remember { mutableStateOf(loadBottomTools(context)) }
var showCustomizeToolsSheet by remember { mutableStateOf(false) }
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
@Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String ->
val isOss = BuildConfig.FLAVOR == "oss"
val effectiveUseOnline = !isOss && useOnlineDictionary
val effectiveUseOnline = areReaderAiFeaturesEnabled(context) && useOnlineDictionary
if (effectiveUseOnline) {
val wordCount = countWords(word)
if (wordCount > 1 && !isProUser) {
if (BuildConfig.FLAVOR != "oss" && wordCount > 1 && !isProUser) {
showDictionaryUpsellDialog = true
} else {
selectedTextForAi = word
@ -815,6 +897,8 @@ fun EpubReaderHost(
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
val pageInfoCornerBottomPadding = roundedCornerBottomPadding.coerceAtMost(8.dp)
var bookmarks by remember(epubBook.title) {
mutableStateOf(
@ -991,6 +1075,7 @@ fun EpubReaderHost(
var currentParagraphGap by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.paragraphGap) }
var currentImageSize by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.imageSize) }
var currentHorizontalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.horizontalMargin) }
var currentVerticalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.verticalMargin) }
var currentTextAlign by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.textAlign) }
var currentFontFamily by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.font) }
var currentCustomFontPath by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.customPath) }
@ -1006,14 +1091,14 @@ fun EpubReaderHost(
var showFontSelectionSheet by remember { mutableStateOf(false) }
val fontSheetState = rememberModalBottomSheetState()
LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) {
LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) {
if (isFormatLocal) {
saveLocalReaderSettings(
context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
)
} else {
saveReaderSettings(
context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
)
}
}
@ -1130,6 +1215,7 @@ fun EpubReaderHost(
var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
var globalTextureTransparency by remember { mutableFloatStateOf(loadGlobalTextureTransparency(context)) }
val activeTheme = remember(currentThemeId, customThemes) {
BuiltInThemes.find { it.id == currentThemeId }
@ -1151,6 +1237,19 @@ fun EpubReaderHost(
} else activeTheme.textColor
}
val activeTextureId = activeTheme.textureId
val activeTextureAlpha = 1f - globalTextureTransparency
val activeTextureBitmap = remember(activeTextureId) {
loadReaderTextureBitmap(context, activeTextureId)
}
val activeTextureModifier = activeTextureBitmap?.let { bitmap ->
Modifier.drawBehind {
drawRect(
brush = ShaderBrush(ImageShader(bitmap, TileMode.Repeated, TileMode.Repeated)),
blendMode = BlendMode.SrcOver,
alpha = activeTextureAlpha.coerceIn(0f, 1f)
)
}
} ?: Modifier
val infoBarBgColor = remember(effectiveBg, isDarkTheme) {
val overlayAlpha = if (isDarkTheme) 0.08f else 0.06f
@ -1401,7 +1500,7 @@ fun EpubReaderHost(
}
fun startTts() {
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
return
}
@ -1441,6 +1540,7 @@ fun EpubReaderHost(
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
chapterIndex = chapterIndex,
totalChapters = chapters.size,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@ -1460,7 +1560,7 @@ fun EpubReaderHost(
)
fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) {
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
return
}
@ -1504,6 +1604,7 @@ fun EpubReaderHost(
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
chapterIndex = chapterIndex,
totalChapters = chapters.size,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@ -1582,18 +1683,6 @@ fun EpubReaderHost(
focusRequester = searchFocusRequester
)
if (epubBook.extractionBasePath.isBlank() || !File(epubBook.extractionBasePath).exists()) {
Timber.e("Extraction base path is blank or does not exist: ${epubBook.extractionBasePath}"
)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"Error: Book content not found. Path: ${epubBook.extractionBasePath}",
color = MaterialTheme.colorScheme.error
)
}
return
}
val totalPagesInCurrentChapter = remember(currentScrollHeightValue, currentClientHeightValue) {
if (currentClientHeightValue > 0) {
max(
@ -1989,10 +2078,7 @@ fun EpubReaderHost(
}
}
val pageInfoBottomPadding by animateDpAsState(
targetValue = if (showBars && pageInfoMode == PageInfoMode.SYNC) 45.dp else 0.dp,
label = "PageInfoBottomPadding"
)
val pageInfoBarHeight = PAGE_INFO_BAR_HEIGHT + pageInfoCornerBottomPadding
val isPageInfoVisible = when (pageInfoMode) {
PageInfoMode.DEFAULT -> !showBars
@ -2631,7 +2717,7 @@ fun EpubReaderHost(
}
val handleGenerateSummary: (Boolean) -> Unit = { force ->
if (!isProUser && credits <= 0) {
if (BuildConfig.FLAVOR != "oss" && !isProUser && credits <= 0) {
showInsufficientCreditsDialog = true
showAiHubSheet = false
} else {
@ -2688,6 +2774,7 @@ fun EpubReaderHost(
val finalSummaryBuilder = StringBuilder()
summarizeBookContent(
content = text,
context = context,
authToken = token,
onUsageReceived = { cost, freeRemaining ->
currentCost = cost
@ -2750,7 +2837,7 @@ fun EpubReaderHost(
}
val handleGenerateRecap: () -> Unit = {
if (credits <= 0) {
if (BuildConfig.FLAVOR != "oss" && credits <= 0) {
showInsufficientCreditsDialog = true
showAiHubSheet = false
} else {
@ -2820,6 +2907,7 @@ fun EpubReaderHost(
modifier = Modifier
.fillMaxSize()
.background(effectiveBg)
.then(activeTextureModifier)
.padding(top = effectiveTopPadding)
.focusRequester(containerFocusRequester)
.focusable()
@ -2879,13 +2967,15 @@ fun EpubReaderHost(
) {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp
val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp
val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp
val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = contentTopPadding)
.padding(bottom = contentBottomPadding)
.padding(top = 16.dp)
.testTag("ReaderContainer")
) {
if (chapters.isEmpty()) {
@ -3292,10 +3382,12 @@ fun EpubReaderHost(
currentParagraphGap = currentParagraphGap,
currentImageSize = currentImageSize,
currentHorizontalMargin = currentHorizontalMargin,
currentVerticalMargin = currentVerticalMargin,
currentFontFamily = currentFontFamily,
customFontPath = currentCustomFontPath,
currentTextAlign = currentTextAlign,
activeTextureId = activeTextureId,
activeTextureAlpha = activeTextureAlpha,
onHighlightClicked = {
lastHighlightClickTime = System.currentTimeMillis()
showBars = false
@ -3462,7 +3554,7 @@ fun EpubReaderHost(
if (ttsChunks.isNotEmpty()) {
logTtsChapterDiag("Vertical TTS extraction produced ${ttsChunks.size} chunks for chapter $targetChapterIndex")
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
ttsShouldStartOnChapterLoad = false
return@launch
@ -3483,6 +3575,7 @@ fun EpubReaderHost(
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
chapterIndex = targetChapterIndex,
totalChapters = chapters.size,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@ -3528,6 +3621,7 @@ fun EpubReaderHost(
summarizeBookContent(
content = content,
context = context,
authToken = token,
onUsageReceived = { cost: Double?, freeRemaining: Int? ->
currentCost = cost
@ -3779,11 +3873,14 @@ fun EpubReaderHost(
}
RenderMode.PAGINATED -> {
val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp
val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp
val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp
val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(top = contentTopPadding)
.padding(bottom = contentBottomPadding)
.testTag("ReaderContainer")
) {
@ -3799,6 +3896,7 @@ fun EpubReaderHost(
paragraphGapMultiplier = currentParagraphGap,
imageSizeMultiplier = currentImageSize,
horizontalMarginMultiplier = currentHorizontalMargin,
verticalMarginMultiplier = currentVerticalMargin,
fontFamily = activeFontFamily,
textAlign = currentTextAlign,
activeHighlightPalette = currentHighlightPalette,
@ -3810,6 +3908,7 @@ fun EpubReaderHost(
offset = ttsState.startOffsetInSource
).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 },
activeTextureId = activeTextureId,
activeTextureAlpha = activeTextureAlpha,
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
onPaginatorReady = { newPaginator ->
@ -4094,17 +4193,19 @@ fun EpubReaderHost(
// Page Info Bar (Vertical)
AnimatedVisibility(
visible = renderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible,
visible = currentRenderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(200)),
modifier = Modifier.align(Alignment.BottomCenter)
modifier = Modifier.align(
if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter
)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(PAGE_INFO_BAR_HEIGHT)
.height(pageInfoBarHeight)
.background(infoBarBgColor)
.padding(bottom = bottomPadding + pageInfoBottomPadding)
.then(activeTextureModifier)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
@ -4140,17 +4241,19 @@ fun EpubReaderHost(
// Page Info Bar (Paginated)
AnimatedVisibility(
visible = renderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0,
visible = currentRenderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(200)),
modifier = Modifier.align(Alignment.BottomCenter)
modifier = Modifier.align(
if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter
)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(PAGE_INFO_BAR_HEIGHT)
.height(pageInfoBarHeight)
.background(infoBarBgColor)
.padding(bottom = bottomPadding + pageInfoBottomPadding)
.then(activeTextureModifier)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
@ -4442,6 +4545,8 @@ fun EpubReaderHost(
volumeScrollEnabled = volumeScrollEnabled,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
hiddenTools = hiddenTools,
toolOrder = toolOrder,
bottomTools = bottomTools,
onCustomizeTools = { showCustomizeToolsSheet = true },
onNavigateBack = { triggerSaveAndExit() },
isKeepScreenOn = isKeepScreenOn,
@ -4522,6 +4627,71 @@ fun EpubReaderHost(
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onOpenVisualOptions = { showVisualOptionsSheet = true },
onOpenAiHub = { showAiHubSheet = true },
onOpenSlider = {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
sliderStartPage = currentPageInChapter
sliderCurrentPage = currentPageInChapter.toFloat()
isPageSliderVisible = true
showBars = false
scope.launch {
webViewRefForTts?.let { webView ->
startPageThumbnail = captureWebViewVisibleArea(webView)
}
}
}
RenderMode.PAGINATED -> {
if (paginatedPagerState.pageCount > 0) {
sliderStartPage = paginatedPagerState.currentPage + 1
sliderCurrentPage = (paginatedPagerState.currentPage + 1).toFloat()
isPageSliderVisible = true
showBars = false
startPageThumbnail = null
} else {
bannerMessage = BannerMessage("Book is not paginated yet.")
}
}
}
},
onOpenDrawer = {
scope.launch { drawerState.open() }
},
onToggleFormat = {
showFormatAdjustmentBars = !showFormatAdjustmentBars
if (showFormatAdjustmentBars) {
searchState.showSearchResultsPanel = false
isPageSliderVisible = false
}
},
onToggleSearch = {
searchState.isSearchActive = true
searchState.showSearchResultsPanel = true
showBars = true
showFormatAdjustmentBars = false
},
onToggleTts = {
if (isTtsSessionActive) {
Timber.d("TTS button clicked: Stopping TTS")
userStoppedTts = true
ttsController.stop()
} else {
when {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> {
startTts()
}
activity?.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) == true -> {
showPermissionRationaleDialog = true
}
else -> {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
},
onToggleReflow = if (onToggleReflow != null) {
{
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
@ -4687,8 +4857,12 @@ fun EpubReaderHost(
ttsState = ttsState,
isProUser = isProUser,
hiddenTools = hiddenTools,
toolOrder = toolOrder,
bottomTools = bottomTools,
currentTtsMode = currentTtsMode,
onOpenAiHub = { showAiHubSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onOpenSlider = {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
@ -4770,6 +4944,8 @@ fun EpubReaderHost(
onImageSizeChange = { currentImageSize = it },
currentHorizontalMargin = currentHorizontalMargin,
onHorizontalMarginChange = { currentHorizontalMargin = it },
currentVerticalMargin = currentVerticalMargin,
onVerticalMarginChange = { currentVerticalMargin = it },
currentFont = currentFontFamily,
currentCustomFontName = if(currentCustomFontPath != null) {
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font"
@ -4788,6 +4964,7 @@ fun EpubReaderHost(
currentParagraphGap = DEFAULT_PARAGRAPH_GAP_VAL
currentImageSize = DEFAULT_IMAGE_SIZE_VAL
currentHorizontalMargin = DEFAULT_HORIZONTAL_MARGIN_VAL
currentVerticalMargin = DEFAULT_VERTICAL_MARGIN_VAL
currentFontFamily = ReaderFont.ORIGINAL
currentCustomFontPath = null
currentTextAlign = ReaderTextAlign.DEFAULT
@ -5085,10 +5262,20 @@ fun EpubReaderHost(
if (showCustomizeToolsSheet) {
CustomizeToolsSheet(
hiddenTools = hiddenTools,
toolOrder = toolOrder,
bottomTools = bottomTools,
onUpdate = { newHiddenSet ->
hiddenTools = newHiddenSet
saveHiddenTools(context, newHiddenSet)
},
onOrderUpdate = { newOrder ->
toolOrder = newOrder
saveToolOrder(context, newOrder)
},
onPlacementUpdate = { newBottomTools ->
bottomTools = newBottomTools
saveBottomTools(context, newBottomTools)
},
onDismiss = { showCustomizeToolsSheet = false }
)
}
@ -5133,6 +5320,11 @@ fun EpubReaderHost(
pageInfoMode = it
savePageInfoMode(context, it)
},
pageInfoPosition = pageInfoPosition,
onPageInfoPositionChange = {
pageInfoPosition = it
savePageInfoPosition(context, it)
},
pullToTurnEnabled = pullToTurnEnabled,
onPullToTurnChange = {
pullToTurnEnabled = it
@ -5173,6 +5365,11 @@ fun EpubReaderHost(
ReaderThemePanel(
isVisible = true,
currentThemeId = currentThemeId,
globalTextureTransparency = globalTextureTransparency,
onGlobalTextureTransparencyChange = {
globalTextureTransparency = it
saveGlobalTextureTransparency(context, it)
},
onThemeSelected = {
currentThemeId = it
saveReaderThemeId(context, it)

View file

@ -120,6 +120,7 @@ private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled"
private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode"
private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
private const val PAGE_INFO_POSITION_KEY = "reader_page_info_position"
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
const val DEFAULT_FONT_SIZE_VAL = 1.0f
@ -127,6 +128,7 @@ const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f
const val DEFAULT_IMAGE_SIZE_VAL = 1.0f
const val DEFAULT_HORIZONTAL_MARGIN_VAL = 1.0f
const val DEFAULT_VERTICAL_MARGIN_VAL = 1.0f
private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate"
private const val TTS_PITCH_KEY = "tts_pitch"
@ -177,12 +179,18 @@ enum class PageInfoMode(val id: Int, val title: String) {
HIDDEN(2, "Always Hide")
}
enum class PageInfoPosition(val id: Int, val title: String) {
BOTTOM(0, "Bottom"),
TOP(1, "Top")
}
data class FormatSettings(
val fontSize: Float,
val lineHeight: Float,
val paragraphGap: Float,
val imageSize: Float,
val horizontalMargin: Float,
val verticalMargin: Float,
val font: ReaderFont,
val customPath: String?,
val textAlign: ReaderTextAlign
@ -194,9 +202,11 @@ private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_"
private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_"
private const val LOCAL_IMAGE_SIZE_PREFIX = "local_image_size_"
private const val LOCAL_HORIZONTAL_MARGIN_PREFIX = "local_horizontal_margin_"
private const val LOCAL_VERTICAL_MARGIN_PREFIX = "local_vertical_margin_"
private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_"
private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_"
private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin"
private const val VERTICAL_MARGIN_KEY = "reader_vertical_margin"
fun loadFormatIsLocal(context: Context, bookId: String): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -216,6 +226,7 @@ fun saveLocalReaderSettings(
paragraphGap: Float,
imageSize: Float,
horizontalMargin: Float,
verticalMargin: Float,
fontFamily: ReaderFont,
customFontPath: String?,
textAlign: ReaderTextAlign
@ -227,6 +238,7 @@ fun saveLocalReaderSettings(
putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap)
putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize)
putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin)
putFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, verticalMargin)
if (customFontPath != null) {
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
} else {
@ -258,6 +270,17 @@ fun loadPageInfoMode(context: Context): PageInfoMode {
return PageInfoMode.entries.find { it.id == id } ?: PageInfoMode.DEFAULT
}
fun savePageInfoPosition(context: Context, position: PageInfoPosition) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putInt(PAGE_INFO_POSITION_KEY, position.id) }
}
fun loadPageInfoPosition(context: Context): PageInfoPosition {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val id = prefs.getInt(PAGE_INFO_POSITION_KEY, PageInfoPosition.BOTTOM.id)
return PageInfoPosition.entries.find { it.id == id } ?: PageInfoPosition.BOTTOM
}
fun savePullToTurn(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PULL_TO_TURN_ENABLED_KEY, enabled) }
@ -288,6 +311,11 @@ fun loadHorizontalMargin(context: Context): Float {
return if (loadRemoveEdgePadding(context)) 0f else DEFAULT_HORIZONTAL_MARGIN_VAL
}
fun loadVerticalMargin(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(VERTICAL_MARGIN_KEY, DEFAULT_VERTICAL_MARGIN_VAL)
}
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -321,6 +349,12 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
loadHorizontalMargin(context)
}
val verticalMargin = if (isLocal && prefs.contains(LOCAL_VERTICAL_MARGIN_PREFIX + bookId)) {
prefs.getFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, DEFAULT_VERTICAL_MARGIN_VAL)
} else {
loadVerticalMargin(context)
}
val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) {
prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
} else {
@ -346,6 +380,7 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
paragraphGap = paragraphGap,
imageSize = imageSize,
horizontalMargin = horizontalMargin,
verticalMargin = verticalMargin,
font = font,
customPath = customPath,
textAlign = textAlign
@ -390,6 +425,7 @@ fun saveReaderSettings(
paragraphGap: Float,
imageSize: Float,
horizontalMargin: Float,
verticalMargin: Float,
fontFamily: ReaderFont,
customFontPath: String?,
textAlign: ReaderTextAlign
@ -401,6 +437,7 @@ fun saveReaderSettings(
putFloat(PARAGRAPH_GAP_KEY, paragraphGap)
putFloat(IMAGE_SIZE_KEY, imageSize)
putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin)
putFloat(VERTICAL_MARGIN_KEY, verticalMargin)
if (customFontPath != null) {
putString(FONT_FAMILY_KEY, "custom|$customFontPath")
} else {
@ -454,6 +491,8 @@ fun ReaderTextFormatPanel(
onImageSizeChange: (Float) -> Unit,
currentHorizontalMargin: Float,
onHorizontalMarginChange: (Float) -> Unit,
currentVerticalMargin: Float,
onVerticalMarginChange: (Float) -> Unit,
currentFont: ReaderFont,
currentCustomFontName: String?,
onFontOptionClick: () -> Unit,
@ -699,6 +738,20 @@ fun ReaderTextFormatPanel(
}
}
)
FormatSlider(
label = stringResource(R.string.label_vertical_margin),
value = currentVerticalMargin,
onValueChange = onVerticalMarginChange,
valueRange = 0.0f..3.0f,
formatValue = {
when {
it <= 0.01f -> noneLabel
it in 0.99f..1.01f -> originalLabel
else -> "%.1fx".format(it)
}
}
)
}
}
}
@ -830,6 +883,8 @@ fun VisualOptionsSheet(
onSystemUiModeChange: (SystemUiMode) -> Unit,
pageInfoMode: PageInfoMode,
onPageInfoModeChange: (PageInfoMode) -> Unit,
pageInfoPosition: PageInfoPosition,
onPageInfoPositionChange: (PageInfoPosition) -> Unit,
pullToTurnEnabled: Boolean,
onPullToTurnChange: (Boolean) -> Unit,
pullToTurnMultiplier: Float,
@ -884,6 +939,16 @@ fun VisualOptionsSheet(
getLabel = { it.title }
)
Spacer(modifier = Modifier.height(16.dp))
Text(stringResource(R.string.visual_options_progress_bar_position), style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
OptionSegmentedControl(
options = PageInfoPosition.entries,
selectedOption = pageInfoPosition,
onOptionSelected = onPageInfoPositionChange,
getLabel = { it.title }
)
Spacer(modifier = Modifier.height(24.dp))
// Pull to change chapter

View file

@ -20,6 +20,7 @@
package com.aryan.reader.epubreader
import timber.log.Timber
import android.graphics.Color
import android.view.KeyEvent
import android.view.View
import android.view.Window
@ -51,14 +52,20 @@ fun EpubReaderSystemUiController(
return@DisposableEffect onDispose {}
}
val insetsController = WindowCompat.getInsetsController(window, view)
val originalStatusBarColor = window.statusBarColor
val originalNavigationBarColor = window.navigationBarColor
Timber.d("Applying immersive mode.")
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
onDispose {
Timber.d("Restoring system UI.")
WindowCompat.setDecorFitsSystemWindows(window, true)
window.statusBarColor = originalStatusBarColor
window.navigationBarColor = originalNavigationBarColor
insetsController.show(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.statusBars())
insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars
insetsController.systemBarsBehavior = initialSystemBarsBehavior
@ -142,4 +149,4 @@ fun Modifier.volumeScrollHandler(
}
}
true
}
}

View file

@ -328,6 +328,8 @@ private fun handleVerticalAutoAdvance(
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
chapterIndex = currentTtsChapterIndex,
totalChapters = chapters.size,
continueSession = true,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@ -358,6 +360,8 @@ private fun handleVerticalAutoAdvance(
chapterTitle = chapters.getOrNull(nextIdx)?.title,
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
chapterIndex = nextIdx,
totalChapters = chapters.size,
continueSession = true,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@ -433,6 +437,8 @@ private fun handlePaginatedAutoAdvance(
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
chapterIndex = chapterToTry,
totalChapters = chapters.size,
continueSession = true,
ttsMode = ttsMode,
playbackSource = "READER",
authToken = token

View file

@ -0,0 +1,172 @@
package com.aryan.reader.feedback
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.aryan.reader.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SupportProjectScreen(
navController: NavHostController
) {
val uriHandler = LocalUriHandler.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.support_project_title)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(32.dp))
Icon(
imageVector = Icons.Outlined.FavoriteBorder,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.support_project_heading),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.support_project_desc),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(48.dp))
SupportOptionCard(
title = stringResource(R.string.support_github_sponsor),
description = stringResource(R.string.support_github_sponsor_desc),
icon = {
Icon(
painter = painterResource(id = R.drawable.github),
contentDescription = stringResource(R.string.support_github_sponsor),
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary
)
},
onClick = {
uriHandler.openUri("https://github.com/sponsors/Aryan-Raj3112")
}
)
Spacer(modifier = Modifier.height(16.dp))
SupportOptionCard(
title = stringResource(R.string.support_patreon),
description = stringResource(R.string.support_patreon_desc),
icon = {
Icon(
imageVector = Icons.Outlined.FavoriteBorder,
contentDescription = stringResource(R.string.support_patreon),
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary
)
},
onClick = {
uriHandler.openUri("https://www.patreon.com/c/epistemereader")
}
)
Spacer(modifier = Modifier.weight(1f))
}
}
}
@Composable
private fun SupportOptionCard(
title: String,
description: String,
icon: @Composable () -> Unit,
onClick: () -> Unit
) {
OutlinedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically
) {
icon()
Spacer(modifier = Modifier.width(20.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = stringResource(R.string.action_open),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View file

@ -38,6 +38,8 @@ data class OpdsAcquisition(
get() = when {
mimeType.contains("epub") -> "EPUB"
mimeType.contains("pdf") -> "PDF"
mimeType.contains("markdown") || mimeType.contains("text/x-markdown") -> "MD"
mimeType.contains("html") || mimeType.contains("xhtml") -> "HTML"
mimeType.contains("mobi") || mimeType.contains("x-mobipocket-ebook") -> "MOBI"
mimeType.contains("fictionbook") || mimeType.contains("fb2") -> "FB2"
mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ"
@ -52,6 +54,7 @@ data class OpdsAcquisition(
"PDF" -> 4
"MOBI" -> 3
"FB2" -> 2
"MD", "HTML" -> 2
"CBZ" -> 1
"TXT" -> 0
else -> -1
@ -90,4 +93,4 @@ data class OpdsEntry(
val isStreamable: Boolean
get() = pseUrlTemplate != null && pseCount != null && pseCount > 0
}
}

View file

@ -5,6 +5,7 @@ import android.content.Context
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.aryan.reader.resolveFileExtensionSuffixFromName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Response
import okhttp3.Request
import timber.log.Timber
import java.io.File
@ -93,16 +95,7 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
val body = response.body ?: throw Exception("Empty body")
val contentLength = body.contentLength()
val ext = when (acquisition.formatName) {
"EPUB" -> ".epub"
"PDF" -> ".pdf"
"MOBI" -> ".mobi"
"FB2" -> ".fb2"
"CBZ" -> ".cbz"
"CBR" -> ".cbr"
"TXT" -> ".txt"
else -> ".epub"
}
val ext = resolveOpdsDownloadExtension(acquisition, response)
val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50)
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
@ -148,6 +141,45 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
}
}
private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
val candidates = listOfNotNull(
response.header("Content-Disposition")?.let(::extractContentDispositionFilename),
Uri.parse(acquisition.url).lastPathSegment
)
candidates.forEach { candidate ->
resolveFileExtensionSuffixFromName(Uri.decode(candidate))?.let { return it }
}
return when (acquisition.formatName) {
"EPUB" -> ".epub"
"PDF" -> ".pdf"
"MOBI" -> ".mobi"
"FB2" -> ".fb2"
"CBZ" -> ".cbz"
"CBR" -> ".cbr"
"MD" -> ".md"
"HTML" -> ".html"
"TXT" -> ".txt"
else -> ".epub"
}
}
private fun extractContentDispositionFilename(contentDisposition: String): String? {
val encodedFilename = Regex("filename\\*=UTF-8''([^;]+)", RegexOption.IGNORE_CASE)
.find(contentDisposition)
?.groupValues
?.getOrNull(1)
if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"')
return Regex("filename=\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
.find(contentDisposition)
?.groupValues
?.getOrNull(1)
?.trim()
?.trim('"')
}
init {
loadCatalogs()
}
@ -221,4 +253,4 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
}
}

View file

@ -0,0 +1,81 @@
package com.aryan.reader.paginatedreader
import android.graphics.BitmapFactory
import androidx.compose.ui.text.font.FontFamily
import java.io.File
import java.net.URLDecoder
import java.nio.file.Paths
object AndroidHtmlResourceResolver : HtmlResourceResolver {
override fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? {
if (src.isBlank()) return null
val decodedSrc = try {
URLDecoder.decode(src, "UTF-8")
} catch (_: Exception) {
src
}
val parentPath = File(chapterAbsPath).parent ?: ""
val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString()
val fromRelativeFile = File(extractionBasePath, relativePath)
return try {
when {
fromRelativeFile.exists() -> fromRelativeFile.canonicalFile.absolutePath
File(extractionBasePath, decodedSrc).exists() -> File(extractionBasePath, decodedSrc).canonicalFile.absolutePath
else -> null
}
} catch (_: Exception) {
null
}
}
override fun readText(path: String): String? {
return runCatching { File(path).readText() }.getOrNull()
}
override fun imageDimensions(path: String): Pair<Float?, Float?>? {
return runCatching {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, options)
if (options.outWidth > 0 && options.outHeight > 0) {
options.outWidth.toFloat() to options.outHeight.toFloat()
} else {
null
}
}.getOrNull()
}
}
object AndroidHtmlFontFamilyLoader : HtmlFontFamilyLoader {
override fun load(fontFaces: List<FontFaceInfo>, extractionBasePath: String): Map<String, FontFamily> {
return loadFontFamilies(fontFaces, extractionBasePath)
}
}
fun androidHtmlToSemanticBlocks(
html: String,
cssRules: OptimizedCssRules,
textStyle: androidx.compose.ui.text.TextStyle,
chapterAbsPath: String,
extractionBasePath: String,
density: androidx.compose.ui.unit.Density,
fontFamilyMap: Map<String, FontFamily>,
constraints: androidx.compose.ui.unit.Constraints,
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
mathSvgCache: Map<String, String> = emptyMap()
): List<SemanticBlock> {
return htmlToSemanticBlocks(
html = html,
cssRules = cssRules,
textStyle = textStyle,
chapterAbsPath = chapterAbsPath,
extractionBasePath = extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
constraints = constraints,
imageDimensionsCache = imageDimensionsCache,
mathSvgCache = mathSvgCache,
resourceResolver = AndroidHtmlResourceResolver,
fontFamilyLoader = AndroidHtmlFontFamilyLoader
)
}

View file

@ -120,7 +120,8 @@ class BookPaginator(
private val mathMLRenderer: MathMLRenderer,
private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float,
private val imageSizeMultiplier: Float
private val imageSizeMultiplier: Float,
private val verticalMarginMultiplier: Float
) : IPaginator {
override var totalPageCount by mutableIntStateOf(0)
private set
@ -185,6 +186,14 @@ class BookPaginator(
isLoading = true
Timber.d("Initialization started.")
if (chapters.isEmpty()) {
totalPageCount = 0
pageCountsAreAccurate = true
isLoading = false
Timber.w("Paginator initialized with no chapters. Skipping pagination startup.")
return@launch
}
// 1. Book processing check (Keep existing logic)
val bookRecord = bookCacheDao.getProcessedBook(bookId)
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
@ -214,7 +223,7 @@ class BookPaginator(
// 5. Prioritize CURRENT chapter only
// We no longer blindly queue neighbors immediately to keep startup fast.
// We only queue the requested chapter.
val startChapter = initialChapterToPaginate.coerceIn(0, chapters.size - 1)
val startChapter = initialChapterToPaginate.coerceIn(0, chapters.lastIndex)
// Trigger actual pagination for the current chapter to replace the estimate with reality
triggerPagination(startChapter, PRIORITY_HIGHEST)
@ -277,6 +286,7 @@ class BookPaginator(
append("-ta:$userTextAlign")
append("-pg:$paragraphGapMultiplier")
append("-img:$imageSizeMultiplier")
append("-vm:$verticalMarginMultiplier")
}
val hash = configString.hashCode()
return hash
@ -503,7 +513,7 @@ class BookPaginator(
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
}
val semanticBlocks = htmlToSemanticBlocks(
val semanticBlocks = androidHtmlToSemanticBlocks(
html = processedHtml,
cssRules = parsingCssRules,
textStyle = textStyle.copy(color = Color.Black),
@ -792,6 +802,10 @@ class BookPaginator(
}
private fun triggerPagination(chapterIndex: Int, priority: Int) {
if (chapterIndex !in chapters.indices) {
Timber.w("Trigger: Ignoring invalid chapter index $chapterIndex. Chapter count: ${chapters.size}.")
return
}
if (pageCache[chapterIndex] != null) {
Timber.v("Trigger: Chapter $chapterIndex is already in cache. Ignoring.")
return

View file

@ -163,10 +163,12 @@ class ContentStyler(
}
is SemanticMath -> {
val svgContent = block.svgContent
val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
val finalSvgContent = when {
block.isFromMathJax || block.svgContent.isNullOrBlank() -> block.svgContent
block.isFromMathJax || nonBlankSvgContent == null -> svgContent
else -> {
val themedSvg = applyThemeToSvg(block.svgContent)
val themedSvg = applyThemeToSvg(nonBlankSvgContent)
embedImagesInSvg(themedSvg)
}
}
@ -452,11 +454,11 @@ class ContentStyler(
}
}
if (span.linkHref != null) {
addStringAnnotation("URL", span.linkHref, span.start, span.end)
span.linkHref?.let { linkHref ->
addStringAnnotation("URL", linkHref, span.start, span.end)
}
if (span.elementId != null) {
addStringAnnotation("ID", span.elementId, span.start, span.end)
span.elementId?.let { elementId ->
addStringAnnotation("ID", elementId, span.start, span.end)
}
}
}
@ -589,4 +591,4 @@ class ContentStyler(
else -> if (isOrdered) "$counter. " else ""
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -27,47 +27,6 @@ import androidx.compose.ui.text.font.FontWeight
import java.io.File
import java.security.MessageDigest
/**
* A centralized mapper for handling conversions between generic CSS font family names
* and Compose's FontFamily objects.
*/
object FontFamilyMapper {
private val genericFontMap = mapOf(
"serif" to FontFamily.Serif,
"sans-serif" to FontFamily.SansSerif,
"monospace" to FontFamily.Monospace,
"cursive" to FontFamily.Cursive,
"default" to FontFamily.Default,
"system-ui" to FontFamily.Default,
"ui-sans-serif" to FontFamily.Default,
"ui-serif" to FontFamily.Default,
"ui-monospace" to FontFamily.Default,
"ui-rounded" to FontFamily.Default
)
/**
* Converts a string name (e.g., "serif") to a Compose [FontFamily].
*/
fun nameToFontFamily(name: String): FontFamily? {
return genericFontMap[name.trim().lowercase()]
}
/**
* Converts a Compose [FontFamily] back to its primary string name for serialization.
* Custom fonts are not serialized by name and will return null.
*/
fun fontFamilyToName(fontFamily: FontFamily): String? {
return when (fontFamily) {
FontFamily.Serif -> "serif"
FontFamily.SansSerif -> "sans-serif"
FontFamily.Monospace -> "monospace"
FontFamily.Cursive -> "cursive"
FontFamily.Default -> "default"
else -> null
}
}
}
private fun getCacheKeyForFont(bookId: String, fontPath: String): String {
val identifier = "$bookId:$fontPath"
val digest = MessageDigest.getInstance("MD5").digest(identifier.toByteArray())
@ -158,4 +117,4 @@ fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map
null
}
}.filterValues { it != null }.mapValues { it.value!! }
}
}

View file

@ -1,670 +0,0 @@
/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
package com.aryan.reader.paginatedreader
import android.graphics.BitmapFactory
import android.os.Build
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import org.jsoup.select.Selector
import java.io.File
import java.net.URLDecoder
import java.nio.file.Paths
private val unsupportedPseudoElementRegex = Regex("::?(before|after|first-letter|first-line|marker|selection)", RegexOption.IGNORE_CASE)
private fun Element.getCfiPath(): String {
val path = mutableListOf<Int>()
var currentNode: Node? = this
while (currentNode != null && (currentNode !is Element || currentNode.tagName() != "body")) {
val parent = currentNode.parent() ?: break
val children = parent.childNodes().filter { node ->
node is Element || (node is TextNode && node.text().trim().isNotEmpty())
}
val nodeIndex = children.indexOf(currentNode)
if (nodeIndex == -1) {
currentNode = parent
continue
}
val cfiIndex = (nodeIndex * 2) + 2
path.add(0, cfiIndex)
currentNode = parent
}
path.add(0, 4)
return "/" + path.joinToString("/")
}
private fun String.capitalizeWords(): String =
split(' ').joinToString(" ") { word ->
if (word.isNotEmpty()) word.replaceFirstChar { it.titlecase() } else ""
}
/**
* The public entry point for converting HTML to a list of [SemanticBlock]s.
* This function sets up a parsing context and delegates the work to a [SemanticHtmlParser] instance.
*/
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
fun htmlToSemanticBlocks(
html: String,
cssRules: OptimizedCssRules,
textStyle: TextStyle,
chapterAbsPath: String,
extractionBasePath: String,
density: Density,
fontFamilyMap: Map<String, FontFamily>,
constraints: Constraints,
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
mathSvgCache: Map<String, String> = emptyMap()
): List<SemanticBlock> {
return SemanticHtmlParser(
cssRules,
textStyle,
chapterAbsPath,
extractionBasePath,
density,
fontFamilyMap,
constraints,
imageDimensionsCache,
mathSvgCache
).parse(html)
}
/**
* A stateful parser that holds the context for a single HTML-to-SemanticBlock conversion.
*/
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private class SemanticHtmlParser(
cssRules: OptimizedCssRules,
private val textStyle: TextStyle,
private val chapterAbsPath: String,
private val extractionBasePath: String,
private val density: Density,
fontFamilyMap: Map<String, FontFamily>,
private val constraints: Constraints,
private val imageDimensionsCache: Map<String, Pair<Float, Float>>,
private val mathSvgCache: Map<String, String>
) {
private val styleCache = mutableMapOf<String, CssStyle>()
private var combinedRules: OptimizedCssRules = cssRules
private val currentFontFamilyMap: MutableMap<String, FontFamily> = fontFamilyMap.toMutableMap()
private var nextBlockIndex = 0
fun parse(html: String): List<SemanticBlock> {
val document = Jsoup.parse(html, chapterAbsPath)
val inlineCssContent = document.head().select("style").joinToString(separator = "\n") { it.data() }
if (inlineCssContent.isNotBlank()) {
Timber.d("Found inline <style> content in $chapterAbsPath. Parsing...")
val inlineParseResult = CssParser.parse(
cssContent = inlineCssContent,
cssPath = chapterAbsPath,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false
)
if (inlineParseResult.fontFaces.isNotEmpty()) {
val newFonts = loadFontFamilies(inlineParseResult.fontFaces, extractionBasePath)
if (newFonts.isNotEmpty()) {
currentFontFamilyMap.putAll(newFonts)
}
}
combinedRules = combinedRules.merge(inlineParseResult.rules)
}
val body = document.body()
return parseContainer(body, getElementStyle(body))
}
private fun parseNodeToSemanticBlocks(
element: Element,
inheritedStyle: CssStyle
): List<SemanticBlock> {
val elementOwnStyle = getElementStyle(element)
val finalBlockStyle = elementOwnStyle.blockStyle.copy(
listStyleType = elementOwnStyle.blockStyle.listStyleType ?: inheritedStyle.blockStyle.listStyleType,
listStyleImage = elementOwnStyle.blockStyle.listStyleImage ?: inheritedStyle.blockStyle.listStyleImage
)
val finalStyle = elementOwnStyle.copy(
spanStyle = inheritedStyle.spanStyle.merge(elementOwnStyle.spanStyle),
paragraphStyle = inheritedStyle.paragraphStyle.merge(elementOwnStyle.paragraphStyle),
blockStyle = finalBlockStyle,
fontFamilies = elementOwnStyle.fontFamilies.ifEmpty { inheritedStyle.fontFamilies },
fontSize = if (elementOwnStyle.fontSize.isSpecified) elementOwnStyle.fontSize else inheritedStyle.fontSize,
textTransform = elementOwnStyle.textTransform ?: inheritedStyle.textTransform,
hyphens = elementOwnStyle.hyphens ?: inheritedStyle.hyphens,
fontVariantNumeric = elementOwnStyle.fontVariantNumeric ?: inheritedStyle.fontVariantNumeric,
textEmphasis = elementOwnStyle.textEmphasis ?: inheritedStyle.textEmphasis
)
if (finalStyle.display == "none") return emptyList()
return elementToSemanticBlocks(element, finalStyle)
}
private fun getElementDescriptor(element: Element): String {
return buildString {
append(element.tagName())
val id = element.id()
if (id.isNotEmpty()) append('#').append(id)
val classes = element.classNames()
if (classes.isNotEmpty()) append('.').append(classes.sorted().joinToString("."))
}
}
private fun getElementStyle(element: Element): CssStyle {
val cacheKey = getElementDescriptor(element)
val baseStyle = styleCache.getOrPut(cacheKey) {
val potentialRules = mutableListOf<CssRule>()
combinedRules.byTag[element.tagName()]?.let { potentialRules.addAll(it) }
element.id().takeIf { it.isNotEmpty() }?.let { id ->
combinedRules.byId[id]?.let { potentialRules.addAll(it) }
}
element.classNames().forEach { className ->
combinedRules.byClass[className]?.let { potentialRules.addAll(it) }
}
potentialRules.addAll(combinedRules.otherComplex)
val matchingRules = potentialRules.filter { rule ->
if (unsupportedPseudoElementRegex.containsMatchIn(rule.selector.selector)) return@filter false
try {
element.`is`(rule.selector.selector)
} catch (e: Selector.SelectorParseException) {
Timber.w(e, "Jsoup failed to parse selector '${rule.selector.selector}'.")
false
}
}
matchingRules.sortedBy { it.selector.specificity }.fold(CssStyle()) { acc, rule ->
acc.merge(rule.style)
}
}
var elementStyle = baseStyle
val inlineStyleAttribute = element.attr("style")
if (inlineStyleAttribute.isNotBlank()) {
val inlineStyle = CssParser.parseProperties(inlineStyleAttribute, textStyle.fontSize.value, density.density, constraints, onlyImportant = false, isDarkTheme = false)
elementStyle = elementStyle.merge(inlineStyle)
}
element.attr("align").takeIf { it.isNotBlank() }?.let { align ->
val textAlign = when (align.lowercase()) {
"center" -> TextAlign.Center; "right" -> TextAlign.End
"justify" -> TextAlign.Justify; "left" -> TextAlign.Start
else -> null
}
if (textAlign != null) {
elementStyle = elementStyle.merge(CssStyle(paragraphStyle = ParagraphStyle(textAlign = textAlign)))
}
}
return elementStyle
}
private fun elementToSemanticBlocks(
element: Element,
elementStyle: CssStyle
): List<SemanticBlock> {
val elementId = element.id().ifBlank { null }
val cfi = element.getCfiPath()
if (element.tagName().equals("br", ignoreCase = true)) {
return listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, isExplicitLineBreak = true, blockIndex = nextBlockIndex++))
}
if (elementStyle.blockStyle.display == "flex") {
val children = element.children().flatMap { child ->
parseNodeToSemanticBlocks(child, elementStyle)
}
return listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
}
val result = when (val tagName = element.tagName().lowercase()) {
"div", "header", "section", "article", "aside", "main", "footer", "nav", "figure" -> {
val hasBoxStyles = elementStyle.blockStyle.backgroundColor.isSpecified ||
elementStyle.blockStyle.borderTop != null ||
elementStyle.blockStyle.borderRight != null ||
elementStyle.blockStyle.borderBottom != null ||
elementStyle.blockStyle.borderLeft != null ||
elementStyle.blockStyle.padding != BoxBorders() ||
elementStyle.blockStyle.borderTopLeftRadius > 0.dp ||
elementStyle.blockStyle.borderTopRightRadius > 0.dp ||
elementStyle.blockStyle.borderBottomRightRadius > 0.dp ||
elementStyle.blockStyle.borderBottomLeftRadius > 0.dp
if (hasBoxStyles) {
val childStyle = elementStyle.copy(
blockStyle = elementStyle.blockStyle.copy(
backgroundColor = Color.Unspecified,
borderTop = null, borderRight = null, borderBottom = null, borderLeft = null,
padding = BoxBorders(),
margin = BoxBorders()
)
)
val children = parseContainer(element, childStyle)
listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else {
parseContainer(element, elementStyle)
}
}
"svg" -> parseSvgElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"table" -> parseTableElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"math-placeholder" -> parseMathPlaceholderToSemantic(element, elementStyle)
"img" -> parseImageElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"h1", "h2", "h3", "h4", "h5", "h6" -> {
val hasNonTextChildren = element.select("img, svg, math-placeholder, table, hr, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty()
if (hasNonTextChildren) {
val level = tagName.substring(1).toIntOrNull() ?: 1
val fontSizeMultiplier = when (level) {
1 -> 1.5f; 2 -> 1.4f; 3 -> 1.3f; 4 -> 1.2f; 5 -> 1.1f; else -> 1.0f
}
val headerStyle = elementStyle.copy(
spanStyle = elementStyle.spanStyle.copy(
fontWeight = FontWeight.Bold,
fontSize = (textStyle.fontSize.value * fontSizeMultiplier).sp
)
)
val hasBoxStyles = headerStyle.blockStyle.backgroundColor.isSpecified ||
headerStyle.blockStyle.borderTop != null ||
headerStyle.blockStyle.borderRight != null ||
headerStyle.blockStyle.borderBottom != null ||
headerStyle.blockStyle.borderLeft != null ||
headerStyle.blockStyle.padding != BoxBorders() ||
headerStyle.blockStyle.borderTopLeftRadius > 0.dp ||
headerStyle.blockStyle.borderTopRightRadius > 0.dp ||
headerStyle.blockStyle.borderBottomRightRadius > 0.dp ||
headerStyle.blockStyle.borderBottomLeftRadius > 0.dp
if (hasBoxStyles) {
val childStyle = headerStyle.copy(
blockStyle = headerStyle.blockStyle.copy(
backgroundColor = Color.Unspecified,
borderTop = null, borderRight = null, borderBottom = null, borderLeft = null,
padding = BoxBorders(),
margin = BoxBorders()
)
)
val children = parseContainer(element, childStyle)
listOf(SemanticFlexContainer(children, headerStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else {
parseContainer(element, headerStyle)
}
} else {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
if (text.isNotBlank()) {
val level = tagName.substring(1).toIntOrNull() ?: 1
listOf(SemanticHeader(level, text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else emptyList()
}
}
"hr" -> listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, blockIndex = nextBlockIndex++))
"ul", "ol" -> parseListElementToSemantic(element, elementStyle)
else -> {
val hasBlockDescendant = !element.isBlock && element.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty()
if (element.isBlock || hasBlockDescendant) {
parseContainer(element, elementStyle)
} else {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
if (text.isNotBlank()) {
listOf(SemanticParagraph(text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else emptyList()
}
}
}
return if (elementId != null && result.isNotEmpty()) {
val first = result.first()
if (first.elementId == null) {
listOf(first.withElementId(elementId)) + result.drop(1)
} else result
} else result
}
private fun parseContainer(element: Element, style: CssStyle): List<SemanticBlock> {
val children = mutableListOf<SemanticBlock>()
val textNodesBuffer = mutableListOf<Node>()
fun flushTextBuffer() {
if (textNodesBuffer.isEmpty()) return
val (text, spans) = buildSemanticTextAndSpansFromNodes(textNodesBuffer, style)
if (text.isNotBlank()) {
val finalSpans = spans.toMutableList()
if (element.tagName().lowercase() == "a") {
val href = element.attr("href").ifBlank { null }
if (href != null) {
finalSpans.add(SemanticSpan(
start = 0,
end = text.length,
style = style,
linkHref = href,
tag = "a",
elementId = element.id().ifBlank { null }
))
}
}
children.add(SemanticParagraph(text, finalSpans, style, element.id().ifBlank { null }, element.getCfiPath(), blockIndex = nextBlockIndex++))
}
textNodesBuffer.clear()
}
element.childNodes().forEach { node ->
if (node is Element) {
val tagName = node.tagName().lowercase()
val isEffectivelyBlock = node.isBlock || tagName in listOf("img", "svg", "math-placeholder", "hr") ||
(!node.isBlock && node.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty())
if (isEffectivelyBlock) {
flushTextBuffer()
children.addAll(parseNodeToSemanticBlocks(node, style))
} else {
textNodesBuffer.add(node)
}
} else {
textNodesBuffer.add(node)
}
}
flushTextBuffer()
return children
}
private fun buildSemanticTextAndSpans(
rootElement: Element,
rootStyle: CssStyle
): Pair<String, List<SemanticSpan>> {
return buildSemanticTextAndSpansFromNodes(rootElement.childNodes(), rootStyle)
}
private fun buildSemanticTextAndSpansFromNodes(
nodes: List<Node>,
rootStyle: CssStyle
): Pair<String, List<SemanticSpan>> {
val textBuilder = StringBuilder()
val spans = mutableListOf<SemanticSpan>()
fun processNode(node: Node, inheritedStyle: CssStyle) {
when (node) {
is TextNode -> {
var text = node.wholeText.replace('\n', ' ')
when (inheritedStyle.textTransform) {
"uppercase" -> text = text.uppercase()
"lowercase" -> text = text.lowercase()
"capitalize" -> text = text.capitalizeWords()
}
textBuilder.append(text)
}
is Element -> {
if (node.tagName().lowercase() == "br") {
textBuilder.append('\n'); return
}
val currentElementStyle = getElementStyle(node)
val newStyle = inheritedStyle.merge(currentElementStyle)
val startIndex = textBuilder.length
node.childNodes().forEach { processNode(it, newStyle) }
val endIndex = textBuilder.length
val elementId = node.id().ifBlank { null }
val isAnchor = node.tagName().lowercase() == "a" || elementId != null
// Capture span if it has content OR if it has an ID (anchor)
if (startIndex < endIndex || elementId != null) {
val href = if (node.tagName().lowercase() == "a") node.attr("href").ifBlank { null } else null
spans.add(SemanticSpan(
start = startIndex,
end = endIndex,
style = newStyle,
linkHref = href,
tag = node.tagName().lowercase(),
elementId = elementId // Pass the ID here
))
}
}
}
}
nodes.forEach { processNode(it, rootStyle) }
var processedText = textBuilder.toString()
if (processedText.isNotEmpty() && processedText.last().isWhitespace()) {
// 1. Find the index where trailing whitespace begins
var newLength = processedText.length
while (newLength > 0 && processedText[newLength - 1].isWhitespace()) {
newLength--
}
// 2. Cut the text
processedText = processedText.substring(0, newLength)
// 3. Filter or Cap spans so they don't point to indices that no longer exist
val adjustedSpans = spans.mapNotNull { span ->
if (span.start >= newLength) {
// Span started in the whitespace area, remove it
null
} else if (span.end > newLength) {
// Span ended in the whitespace area, cap it
span.copy(end = newLength)
} else {
span
}
}
return processedText to adjustedSpans
}
return processedText to spans
}
private fun parseMathPlaceholderToSemantic(element: Element, style: CssStyle): List<SemanticBlock> {
val uniqueId = element.id()
val svgContent = mathSvgCache[uniqueId]
val altText = element.attr("alttext").ifBlank { "Equation" }
var svgWidth: String? = null
var svgHeight: String? = null
var svgViewBox: String? = null
if (svgContent != null) {
val svgDoc = Jsoup.parse(svgContent)
svgDoc.selectFirst("svg")?.let {
svgWidth = it.attr("width")
svgHeight = it.attr("height")
svgViewBox = it.attr("viewBox")
}
}
return listOf(
SemanticMath(
svgContent, altText, svgWidth, svgHeight, svgViewBox,
isFromMathJax = true, style = style,
elementId = element.id().ifBlank { null }, cfi = element.getCfiPath(), blockIndex = nextBlockIndex++
)
)
}
private fun parseSvgElementToSemantic(svgElement: Element, style: CssStyle): SemanticBlock? {
val children = svgElement.children()
val imageElement = children.firstOrNull()?.takeIf { children.size == 1 && it.tagName() == "image" }
if (imageElement != null) {
Timber.d("Detected SVG acting as a wrapper for an image. Parsing as SemanticImage.")
val href = imageElement.attr("href").ifBlank { imageElement.attr("xlink:href") }
if (href.isBlank()) return null
val imageFile = resolveImagePath(href) ?: return null
val (width, height) = imageDimensionsCache[imageFile.absolutePath] ?: run {
try {
BitmapFactory.Options().apply { inJustDecodeBounds = true }
.also { BitmapFactory.decodeFile(imageFile.absolutePath, it) }
.let {
Timber.tag("IMAGE_DIAG").d("Parsed file bounds: ${it.outWidth}x${it.outHeight} for ${imageFile.name}")
Pair(it.outWidth.toFloat(), it.outHeight.toFloat())
}
} catch (e: Exception) {
Timber.tag("IMAGE_DIAG").e(e, "Failed to parse image bounds for ${imageFile.name}")
Pair(null, null)
}
}
return SemanticImage(
path = imageFile.absolutePath,
altText = svgElement.selectFirst("title")?.text() ?: "Cover Image",
intrinsicWidth = width,
intrinsicHeight = height,
style = style,
elementId = svgElement.id().ifBlank { null },
cfi = svgElement.getCfiPath(),
blockIndex = nextBlockIndex++
)
}
Timber.d("Parsing genuine SVG content into SemanticMath block.")
val title = svgElement.selectFirst("title")?.text()
val desc = svgElement.selectFirst("desc")?.text()
val altText = title ?: desc ?: "SVG Image"
return SemanticMath(
svgContent = svgElement.outerHtml(),
altText = altText,
style = style,
elementId = svgElement.id().ifBlank { null },
cfi = svgElement.getCfiPath(),
svgWidth = svgElement.attr("width").ifBlank { null },
svgHeight = svgElement.attr("height").ifBlank { null },
svgViewBox = svgElement.attr("viewBox").ifBlank { null },
isFromMathJax = false,
blockIndex = nextBlockIndex++
)
}
private fun parseImageElementToSemantic(element: Element, style: CssStyle): SemanticBlock? {
val src = element.attr("src")
if (src.isBlank()) return null
val imageFile = resolveImagePath(src) ?: return null
if (imageFile.extension.equals("svg", ignoreCase = true)) {
return try {
val svgContent = imageFile.readText()
val svgElement = Jsoup.parseBodyFragment(svgContent).body().children().firstOrNull()
svgElement?.let { parseSvgElementToSemantic(it, style) }
} catch (e: Exception) {
Timber.e(e, "Failed to read SVG from <img> tag: ${imageFile.path}")
null
}
}
val (width, height) = imageDimensionsCache[imageFile.absolutePath] ?: run {
try {
BitmapFactory.Options().apply { inJustDecodeBounds = true }
.also { BitmapFactory.decodeFile(imageFile.absolutePath, it) }
.let { Pair(it.outWidth.toFloat(), it.outHeight.toFloat()) }
} catch (_: Exception) {
Pair(null, null)
}
}
return SemanticImage(
path = imageFile.absolutePath,
altText = element.attr("alt"),
intrinsicWidth = width,
intrinsicHeight = height,
style = style,
elementId = element.id().ifBlank { null },
cfi = element.getCfiPath(),
blockIndex = nextBlockIndex++
)
}
private fun resolveImagePath(src: String): File? {
if (src.isBlank()) return null
val decodedSrc = try { URLDecoder.decode(src, "UTF-8") } catch (_: Exception) { src }
val parentPath = File(chapterAbsPath).parent ?: ""
val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString()
val fromRelativeFile = File(extractionBasePath, relativePath)
try {
if (fromRelativeFile.exists()) return fromRelativeFile.canonicalFile
val fromRootFile = File(extractionBasePath, decodedSrc)
if (fromRootFile.exists()) return fromRootFile.canonicalFile
} catch (e: java.io.IOException) {
Timber.e(e, "Could not get canonical path for image at $src")
return null
}
Timber.w("Image not found. Tried: ${fromRelativeFile.absolutePath} and ${File(extractionBasePath, decodedSrc).absolutePath}")
return null
}
private fun parseListElementToSemantic(listElement: Element, listStyle: CssStyle): List<SemanticBlock> {
val isOrdered = listElement.tagName().lowercase() == "ol"
val items = listElement.children().mapNotNull { child ->
if (child.tagName().lowercase() != "li") return@mapNotNull null
val itemStyle = listStyle.merge(getElementStyle(child))
val (text, spans) = buildSemanticTextAndSpans(child, itemStyle)
val imageSrc = itemStyle.blockStyle.listStyleImage?.let { resolveImagePath(it)?.absolutePath }
SemanticListItem(text, spans, itemStyle, child.id().ifBlank { null }, child.getCfiPath(), 0, imageSrc, blockIndex = nextBlockIndex++)
}
return listOf(SemanticList(items, isOrdered, listStyle, listElement.id().ifBlank { null }, listElement.getCfiPath(), blockIndex = nextBlockIndex++))
}
private fun parseTableElementToSemantic(tableElement: Element, tableStyle: CssStyle): SemanticTable? {
val rows = tableElement.select("tr").mapNotNull { rowElement ->
val rowStyle = getElementStyle(rowElement)
if (rowStyle.display == "none") return@mapNotNull null
val cells = rowElement.children().mapNotNull { cellElement ->
val tagName = cellElement.tagName().lowercase()
if (tagName !in listOf("td", "th")) return@mapNotNull null
var cellCssStyle = getElementStyle(cellElement)
if (cellCssStyle.display == "none") return@mapNotNull null
if (!cellCssStyle.blockStyle.backgroundColor.isSpecified) {
if (rowStyle.blockStyle.backgroundColor.isSpecified) {
cellCssStyle = cellCssStyle.copy(
blockStyle = cellCssStyle.blockStyle.copy(
backgroundColor = rowStyle.blockStyle.backgroundColor
)
)
}
}
val cellContent = parseContainer(cellElement, cellCssStyle)
SemanticTableCell(cellContent, tagName == "th", cellElement.attr("colspan").toIntOrNull() ?: 1, cellCssStyle)
}
cells.ifEmpty { null }
}
if (rows.isEmpty()) return null
return SemanticTable(rows, tableStyle, tableElement.id().ifBlank { null }, tableElement.getCfiPath(), blockIndex = nextBlockIndex++)
}
}

View file

@ -115,7 +115,7 @@ class LocatorConverter(
otherComplex = mergedOtherComplex
)
val semanticBlocks = htmlToSemanticBlocks(
val semanticBlocks = androidHtmlToSemanticBlocks(
html = htmlToParse,
cssRules = parsingCssRules,
textStyle = TextStyle(),
@ -381,4 +381,4 @@ class LocatorConverter(
}
return@withContext null
}
}
}

View file

@ -21,6 +21,7 @@ import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@ -147,7 +148,7 @@ import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.ImageRequest.Builder
import com.aryan.reader.R
import com.aryan.reader.ReaderTexture
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.countWords
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epubreader.HighlightColor
@ -251,6 +252,12 @@ private fun headerFontScale(level: Int): Float = when (level) {
else -> 1.0f
}
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
}
private fun createHeaderTextStyle(
baseStyle: TextStyle,
level: Int,
@ -473,6 +480,51 @@ private fun computeImageRenderSizeDp(
return with(density) { widthPx.toDp() to heightPx.toDp() }
}
private fun imageBlockContentAlignment(style: BlockStyle): Alignment {
return when {
style.float == "right" || style.horizontalAlign == "right" || style.horizontalAlign == "end" -> Alignment.CenterEnd
style.float == "left" || style.horizontalAlign == "left" || style.horizontalAlign == "start" -> Alignment.CenterStart
else -> Alignment.Center
}
}
private fun tableCellImageModifier(
block: ImageBlock,
density: Density,
imageSizeMultiplier: Float
): Modifier {
val baseModifier = if (block.style.width.isSpecified && block.style.width > 0.dp) {
Modifier.width(block.style.width * imageSizeMultiplier)
} else {
Modifier.fillMaxWidth(imageSizeMultiplier.coerceIn(0f, 1f))
}
val intrinsicWidth = block.intrinsicWidth
val intrinsicHeight = block.intrinsicHeight
val sizedModifier = if (
intrinsicWidth != null &&
intrinsicHeight != null &&
intrinsicWidth > 0f &&
intrinsicHeight > 0f
) {
baseModifier.aspectRatio(intrinsicWidth / intrinsicHeight)
} else {
baseModifier.height(
if (block.expectedHeight > 0) {
with(density) { (block.expectedHeight * imageSizeMultiplier).toDp() }
} else {
250.dp
}
)
}
return if (block.style.maxWidth.isSpecified && block.style.maxWidth > 0.dp) {
sizedModifier.widthIn(max = block.style.maxWidth * imageSizeMultiplier)
} else {
sizedModifier
}
}
@Composable
private fun WrappingContentLayout(
block: WrappingContentBlock,
@ -682,6 +734,7 @@ fun PaginatedReaderScreen(
paragraphGapMultiplier: Float,
imageSizeMultiplier: Float,
horizontalMarginMultiplier: Float,
verticalMarginMultiplier: Float,
fontFamily: FontFamily,
textAlign: ReaderTextAlign,
ttsHighlightInfo: TtsHighlightInfo?,
@ -702,7 +755,8 @@ fun PaginatedReaderScreen(
onHighlightDeleted: (String) -> Unit,
activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit,
activeTextureId: String? = null
activeTextureId: String? = null,
activeTextureAlpha: Float = 0.55f
) {
LaunchedEffect(userHighlights) {
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
@ -713,11 +767,7 @@ fun PaginatedReaderScreen(
val context = LocalContext.current
val textureBitmap = remember(activeTextureId) {
activeTextureId?.let { id ->
ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
ImageBitmap.imageResource(context.resources, resId)
}
}
loadReaderTextureBitmap(context, activeTextureId)
}
val textureModifier = if (textureBitmap != null) {
@ -725,13 +775,13 @@ fun PaginatedReaderScreen(
val brush = ShaderBrush(
ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)
)
drawRect(brush = brush, blendMode = BlendMode.Multiply, alpha = 0.6f)
drawRect(brush = brush, blendMode = BlendMode.SrcOver, alpha = activeTextureAlpha.coerceIn(0f, 1f))
}
} else Modifier
var isNavigatingByLink by remember { mutableStateOf(false) }
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg).then(textureModifier)) {
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
val textMeasurer = rememberTextMeasurer()
val baseTextStyle = MaterialTheme.typography.bodyLarge
@ -740,6 +790,7 @@ fun PaginatedReaderScreen(
var debouncedParagraphGapMult by remember { mutableFloatStateOf(paragraphGapMultiplier) }
var debouncedImageSizeMult by remember { mutableFloatStateOf(imageSizeMultiplier) }
var debouncedHorizontalMarginMult by remember { mutableFloatStateOf(horizontalMarginMultiplier) }
var debouncedVerticalMarginMult by remember { mutableFloatStateOf(verticalMarginMultiplier) }
var debouncedFontFamily by remember { mutableStateOf(fontFamily) }
var debouncedTextAlign by remember { mutableStateOf(textAlign) }
@ -781,7 +832,7 @@ fun PaginatedReaderScreen(
debouncedFontFamily
) {
val adjustedFontSize = baseTextStyle.fontSize * debouncedFontSizeMult
val adjustedLineHeight = adjustedFontSize * debouncedLineHeightMult
val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
baseTextStyle.copy(
color = effectiveText,
@ -810,12 +861,13 @@ fun PaginatedReaderScreen(
}
}
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, fontFamily, textAlign) {
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, verticalMarginMultiplier, fontFamily, textAlign) {
if (fontSizeMultiplier != debouncedFontSizeMult ||
lineHeightMultiplier != debouncedLineHeightMult ||
paragraphGapMultiplier != debouncedParagraphGapMult ||
imageSizeMultiplier != debouncedImageSizeMult ||
horizontalMarginMultiplier != debouncedHorizontalMarginMult ||
verticalMarginMultiplier != debouncedVerticalMarginMult ||
fontFamily != debouncedFontFamily ||
textAlign != debouncedTextAlign
) {
@ -836,6 +888,7 @@ fun PaginatedReaderScreen(
debouncedParagraphGapMult = paragraphGapMultiplier
debouncedImageSizeMult = imageSizeMultiplier
debouncedHorizontalMarginMult = horizontalMarginMultiplier
debouncedVerticalMarginMult = verticalMarginMultiplier
debouncedFontFamily = fontFamily
debouncedTextAlign = textAlign
Timber.d("Debounce complete. Applying new format settings.")
@ -851,8 +904,28 @@ fun PaginatedReaderScreen(
}
val density = LocalDensity.current
val horizontalPadding = 16.dp * debouncedHorizontalMarginMult
val verticalPadding = 16.dp
val requestedHorizontalPadding = 16.dp * debouncedHorizontalMarginMult
val requestedVerticalPadding = 16.dp * debouncedVerticalMarginMult
val effectiveReaderPadding =
remember(this.constraints, density, requestedHorizontalPadding, requestedVerticalPadding) {
val requestedHorizontalPaddingPx = with(density) { requestedHorizontalPadding.roundToPx() }
val requestedVerticalPaddingPx = with(density) { requestedVerticalPadding.roundToPx() }
val minReadableWidthPx = with(density) { 96.dp.roundToPx() }
.coerceAtMost(this.constraints.maxWidth)
val minReadableHeightPx = with(density) { 160.dp.roundToPx() }
.coerceAtMost(this.constraints.maxHeight)
val horizontalPaddingPx = requestedHorizontalPaddingPx.coerceAtMost(
((this.constraints.maxWidth - minReadableWidthPx) / 2).coerceAtLeast(0)
)
val verticalPaddingPx = requestedVerticalPaddingPx.coerceAtMost(
((this.constraints.maxHeight - minReadableHeightPx) / 2).coerceAtLeast(0)
)
with(density) {
horizontalPaddingPx.toDp() to verticalPaddingPx.toDp()
}
}
val horizontalPadding = effectiveReaderPadding.first
val verticalPadding = effectiveReaderPadding.second
val textConstraints =
remember(this.constraints, density, horizontalPadding, verticalPadding) {
@ -860,9 +933,9 @@ fun PaginatedReaderScreen(
val verticalPaddingPx = with(density) { verticalPadding.roundToPx() }
val finalConstraints = this.constraints.copy(
minWidth = 0,
maxWidth = this.constraints.maxWidth - (2 * horizontalPaddingPx),
maxWidth = (this.constraints.maxWidth - (2 * horizontalPaddingPx)).coerceAtLeast(1),
minHeight = 0,
maxHeight = this.constraints.maxHeight - (2 * verticalPaddingPx)
maxHeight = (this.constraints.maxHeight - (2 * verticalPaddingPx)).coerceAtLeast(1)
)
finalConstraints
}
@ -950,7 +1023,8 @@ fun PaginatedReaderScreen(
mathMLRenderer = mathMLRenderer,
userTextAlign = userTextAlign,
paragraphGapMultiplier = debouncedParagraphGapMult,
imageSizeMultiplier = debouncedImageSizeMult
imageSizeMultiplier = debouncedImageSizeMult,
verticalMarginMultiplier = debouncedVerticalMarginMult
)
}
@ -1168,7 +1242,10 @@ fun PaginatedReaderScreen(
isDarkTheme = isDarkTheme,
activeHighlightPalette = activeHighlightPalette,
onUpdatePalette = onUpdatePalette,
effectiveText = effectiveText
effectiveText = effectiveText,
pageTextureModifier = if (isPageTurnAnimationEnabled) Modifier else textureModifier,
pageTextureBitmap = textureBitmap,
pageTextureAlpha = activeTextureAlpha.coerceIn(0f, 1f)
)
androidx.compose.animation.AnimatedVisibility(
@ -1989,7 +2066,10 @@ internal fun PaginatedReaderContent(
onHighlightDeleted: (String) -> Unit,
activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit,
isDarkTheme: Boolean
isDarkTheme: Boolean,
pageTextureModifier: Modifier = Modifier,
pageTextureBitmap: ImageBitmap? = null,
pageTextureAlpha: Float = 0f
) {
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
@ -2130,7 +2210,9 @@ internal fun PaginatedReaderContent(
pageIndex,
effectiveBg,
isDarkTheme,
pageTurnTouchY
pageTurnTouchY,
pageTextureBitmap,
pageTextureAlpha
)
} else Modifier
@ -2284,7 +2366,7 @@ internal fun PaginatedReaderContent(
pendingCrossPageSelection = null
}
Box(modifier = Modifier.fillMaxSize().then(pageModifier)) {
Box(modifier = Modifier.fillMaxSize().background(effectiveBg).then(pageTextureModifier).then(pageModifier)) {
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
detectTapGestures(
@ -2784,12 +2866,14 @@ internal fun PaginatedReaderContent(
val markerAreaModifier =
Modifier.width(32.dp)
.padding(end = 8.dp)
val itemMarkerImage = block.itemMarkerImage
val itemMarker = block.itemMarker
if (block.itemMarkerImage != null) {
if (itemMarkerImage != null) {
val imageRequest =
Builder(LocalContext.current).data(
File(
block.itemMarkerImage
itemMarkerImage
)
).crossfade(true).build()
val imageSize = with(density) {
@ -2805,9 +2889,9 @@ internal fun PaginatedReaderContent(
alignment = Alignment.CenterEnd,
contentScale = ContentScale.FillHeight
)
} else if (block.itemMarker != null) {
} else if (itemMarker != null) {
Text(
text = block.itemMarker,
text = itemMarker,
style = textStyle.copy(
textAlign = TextAlign.End
),
@ -3017,10 +3101,12 @@ internal fun PaginatedReaderContent(
}
is MathBlock -> {
val svgContent = block.svgContent?.takeIf { it.isNotBlank() }
Timber.d(
"PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${!block.svgContent.isNullOrBlank()}"
"PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${svgContent != null}"
)
if (!block.svgContent.isNullOrBlank()) {
if (svgContent != null) {
val nonBlankSvgContent = svgContent
BoxWithConstraints(
modifier = paddingModifier
) {
@ -3104,7 +3190,7 @@ internal fun PaginatedReaderContent(
val imageRequest =
Builder(LocalContext.current).data(
SvgData(
block.svgContent
nonBlankSvgContent
)
).listener(
onError = { _, result ->
@ -3189,7 +3275,10 @@ internal fun PaginatedReaderContent(
)
}).crossfade(true).build()
BoxWithConstraints(modifier = paddingModifier) {
BoxWithConstraints(
modifier = paddingModifier,
contentAlignment = imageBlockContentAlignment(style)
) {
val scaledSize = computeImageRenderSizeDp(
block = block,
density = density,
@ -3357,9 +3446,10 @@ internal fun PaginatedReaderContent(
Row(
verticalAlignment = Alignment.Top
) {
if (blockInCell.itemMarker != null) {
val itemMarker = blockInCell.itemMarker
if (itemMarker != null) {
Text(
text = blockInCell.itemMarker,
text = itemMarker,
style = cellTextStyle,
modifier = Modifier.padding(
end = 4.dp
@ -3390,40 +3480,23 @@ internal fun PaginatedReaderContent(
}
is ImageBlock -> {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val scaledSize = computeImageRenderSizeDp(
AsyncImage(
model = Builder(
LocalContext.current
).data(
File(
blockInCell.path
)
)
.build(),
contentDescription = blockInCell.altText,
contentScale = ContentScale.Fit,
modifier = tableCellImageModifier(
block = blockInCell,
density = density,
maxWidthDp = maxWidth,
imageSizeMultiplier = imageSizeMultiplier
)
val imageModifier = Modifier.then(
if (scaledSize != null) {
Modifier.width(scaledSize.first).height(scaledSize.second)
} else {
Modifier.fillMaxWidth().then(
if (blockInCell.expectedHeight > 0) {
Modifier.height(with(density) { (blockInCell.expectedHeight * imageSizeMultiplier).toDp() })
} else {
Modifier.height(250.dp)
}
)
}
)
AsyncImage(
model = Builder(
LocalContext.current
).data(
File(
blockInCell.path
)
)
.build(),
contentDescription = blockInCell.altText,
contentScale = ContentScale.Fit,
modifier = imageModifier
)
}
)
}
is TextContentBlock -> {
@ -4101,10 +4174,12 @@ private fun RenderFlexChildBlock(
val markerAreaModifier = Modifier
.width(32.dp)
.padding(end = 8.dp)
val itemMarkerImage = childBlock.itemMarkerImage
val itemMarker = childBlock.itemMarker
if (childBlock.itemMarkerImage != null) {
if (itemMarkerImage != null) {
val imageRequest =
Builder(LocalContext.current).data(File(childBlock.itemMarkerImage))
Builder(LocalContext.current).data(File(itemMarkerImage))
.crossfade(true).build()
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
@ -4115,9 +4190,9 @@ private fun RenderFlexChildBlock(
alignment = Alignment.CenterEnd,
contentScale = ContentScale.FillHeight
)
} else if (childBlock.itemMarker != null) {
} else if (itemMarker != null) {
Text(
text = childBlock.itemMarker,
text = itemMarker,
style = textStyle.copy(textAlign = TextAlign.End),
modifier = markerAreaModifier
)
@ -4160,7 +4235,7 @@ private fun RenderFlexChildBlock(
ColorFilter.colorMatrix(ColorMatrix(matrix))
} else null
BoxWithConstraints {
BoxWithConstraints(contentAlignment = imageBlockContentAlignment(style)) {
val scaledSize = computeImageRenderSizeDp(
block = childBlock,
density = density,
@ -4278,37 +4353,20 @@ private fun RenderFlexChildBlock(
modifier = Modifier.fillMaxWidth()
)
} else if (blockInCell is ImageBlock) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val scaledSize = computeImageRenderSizeDp(
AsyncImage(
model = Builder(LocalContext.current).data(
File(
blockInCell.path
)
).build(),
contentDescription = blockInCell.altText,
contentScale = ContentScale.Fit,
modifier = tableCellImageModifier(
block = blockInCell,
density = density,
maxWidthDp = maxWidth,
imageSizeMultiplier = imageSizeMultiplier
)
val imageModifier = Modifier.then(
if (scaledSize != null) {
Modifier.width(scaledSize.first).height(scaledSize.second)
} else {
Modifier.fillMaxWidth().then(
if (blockInCell.expectedHeight > 0) {
Modifier.height(with(density) { (blockInCell.expectedHeight * imageSizeMultiplier).toDp() })
} else {
Modifier.height(250.dp)
}
)
}
)
AsyncImage(
model = Builder(LocalContext.current).data(
File(
blockInCell.path
)
).build(),
contentDescription = blockInCell.altText,
contentScale = ContentScale.Fit,
modifier = imageModifier
)
}
)
}
}
}
@ -4332,7 +4390,9 @@ private fun Modifier.realisticBookPage(
pageIndex: Int,
paperColor: Color,
isDarkTheme: Boolean,
touchY: Float?
touchY: Float?,
textureBitmap: ImageBitmap? = null,
textureAlpha: Float = 0f
): Modifier = composed {
val frontPath = remember { Path() }
@ -4360,9 +4420,19 @@ private fun Modifier.realisticBookPage(
.drawWithContent {
val drawStart = System.nanoTime()
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
fun drawPaperBackground() {
drawRect(color = paperColor)
if (textureBitmap != null && textureAlpha > 0f) {
drawRect(
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
blendMode = BlendMode.SrcOver,
alpha = textureAlpha
)
}
}
if (abs(pageOffset) < 0.001f) {
drawRect(color = paperColor)
drawPaperBackground()
drawContent()
}
else if (pageOffset < 0f && pageOffset > -1f) {
@ -4423,7 +4493,7 @@ private fun Modifier.realisticBookPage(
frontPath.close()
clipPath(frontPath) {
drawRect(color = paperColor)
drawPaperBackground()
this@drawWithContent.drawContent()
}
@ -4466,6 +4536,15 @@ private fun Modifier.realisticBookPage(
clipRect(0f, 0f, w, h) {
clipPath(frontPath) {
drawPath(reflectedScreenPath, color = paperColor)
if (textureBitmap != null && textureAlpha > 0f) {
clipPath(reflectedScreenPath) {
drawRect(
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
blendMode = BlendMode.SrcOver,
alpha = textureAlpha
)
}
}
val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
drawPath(reflectedScreenPath, color = flapTint)
@ -4493,12 +4572,12 @@ private fun Modifier.realisticBookPage(
}
} else {
drawRect(color = paperColor)
drawPaperBackground()
drawContent()
}
}
else {
drawRect(color = paperColor)
drawPaperBackground()
drawContent()
}
@ -4515,10 +4594,14 @@ fun Modifier.drawCssBorders(
blockStyle: BlockStyle,
@Suppress("unused") density: Density
): Modifier = this.drawBehind {
val topWidth = blockStyle.borderTop?.width?.toPx() ?: 0f
val rightWidth = blockStyle.borderRight?.width?.toPx() ?: 0f
val bottomWidth = blockStyle.borderBottom?.width?.toPx() ?: 0f
val leftWidth = blockStyle.borderLeft?.width?.toPx() ?: 0f
val borderTop = blockStyle.borderTop
val borderRight = blockStyle.borderRight
val borderBottom = blockStyle.borderBottom
val borderLeft = blockStyle.borderLeft
val topWidth = borderTop?.width?.toPx() ?: 0f
val rightWidth = borderRight?.width?.toPx() ?: 0f
val bottomWidth = borderBottom?.width?.toPx() ?: 0f
val leftWidth = borderLeft?.width?.toPx() ?: 0f
val tlRadius = blockStyle.borderTopLeftRadius.toPx()
val trRadius = blockStyle.borderTopRightRadius.toPx()
@ -4550,9 +4633,9 @@ fun Modifier.drawCssBorders(
}
// TOP
if (topWidth > 0f && blockStyle.borderTop != null) {
val color = blockStyle.borderTop.color
val effect = getPathEffect(blockStyle.borderTop.style, topWidth)
if (topWidth > 0f && borderTop != null) {
val color = borderTop.color
val effect = getPathEffect(borderTop.style, topWidth)
val offset = topWidth / 2f
val startX = if (tlRadius > 0) tlRadius else 0f
@ -4568,9 +4651,9 @@ fun Modifier.drawCssBorders(
}
// BOTTOM
if (bottomWidth > 0f && blockStyle.borderBottom != null) {
val color = blockStyle.borderBottom.color
val effect = getPathEffect(blockStyle.borderBottom.style, bottomWidth)
if (bottomWidth > 0f && borderBottom != null) {
val color = borderBottom.color
val effect = getPathEffect(borderBottom.style, bottomWidth)
val offset = size.height - (bottomWidth / 2f)
val startX = if (blRadius > 0) blRadius else 0f
@ -4586,9 +4669,9 @@ fun Modifier.drawCssBorders(
}
// LEFT
if (leftWidth > 0f && blockStyle.borderLeft != null) {
val color = blockStyle.borderLeft.color
val effect = getPathEffect(blockStyle.borderLeft.style, leftWidth)
if (leftWidth > 0f && borderLeft != null) {
val color = borderLeft.color
val effect = getPathEffect(borderLeft.style, leftWidth)
val offset = leftWidth / 2f
val startY = if (tlRadius > 0) tlRadius else 0f
@ -4604,9 +4687,9 @@ fun Modifier.drawCssBorders(
}
// RIGHT
if (rightWidth > 0f && blockStyle.borderRight != null) {
val color = blockStyle.borderRight.color
val effect = getPathEffect(blockStyle.borderRight.style, rightWidth)
if (rightWidth > 0f && borderRight != null) {
val color = borderRight.color
val effect = getPathEffect(borderRight.style, rightWidth)
val offset = size.width - (rightWidth / 2f)
val startY = if (trRadius > 0) trRadius else 0f
@ -4621,9 +4704,9 @@ fun Modifier.drawCssBorders(
)
}
if (tlRadius > 0f && topWidth > 0f && leftWidth > 0f && blockStyle.borderTop != null) {
if (tlRadius > 0f && topWidth > 0f && leftWidth > 0f && borderTop != null) {
drawArc(
color = blockStyle.borderTop.color,
color = borderTop.color,
startAngle = 180f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(leftWidth/2f, topWidth/2f),
@ -4632,9 +4715,9 @@ fun Modifier.drawCssBorders(
)
}
if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && blockStyle.borderTop != null) {
if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && borderTop != null) {
drawArc(
color = blockStyle.borderTop.color,
color = borderTop.color,
startAngle = 270f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f),
@ -4643,9 +4726,9 @@ fun Modifier.drawCssBorders(
)
}
if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && blockStyle.borderBottom != null) {
if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && borderBottom != null) {
drawArc(
color = blockStyle.borderBottom.color,
color = borderBottom.color,
startAngle = 0f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)),
@ -4654,9 +4737,9 @@ fun Modifier.drawCssBorders(
)
}
if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && blockStyle.borderBottom != null) {
if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && borderBottom != null) {
drawArc(
color = blockStyle.borderBottom.color,
color = borderBottom.color,
startAngle = 90f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)),

View file

@ -1,412 +0,0 @@
/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
@file:OptIn(ExperimentalSerializationApi::class)
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import com.aryan.reader.paginatedreader.serialization.AnnotatedStringSerializer
import com.aryan.reader.paginatedreader.serialization.ColorSerializer
import com.aryan.reader.paginatedreader.serialization.DpSerializer
import com.aryan.reader.paginatedreader.serialization.ParagraphStyleSerializer
import com.aryan.reader.paginatedreader.serialization.SpanStyleSerializer
import com.aryan.reader.paginatedreader.serialization.TextAlignSerializer
import com.aryan.reader.paginatedreader.serialization.TextUnitSerializer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@Serializable
data class BlockStyle(
@ProtoNumber(1) val margin: BoxBorders = BoxBorders(),
@ProtoNumber(2) val padding: BoxBorders = BoxBorders(),
@ProtoNumber(3) @Serializable(with = DpSerializer::class) val width: Dp = Dp.Unspecified,
@ProtoNumber(4) @Serializable(with = DpSerializer::class) val maxWidth: Dp = Dp.Unspecified,
@ProtoNumber(5) @Serializable(with = DpSerializer::class) val height: Dp = Dp.Unspecified,
@ProtoNumber(6) @Serializable(with = ColorSerializer::class) val backgroundColor: Color = Color.Unspecified,
@ProtoNumber(7) val borderTop: BorderStyle? = null,
@ProtoNumber(8) val borderRight: BorderStyle? = null,
@ProtoNumber(9) val borderBottom: BorderStyle? = null,
@ProtoNumber(10) val borderLeft: BorderStyle? = null,
@ProtoNumber(11) val listStyleType: String? = null,
@ProtoNumber(12) val listStyleImage: String? = null,
@ProtoNumber(13) val pageBreakInsideAvoid: Boolean = false,
@ProtoNumber(14) val pageBreakAfterAvoid: Boolean = false,
@ProtoNumber(15) val boxSizing: String? = null,
@ProtoNumber(16) val float: String? = null,
@ProtoNumber(17) val clear: String? = null,
@ProtoNumber(18) val position: String? = null,
@ProtoNumber(19) @Serializable(with = DpSerializer::class) val top: Dp = Dp.Unspecified,
@ProtoNumber(20) @Serializable(with = DpSerializer::class) val right: Dp = Dp.Unspecified,
@ProtoNumber(21) @Serializable(with = DpSerializer::class) val bottom: Dp = Dp.Unspecified,
@ProtoNumber(22) @Serializable(with = DpSerializer::class) val left: Dp = Dp.Unspecified,
@ProtoNumber(23) val display: String? = null,
@ProtoNumber(24) val flexDirection: String? = null,
@ProtoNumber(25) val justifyContent: String? = null,
@ProtoNumber(26) val alignItems: String? = null,
@ProtoNumber(27) val horizontalAlign: String? = null,
@ProtoNumber(28) val filter: String? = null,
@ProtoNumber(29) val borderCollapse: String? = null,
@ProtoNumber(30) @Serializable(with = DpSerializer::class) val borderTopLeftRadius: Dp = 0.dp,
@ProtoNumber(31) @Serializable(with = DpSerializer::class) val borderTopRightRadius: Dp = 0.dp,
@ProtoNumber(32) @Serializable(with = DpSerializer::class) val borderBottomRightRadius: Dp = 0.dp,
@ProtoNumber(33) @Serializable(with = DpSerializer::class) val borderBottomLeftRadius: Dp = 0.dp,
@ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp
) {
fun merge(other: BlockStyle): BlockStyle {
return BlockStyle(
margin = BoxBorders(
top = if (other.margin.top != 0.dp) other.margin.top else this.margin.top,
bottom = if (other.margin.bottom != 0.dp) other.margin.bottom else this.margin.bottom,
left = if (other.margin.left != 0.dp) other.margin.left else this.margin.left,
right = if (other.margin.right != 0.dp) other.margin.right else this.margin.right
),
padding = BoxBorders(
top = if (other.padding.top != 0.dp) other.padding.top else this.padding.top,
bottom = if (other.padding.bottom != 0.dp) other.padding.bottom else this.padding.bottom,
left = if (other.padding.left != 0.dp) other.padding.left else this.padding.left,
right = if (other.padding.right != 0.dp) other.padding.right else this.padding.right
),
width = if (other.width != Dp.Unspecified) other.width else this.width,
maxWidth = if (other.maxWidth != Dp.Unspecified) other.maxWidth else this.maxWidth,
height = if (other.height != Dp.Unspecified) other.height else this.height,
backgroundColor = if (other.backgroundColor.isSpecified) other.backgroundColor else this.backgroundColor,
borderTop = other.borderTop ?: this.borderTop,
borderRight = other.borderRight ?: this.borderRight,
borderBottom = other.borderBottom ?: this.borderBottom,
borderLeft = other.borderLeft ?: this.borderLeft,
borderTopLeftRadius = if (other.borderTopLeftRadius != 0.dp) other.borderTopLeftRadius else this.borderTopLeftRadius,
borderTopRightRadius = if (other.borderTopRightRadius != 0.dp) other.borderTopRightRadius else this.borderTopRightRadius,
borderBottomRightRadius = if (other.borderBottomRightRadius != 0.dp) other.borderBottomRightRadius else this.borderBottomRightRadius,
borderBottomLeftRadius = if (other.borderBottomLeftRadius != 0.dp) other.borderBottomLeftRadius else this.borderBottomLeftRadius,
listStyleType = other.listStyleType ?: this.listStyleType,
listStyleImage = other.listStyleImage ?: this.listStyleImage,
pageBreakInsideAvoid = this.pageBreakInsideAvoid || other.pageBreakInsideAvoid,
pageBreakAfterAvoid = this.pageBreakAfterAvoid || other.pageBreakAfterAvoid,
boxSizing = other.boxSizing ?: this.boxSizing,
float = other.float ?: this.float,
clear = other.clear ?: this.clear,
position = other.position ?: this.position,
top = if (other.top.isSpecified) other.top else this.top,
right = if (other.right.isSpecified) other.right else this.right,
bottom = if (other.bottom.isSpecified) other.bottom else this.bottom,
left = if (other.left.isSpecified) other.left else this.left,
display = other.display ?: this.display,
flexDirection = other.flexDirection ?: this.flexDirection,
justifyContent = other.justifyContent ?: this.justifyContent,
alignItems = other.alignItems ?: this.alignItems,
horizontalAlign = other.horizontalAlign ?: this.horizontalAlign,
filter = other.filter ?: this.filter,
borderCollapse = other.borderCollapse ?: this.borderCollapse,
borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing
)
}
}
@Serializable
data class BoxBorders(
@ProtoNumber(1) @Serializable(with = DpSerializer::class) val top: Dp = 0.dp,
@ProtoNumber(2) @Serializable(with = DpSerializer::class) val right: Dp = 0.dp,
@ProtoNumber(3) @Serializable(with = DpSerializer::class) val bottom: Dp = 0.dp,
@ProtoNumber(4) @Serializable(with = DpSerializer::class) val left: Dp = 0.dp
)
@Serializable
data class BorderStyle(
@ProtoNumber(1) @Serializable(with = DpSerializer::class) val width: Dp = 0.dp,
@ProtoNumber(2) @Serializable(with = ColorSerializer::class) val color: Color = Color.Transparent,
@ProtoNumber(3) val style: String = "solid"
)
@Serializable
sealed interface ContentBlock {
val style: BlockStyle
val elementId: String?
val cfi: String?
val blockIndex: Int
val expectedHeight: Int
}
sealed interface TextContentBlock : ContentBlock {
val content: AnnotatedString
val startCharOffsetInSource: Int
val endCharOffsetInSource: Int
}
@Serializable
data class ParagraphBlock(
@ProtoNumber(1) @Serializable(with = AnnotatedStringSerializer::class) override val content: AnnotatedString,
@ProtoNumber(2) @Serializable(with = TextAlignSerializer::class) val textAlign: TextAlign? = null,
@ProtoNumber(3) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(4) override val elementId: String? = null,
@ProtoNumber(5) override val cfi: String? = null,
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(7) override val endCharOffsetInSource: Int = -1,
@ProtoNumber(8) override val blockIndex: Int,
@ProtoNumber(9) override val expectedHeight: Int = 0
) : TextContentBlock
@Serializable
data class ImageBlock(
@ProtoNumber(1) val path: String,
@ProtoNumber(2) val altText: String?,
@ProtoNumber(3) val intrinsicWidth: Float? = null,
@ProtoNumber(4) val intrinsicHeight: Float? = null,
@ProtoNumber(5) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(6) override val elementId: String? = null,
@ProtoNumber(7) override val cfi: String? = null,
@ProtoNumber(8) val invertOnDarkTheme: Boolean = false,
@ProtoNumber(9) override val blockIndex: Int,
@ProtoNumber(10) override val expectedHeight: Int = 0
) : ContentBlock
@Serializable
data class HeaderBlock(
@ProtoNumber(1) val level: Int,
@ProtoNumber(2) @Serializable(with = AnnotatedStringSerializer::class) override val content: AnnotatedString,
@ProtoNumber(3) @Serializable(with = TextAlignSerializer::class) val textAlign: TextAlign? = null,
@ProtoNumber(4) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(5) override val elementId: String? = null,
@ProtoNumber(6) override val cfi: String? = null,
@ProtoNumber(7) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(8) override val endCharOffsetInSource: Int = -1,
@ProtoNumber(9) override val blockIndex: Int,
@ProtoNumber(10) override val expectedHeight: Int = 0
) : TextContentBlock
@Serializable
data class SpacerBlock(
@ProtoNumber(1) @Serializable(with = DpSerializer::class) val height: Dp = 8.dp,
@ProtoNumber(2) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(3) override val elementId: String? = null,
@ProtoNumber(4) override val cfi: String? = null,
@ProtoNumber(5) override val blockIndex: Int,
@ProtoNumber(6) override val expectedHeight: Int = 0
) : ContentBlock
@Serializable
data class QuoteBlock(
@ProtoNumber(1) @Serializable(with = AnnotatedStringSerializer::class) override val content: AnnotatedString,
@ProtoNumber(2) @Serializable(with = TextAlignSerializer::class) val textAlign: TextAlign? = null,
@ProtoNumber(3) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(4) override val elementId: String? = null,
@ProtoNumber(5) override val cfi: String? = null,
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(7) override val endCharOffsetInSource: Int = -1,
@ProtoNumber(8) override val blockIndex: Int,
@ProtoNumber(9) override val expectedHeight: Int = 0
) : TextContentBlock
@Serializable
data class ListItemBlock(
@ProtoNumber(1) @Serializable(with = AnnotatedStringSerializer::class) override val content: AnnotatedString,
@ProtoNumber(2) val itemMarker: String?,
@ProtoNumber(3) val itemMarkerImage: String? = null,
@ProtoNumber(4) override val style: BlockStyle,
@ProtoNumber(5) override val elementId: String? = null,
@ProtoNumber(6) override val cfi: String? = null,
@ProtoNumber(7) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(8) override val endCharOffsetInSource: Int = -1,
@ProtoNumber(9) override val blockIndex: Int,
@ProtoNumber(10) override val expectedHeight: Int = 0
) : TextContentBlock
@Serializable
data class TableCell(
@ProtoNumber(1) val content: List<ContentBlock>,
@ProtoNumber(2) val isHeader: Boolean = false,
@ProtoNumber(3) val style: CssStyle = CssStyle(),
@ProtoNumber(4) val colspan: Int = 1
)
@Serializable
data class TableBlock(
@ProtoNumber(1) val rows: List<List<TableCell>>,
@ProtoNumber(2) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(3) override val elementId: String? = null,
@ProtoNumber(4) override val cfi: String? = null,
@ProtoNumber(5) override val blockIndex: Int,
@ProtoNumber(6) override val expectedHeight: Int = 0
) : ContentBlock
@Serializable
data class MathBlock(
@ProtoNumber(1) val svgContent: String?,
@ProtoNumber(2) val altText: String?,
@ProtoNumber(3) override val style: BlockStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) val svgWidth: String? = null,
@ProtoNumber(7) val svgHeight: String? = null,
@ProtoNumber(8) val svgViewBox: String? = null,
@ProtoNumber(9) val isFromMathJax: Boolean = false,
@ProtoNumber(10) override val blockIndex: Int,
@ProtoNumber(11) override val expectedHeight: Int = 0
) : ContentBlock
@Serializable
data class WrappingContentBlock(
@ProtoNumber(1) val floatedImage: ImageBlock,
@ProtoNumber(2) val paragraphsToWrap: List<ParagraphBlock>,
@ProtoNumber(3) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(4) override val elementId: String? = null,
@ProtoNumber(5) override val cfi: String? = null,
@ProtoNumber(6) override val blockIndex: Int,
@ProtoNumber(7) override val expectedHeight: Int = 0
) : ContentBlock
@Serializable
data class TextEmphasis(
@ProtoNumber(1) val style: String? = null,
@ProtoNumber(2) val fill: String? = null,
@ProtoNumber(3) @Serializable(with = ColorSerializer::class) val color: Color = Color.Unspecified,
@ProtoNumber(4) val position: String? = null
)
@Serializable
data class CssStyle(
@ProtoNumber(1) @Serializable(with = SpanStyleSerializer::class) val spanStyle: SpanStyle = SpanStyle(),
@ProtoNumber(2) @Serializable(with = ParagraphStyleSerializer::class) val paragraphStyle: ParagraphStyle = ParagraphStyle(),
@ProtoNumber(3) val blockStyle: BlockStyle = BlockStyle(),
@ProtoNumber(4) val fontFamilies: List<String> = emptyList(),
@ProtoNumber(5) val display: String? = null,
@ProtoNumber(6) @Serializable(with = TextUnitSerializer::class) val fontSize: TextUnit = TextUnit.Unspecified,
@ProtoNumber(7) val textTransform: String? = null,
@ProtoNumber(8) val boxSizing: String? = null,
@ProtoNumber(9) val content: String? = null,
@ProtoNumber(10) val hyphens: String? = null,
@ProtoNumber(11) val fontVariantNumeric: String? = null,
@ProtoNumber(12) val textEmphasis: TextEmphasis? = null,
@ProtoNumber(13) @Serializable(with = TextUnitSerializer::class) val wordSpacing: TextUnit = TextUnit.Unspecified,
@ProtoNumber(14) val textDecorationStyle: String? = null,
@ProtoNumber(15) @Serializable(with = ColorSerializer::class) val textDecorationColor: Color = Color.Unspecified,
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified
) {
fun merge(other: CssStyle): CssStyle {
return CssStyle(
spanStyle = this.spanStyle.merge(other.spanStyle),
paragraphStyle = this.paragraphStyle.merge(other.paragraphStyle),
blockStyle = this.blockStyle.merge(other.blockStyle),
fontFamilies = other.fontFamilies.takeIf { it.isNotEmpty() } ?: this.fontFamilies,
display = other.display ?: this.display,
fontSize = if (other.fontSize.isSpecified) other.fontSize else this.fontSize,
textTransform = other.textTransform ?: this.textTransform,
boxSizing = other.boxSizing ?: this.boxSizing,
content = other.content ?: this.content,
hyphens = other.hyphens ?: this.hyphens,
fontVariantNumeric = other.fontVariantNumeric ?: this.fontVariantNumeric,
textEmphasis = other.textEmphasis ?: this.textEmphasis,
wordSpacing = if (other.wordSpacing.isSpecified) other.wordSpacing else this.wordSpacing,
textDecorationStyle = other.textDecorationStyle ?: this.textDecorationStyle,
textDecorationColor = if (other.textDecorationColor.isSpecified) other.textDecorationColor else this.textDecorationColor,
textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset
)
}
}
@Serializable
data class CssSelector(
@ProtoNumber(1) val selector: String,
@ProtoNumber(2) val specificity: Int
)
@Serializable
data class CssRule(
@ProtoNumber(1) val selector: CssSelector,
@ProtoNumber(2) val style: CssStyle
)
@Serializable
data class FontFaceInfo(
@ProtoNumber(1) val fontFamily: String,
@ProtoNumber(2) val src: String,
@ProtoNumber(3) @Serializable(with = com.aryan.reader.paginatedreader.serialization.FontWeightSerializer::class) val fontWeight: FontWeight?,
@ProtoNumber(4) @Serializable(with = com.aryan.reader.paginatedreader.serialization.FontStyleSerializer::class) val fontStyle: FontStyle?
)
@Serializable
data class Page(
@ProtoNumber(1) val content: List<ContentBlock>
)
@Serializable
data class FlexContainerBlock(
@ProtoNumber(1) val children: List<ContentBlock>,
@ProtoNumber(2) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(3) override val elementId: String? = null,
@ProtoNumber(4) override val cfi: String? = null,
@ProtoNumber(5) override val blockIndex: Int,
@ProtoNumber(6) override val expectedHeight: Int = 0
) : ContentBlock
@Serializable
data class OptimizedCssRules(
@ProtoNumber(1) val byTag: Map<String, List<CssRule>> = emptyMap(),
@ProtoNumber(2) val byClass: Map<String, List<CssRule>> = emptyMap(),
@ProtoNumber(3) val byId: Map<String, List<CssRule>> = emptyMap(),
@ProtoNumber(4) val otherComplex: List<CssRule> = emptyList()
) {
fun merge(other: OptimizedCssRules): OptimizedCssRules {
fun mergeMap(
m1: Map<String, List<CssRule>>,
m2: Map<String, List<CssRule>>
): Map<String, List<CssRule>> {
if (m1.isEmpty()) return m2
if (m2.isEmpty()) return m1
val result = LinkedHashMap(m1)
for ((key, value) in m2) {
val existing = result[key]
if (existing != null) {
result[key] = existing + value
} else {
result[key] = value
}
}
return result
}
return OptimizedCssRules(
byTag = mergeMap(this.byTag, other.byTag),
byClass = mergeMap(this.byClass, other.byClass),
byId = mergeMap(this.byId, other.byId),
otherComplex = this.otherComplex + other.otherComplex
)
}
fun toFlatList(): List<CssRule> {
return byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex
}
}
data class OptimizedCssParseResult(
val rules: OptimizedCssRules,
val fontFaces: List<FontFaceInfo>
)

View file

@ -145,7 +145,8 @@ class PaginatedReaderViewModel : ViewModel() {
mathMLRenderer = mathMLRenderer,
userTextAlign = null,
paragraphGapMultiplier = paragraphGapMultiplier,
imageSizeMultiplier = 1.0f
imageSizeMultiplier = 1.0f,
verticalMarginMultiplier = 1.0f
)
paginator = newPaginator

View file

@ -1,202 +0,0 @@
/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
@file:OptIn(ExperimentalSerializationApi::class)
package com.aryan.reader.paginatedreader
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
@Serializable
sealed interface SemanticBlock {
val elementId: String?
val cfi: String?
val style: CssStyle
val blockIndex: Int
}
@Serializable
data class SemanticSpan(
@ProtoNumber(1) val start: Int,
@ProtoNumber(2) val end: Int,
@ProtoNumber(3) val style: CssStyle,
@ProtoNumber(4) val linkHref: String? = null,
@ProtoNumber(5) val tag: String,
@ProtoNumber(6) val elementId: String? = null // Add this
)
fun SemanticBlock.withElementId(id: String): SemanticBlock {
if (this.elementId != null) return this
return when (this) {
is SemanticParagraph -> this.copy(elementId = id)
is SemanticHeader -> this.copy(elementId = id)
is SemanticListItem -> this.copy(elementId = id)
is SemanticList -> this.copy(elementId = id)
is SemanticImage -> this.copy(elementId = id)
is SemanticMath -> this.copy(elementId = id)
is SemanticSpacer -> this.copy(elementId = id)
is SemanticTable -> this.copy(elementId = id)
is SemanticFlexContainer -> this.copy(elementId = id)
is SemanticWrappingBlock -> this.copy(elementId = id)
is SemanticTextBlock -> this
}
}
interface SemanticTextBlock : SemanticBlock {
val text: String
val spans: List<SemanticSpan>
val startCharOffsetInSource: Int
}
@Serializable
data class SemanticParagraph(
@ProtoNumber(1) override val text: String,
@ProtoNumber(2) override val spans: List<SemanticSpan>,
@ProtoNumber(3) override val style: CssStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(7) override val blockIndex: Int = 0
) : SemanticTextBlock
@Serializable
data class SemanticHeader(
@ProtoNumber(1) val level: Int,
@ProtoNumber(2) override val text: String,
@ProtoNumber(3) override val spans: List<SemanticSpan>,
@ProtoNumber(4) override val style: CssStyle,
@ProtoNumber(5) override val elementId: String?,
@ProtoNumber(6) override val cfi: String?,
@ProtoNumber(7) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(8) override val blockIndex: Int = 0
) : SemanticTextBlock
@Serializable
data class SemanticListItem(
@ProtoNumber(1) override val text: String,
@ProtoNumber(2) override val spans: List<SemanticSpan>,
@ProtoNumber(3) override val style: CssStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(7) val itemMarkerImage: String?,
@ProtoNumber(8) override val blockIndex: Int = 0
) : SemanticTextBlock
@Serializable
data class SemanticList(
@ProtoNumber(1) val items: List<SemanticListItem>,
@ProtoNumber(2) val isOrdered: Boolean,
@ProtoNumber(3) override val style: CssStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticImage(
@ProtoNumber(1) val path: String, // Will store the absolute path
@ProtoNumber(2) val altText: String?,
@ProtoNumber(3) val intrinsicWidth: Float?,
@ProtoNumber(4) val intrinsicHeight: Float?,
@ProtoNumber(5) override val style: CssStyle,
@ProtoNumber(6) override val elementId: String?,
@ProtoNumber(7) override val cfi: String?,
@ProtoNumber(8) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticMath(
@ProtoNumber(1) val svgContent: String?,
@ProtoNumber(2) val altText: String?,
@ProtoNumber(3) val svgWidth: String?,
@ProtoNumber(4) val svgHeight: String?,
@ProtoNumber(5) val svgViewBox: String?,
@ProtoNumber(6) val isFromMathJax: Boolean,
@ProtoNumber(7) override val style: CssStyle,
@ProtoNumber(8) override val elementId: String?,
@ProtoNumber(9) override val cfi: String?,
@ProtoNumber(10) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticSpacer(
@ProtoNumber(1) override val style: CssStyle,
@ProtoNumber(2) override val elementId: String?,
@ProtoNumber(3) override val cfi: String?,
@ProtoNumber(4) val isExplicitLineBreak: Boolean = false,
@ProtoNumber(5) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticTableCell(
@ProtoNumber(1) val content: List<SemanticBlock>,
@ProtoNumber(2) val isHeader: Boolean,
@ProtoNumber(3) val colspan: Int,
@ProtoNumber(4) val style: CssStyle
)
@Serializable
data class SemanticTable(
@ProtoNumber(1) val rows: List<List<SemanticTableCell>>,
@ProtoNumber(2) override val style: CssStyle,
@ProtoNumber(3) override val elementId: String?,
@ProtoNumber(4) override val cfi: String?,
@ProtoNumber(5) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticFlexContainer(
@ProtoNumber(1) val children: List<SemanticBlock>,
@ProtoNumber(2) override val style: CssStyle,
@ProtoNumber(3) override val elementId: String?,
@ProtoNumber(4) override val cfi: String?,
@ProtoNumber(5) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticWrappingBlock(
@ProtoNumber(1) val floatedImage: SemanticImage,
@ProtoNumber(2) val paragraphsToWrap: List<SemanticParagraph>,
@ProtoNumber(3) override val style: CssStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) override val blockIndex: Int = 0
) : SemanticBlock
val semanticBlockModule = SerializersModule {
polymorphic(SemanticBlock::class) {
subclass(SemanticParagraph::class)
subclass(SemanticHeader::class)
subclass(SemanticListItem::class)
subclass(SemanticList::class)
subclass(SemanticImage::class)
subclass(SemanticMath::class)
subclass(SemanticSpacer::class)
subclass(SemanticTable::class)
subclass(SemanticFlexContainer::class)
subclass(SemanticWrappingBlock::class)
}
}

View file

@ -1,99 +0,0 @@
/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
package com.aryan.reader.paginatedreader
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
fun parseCssDimensionToTextUnit(
value: String,
containerWidthPx: Int,
density: Float
): TextUnit {
if (density <= 0) return TextUnit.Unspecified
val sanitizedValue = value.trim().lowercase()
return when {
sanitizedValue.endsWith("rem") -> sanitizedValue.removeSuffix("rem").toFloatOrNull()?.em ?: TextUnit.Unspecified
sanitizedValue.endsWith("em") -> sanitizedValue.removeSuffix("em").toFloatOrNull()?.em ?: TextUnit.Unspecified
sanitizedValue.endsWith("px") -> {
val px = sanitizedValue.removeSuffix("px").toFloatOrNull() ?: 0f
(px / density).sp
}
sanitizedValue.endsWith("pt") -> {
val pt = sanitizedValue.removeSuffix("pt").toFloatOrNull() ?: 0f
val px = pt * (4f / 3f)
(px / density).sp
}
sanitizedValue.endsWith("%") -> {
val percentage = sanitizedValue.removeSuffix("%").toFloatOrNull() ?: 0f
if (containerWidthPx > 0) {
val px = (percentage / 100f) * containerWidthPx
(px / density).sp
} else {
TextUnit.Unspecified
}
}
else -> TextUnit.Unspecified
}
}
fun parseCssSizeToDp(
value: String,
baseFontSizeSp: Float,
density: Float,
containerWidthPx: Int
): Dp {
if (density <= 0) return 0.dp
val sanitizedValue = value.trim().lowercase()
return when {
sanitizedValue.endsWith("px") -> {
val px = sanitizedValue.removeSuffix("px").toFloatOrNull() ?: 0f
(px / density).dp
}
sanitizedValue.endsWith("rem") -> {
val rem = sanitizedValue.removeSuffix("rem").toFloatOrNull() ?: 0f
(rem * baseFontSizeSp).dp
}
sanitizedValue.endsWith("em") -> {
val em = sanitizedValue.removeSuffix("em").toFloatOrNull() ?: 0f
(em * baseFontSizeSp).dp
}
sanitizedValue.endsWith("pt") -> {
val pt = sanitizedValue.removeSuffix("pt").toFloatOrNull() ?: 0f
val px = pt * (4f / 3f)
(px / density).dp
}
sanitizedValue.endsWith("%") -> {
val percentage = sanitizedValue.removeSuffix("%").toFloatOrNull() ?: 0f
if (containerWidthPx > 0) {
val px = (percentage / 100f) * containerWidthPx
(px / density).dp
} else {
0.dp
}
}
else -> 0.dp
}
}

View file

@ -1,117 +0,0 @@
/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
package com.aryan.reader.paginatedreader
object UserAgentStylesheet {
val default: String = """
/* Basic inline formatting */
b, strong {
font-weight: bold;
}
i, em, cite, dfn {
font-style: italic;
}
u {
text-decoration: underline;
}
s, strike, del {
text-decoration: line-through;
}
code, kbd, samp, tt, pre {
font-family: monospace;
}
/* Basic block elements */
h1 {
font-size: 2em;
font-weight: bold;
margin-top: 0.67em;
margin-bottom: 0.67em;
}
h2 {
font-size: 1.5em;
font-weight: bold;
margin-top: 0.83em;
margin-bottom: 0.83em;
}
h3 {
font-size: 1.17em;
font-weight: bold;
margin-top: 1em;
margin-bottom: 1em;
}
h4 {
font-size: 1em;
font-weight: bold;
margin-top: 1.33em;
margin-bottom: 1.33em;
}
h5 {
font-size: 0.83em;
font-weight: bold;
margin-top: 1.67em;
margin-bottom: 1.67em;
}
h6 {
font-size: 0.67em;
font-weight: bold;
margin-top: 2.33em;
margin-bottom: 2.33em;
}
p {
margin-top: 1em;
margin-bottom: 1em;
}
div {
margin-top: 0;
margin-bottom: 0;
}
blockquote {
margin-top: 1em;
margin-bottom: 1em;
margin-left: 40px;
margin-right: 40px;
}
dl {
margin-top: 1em;
margin-bottom: 1em;
}
dt {
font-weight: bold;
}
dd {
margin-left: 40px;
}
ul, ol {
margin-top: 1em;
margin-bottom: 1em;
padding-left: 40px;
}
li {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
hr {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
""".trimIndent()
}

View file

@ -198,7 +198,7 @@ abstract class BookCacheDao {
ConfigurationCache::class,
AnchorIndexEntry::class
],
version = 8,
version = 10,
exportSchema = false
)
abstract class BookCacheDatabase : RoomDatabase() {
@ -222,4 +222,4 @@ abstract class BookCacheDatabase : RoomDatabase() {
}
}
}
}
}

View file

@ -25,7 +25,7 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
const val LATEST_PROCESSING_VERSION = 8
const val LATEST_PROCESSING_VERSION = 10
@Entity(tableName = "processed_books")
data class ProcessedBook(
@ -130,4 +130,4 @@ data class ConfigurationCache(
val bookId: String,
val configHash: Int,
val chapterPageCounts: String
)
)

View file

@ -39,7 +39,7 @@ import com.aryan.reader.paginatedreader.FontFaceInfo
import com.aryan.reader.paginatedreader.MathMLRenderer
import com.aryan.reader.paginatedreader.OptimizedCssRules
import com.aryan.reader.paginatedreader.RenderResult
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
import com.aryan.reader.paginatedreader.androidHtmlToSemanticBlocks
import com.aryan.reader.paginatedreader.loadFontFamilies
import com.aryan.reader.paginatedreader.semanticBlockModule
import kotlinx.coroutines.Dispatchers
@ -277,7 +277,7 @@ class BookProcessingWorker(
Timber.d("Chapter $index (Background Worker): Processed HTML contains <math-placeholder>: ${processedHtml.contains("math-placeholder")}")
val semanticBlocks = htmlToSemanticBlocks(
val semanticBlocks = androidHtmlToSemanticBlocks(
html = processedHtml,
cssRules = lightThemeCssRules,
textStyle = textStyle,
@ -371,4 +371,4 @@ class BookProcessingWorker(
blocks.forEach { walk(it) }
return anchors
}
}
}

View file

@ -1,513 +0,0 @@
/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
@file:OptIn(ExperimentalSerializationApi::class)
package com.aryan.reader.paginatedreader.serialization
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.font.FontFamily
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import com.aryan.reader.paginatedreader.FontFamilyMapper
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
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 kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.encoding.encodeStructure
object ColorSerializer : KSerializer<Color> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Color") {
element<Long>("value")
}
override fun serialize(encoder: Encoder, value: Color) {
encoder.encodeStructure(descriptor) {
encodeLongElement(descriptor, 0, value.value.toLong())
}
}
override fun deserialize(decoder: Decoder): Color {
return decoder.decodeStructure(descriptor) {
var colorValue = 0L
while (true) {
when (val index = decodeElementIndex(descriptor)) {
0 -> colorValue = decodeLongElement(descriptor, 0)
-1 -> break
else -> error("Unexpected index: $index")
}
}
Color(colorValue.toULong())
}
}
}
object DpSerializer : KSerializer<Dp> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Dp") {
element<Float>("value")
}
override fun serialize(encoder: Encoder, value: Dp) {
encoder.encodeStructure(descriptor) {
if (value != Dp.Unspecified) {
encodeFloatElement(descriptor, 0, value.value)
}
}
}
override fun deserialize(decoder: Decoder): Dp {
return decoder.decodeStructure(descriptor) {
var dpValue: Float? = null
while (true) {
when (val index = decodeElementIndex(descriptor)) {
0 -> dpValue = decodeFloatElement(descriptor, 0)
-1 -> break
else -> error("Unexpected index: $index")
}
}
dpValue?.dp ?: Dp.Unspecified
}
}
}
object TextUnitTypeSerializer : KSerializer<TextUnitType> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TextUnitType")
override fun serialize(encoder: Encoder, value: TextUnitType) {
val typeString = when (value) {
TextUnitType.Sp -> "Sp"
TextUnitType.Em -> "Em"
else -> "Unspecified"
}
encoder.encodeString(typeString)
}
override fun deserialize(decoder: Decoder): TextUnitType {
return when (decoder.decodeString()) {
"Sp" -> TextUnitType.Sp
"Em" -> TextUnitType.Em
else -> TextUnitType.Unspecified
}
}
}
@Serializable
@SerialName("TextUnit")
private data class TextUnitSurrogate(val value: Float, @Serializable(with = TextUnitTypeSerializer::class) val type: TextUnitType)
object TextUnitSerializer : KSerializer<TextUnit> {
override val descriptor: SerialDescriptor = TextUnitSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: TextUnit) {
if (value.isSpecified) {
val surrogate = TextUnitSurrogate(value.value, value.type)
encoder.encodeSerializableValue(TextUnitSurrogate.serializer(), surrogate)
}
}
override fun deserialize(decoder: Decoder): TextUnit {
return try {
val surrogate = decoder.decodeSerializableValue(TextUnitSurrogate.serializer())
TextUnit(surrogate.value, surrogate.type)
} catch (_: Exception) {
TextUnit.Unspecified
}
}
}
object FontWeightSerializer : KSerializer<FontWeight?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("FontWeight")
override fun serialize(encoder: Encoder, value: FontWeight?) = value?.let { encoder.encodeInt(it.weight) } ?: encoder.encodeNull()
override fun deserialize(decoder: Decoder): FontWeight? = if (decoder.decodeNotNullMark()) FontWeight(decoder.decodeInt()) else null
}
object FontStyleSerializer : KSerializer<FontStyle?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("FontStyle")
override fun serialize(encoder: Encoder, value: FontStyle?) {
val intValue = when (value) {
FontStyle.Normal -> 0
FontStyle.Italic -> 1
else -> -1
}
encoder.encodeInt(intValue)
}
override fun deserialize(decoder: Decoder): FontStyle? {
return when (decoder.decodeInt()) {
0 -> FontStyle.Normal
1 -> FontStyle.Italic
else -> null
}
}
}
object BaselineShiftSerializer : KSerializer<BaselineShift?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("BaselineShift")
override fun serialize(encoder: Encoder, value: BaselineShift?) = value?.let { encoder.encodeFloat(it.multiplier) } ?: encoder.encodeNull()
override fun deserialize(decoder: Decoder): BaselineShift? = if (decoder.decodeNotNullMark()) BaselineShift(decoder.decodeFloat()) else null
}
object TextDecorationSerializer : KSerializer<TextDecoration?> {
@Serializable
private data class TextDecorationSurrogate(val hasUnderline: Boolean, val hasLineThrough: Boolean)
override val descriptor: SerialDescriptor = TextDecorationSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: TextDecoration?) {
if (value == null) {
encoder.encodeNull()
return
}
val surrogate = TextDecorationSurrogate(
hasUnderline = value.contains(TextDecoration.Underline),
hasLineThrough = value.contains(TextDecoration.LineThrough)
)
encoder.encodeSerializableValue(TextDecorationSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): TextDecoration? {
if (decoder.decodeNotNullMark()) {
val surrogate = decoder.decodeSerializableValue(TextDecorationSurrogate.serializer())
var decoration: TextDecoration? = null
if (surrogate.hasUnderline) {
decoration = TextDecoration.Underline
}
if (surrogate.hasLineThrough) {
decoration = (decoration ?: TextDecoration.None) + TextDecoration.LineThrough
}
return decoration
}
return null
}
}
@Serializable
private data class ShadowSurrogate(
@Serializable(with = ColorSerializer::class) val color: Color,
val offsetX: Float,
val offsetY: Float,
val blurRadius: Float
)
object ShadowSerializer : KSerializer<Shadow?> {
override val descriptor: SerialDescriptor = ShadowSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: Shadow?) {
if (value == null) {
encoder.encodeNull()
return
}
val surrogate = ShadowSurrogate(value.color, value.offset.x, value.offset.y, value.blurRadius)
encoder.encodeSerializableValue(ShadowSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): Shadow? {
if (decoder.decodeNotNullMark()) {
val surrogate = decoder.decodeSerializableValue(ShadowSurrogate.serializer())
return Shadow(surrogate.color, Offset(surrogate.offsetX, surrogate.offsetY), surrogate.blurRadius)
}
return null
}
}
@Serializable
private data class SpanStyleSurrogate(
@Serializable(with = ColorSerializer::class) val color: Color = Color.Unspecified,
@Serializable(with = TextUnitSerializer::class) val fontSize: TextUnit = TextUnit.Unspecified,
@Serializable(with = FontWeightSerializer::class) val fontWeight: FontWeight? = null,
@Serializable(with = FontStyleSerializer::class) val fontStyle: FontStyle? = null,
@Serializable(with = FontFamilySerializer::class) val fontFamily: FontFamily? = null,
val fontFeatureSettings: String? = null,
@Serializable(with = TextUnitSerializer::class) val letterSpacing: TextUnit = TextUnit.Unspecified,
@Serializable(with = BaselineShiftSerializer::class) val baselineShift: BaselineShift? = null,
@Serializable(with = TextDecorationSerializer::class) val textDecoration: TextDecoration? = null,
@Serializable(with = ColorSerializer::class) val background: Color = Color.Unspecified,
@Serializable(with = ShadowSerializer::class) val shadow: Shadow? = null
)
object SpanStyleSerializer : KSerializer<SpanStyle> {
override val descriptor: SerialDescriptor = SpanStyleSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: SpanStyle) {
val surrogate = SpanStyleSurrogate(
color = value.color,
fontSize = value.fontSize,
fontWeight = value.fontWeight,
fontStyle = value.fontStyle,
fontFamily = value.fontFamily,
fontFeatureSettings = value.fontFeatureSettings,
letterSpacing = value.letterSpacing,
baselineShift = value.baselineShift,
textDecoration = value.textDecoration,
background = value.background,
shadow = value.shadow
)
encoder.encodeSerializableValue(SpanStyleSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): SpanStyle {
val surrogate = decoder.decodeSerializableValue(SpanStyleSurrogate.serializer())
return SpanStyle(
color = surrogate.color,
fontSize = surrogate.fontSize,
fontWeight = surrogate.fontWeight,
fontStyle = surrogate.fontStyle,
fontFamily = surrogate.fontFamily,
fontFeatureSettings = surrogate.fontFeatureSettings,
letterSpacing = surrogate.letterSpacing,
baselineShift = surrogate.baselineShift,
textDecoration = surrogate.textDecoration,
background = surrogate.background,
shadow = surrogate.shadow
)
}
}
object TextAlignSerializer : KSerializer<TextAlign?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TextAlign")
override fun serialize(encoder: Encoder, value: TextAlign?) {
val intValue = when(value) {
TextAlign.Left -> 1
TextAlign.Right -> 2
TextAlign.Center -> 3
TextAlign.Justify -> 4
TextAlign.Start -> 5
TextAlign.End -> 6
else -> 0 // null or unspecified
}
encoder.encodeInt(intValue)
}
override fun deserialize(decoder: Decoder): TextAlign? {
return when(decoder.decodeInt()) {
1 -> TextAlign.Left
2 -> TextAlign.Right
3 -> TextAlign.Center
4 -> TextAlign.Justify
5 -> TextAlign.Start
6 -> TextAlign.End
else -> null
}
}
}
object TextDirectionSerializer : KSerializer<TextDirection?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TextDirection")
override fun serialize(encoder: Encoder, value: TextDirection?) {
val intValue = when(value) {
TextDirection.Ltr -> 1
TextDirection.Rtl -> 2
TextDirection.Content -> 3
TextDirection.ContentOrLtr -> 4
TextDirection.ContentOrRtl -> 5
else -> 0 // null
}
encoder.encodeInt(intValue)
}
override fun deserialize(decoder: Decoder): TextDirection? {
return when(decoder.decodeInt()) {
1 -> TextDirection.Ltr
2 -> TextDirection.Rtl
3 -> TextDirection.Content
4 -> TextDirection.ContentOrLtr
5 -> TextDirection.ContentOrRtl
else -> null
}
}
}
object LineBreakSerializer : KSerializer<LineBreak?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("LineBreak")
override fun serialize(encoder: Encoder, value: LineBreak?) {
val intValue = when (value) {
LineBreak.Simple -> 1
LineBreak.Paragraph -> 2
else -> 0
}
encoder.encodeInt(intValue)
}
override fun deserialize(decoder: Decoder): LineBreak? {
return when(decoder.decodeInt()) {
1 -> LineBreak.Simple
2 -> LineBreak.Paragraph
else -> null
}
}
}
object HyphensSerializer : KSerializer<Hyphens?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Hyphens")
override fun serialize(encoder: Encoder, value: Hyphens?) {
val intValue = when(value) {
Hyphens.None -> 1
Hyphens.Auto -> 2
else -> 0 // null or unspecified
}
encoder.encodeInt(intValue)
}
override fun deserialize(decoder: Decoder): Hyphens? {
return when(decoder.decodeInt()) {
1 -> Hyphens.None
2 -> Hyphens.Auto
else -> null
}
}
}
@Serializable
private data class TextIndentSurrogate(
@Serializable(with = TextUnitSerializer::class) val firstLine: TextUnit,
@Serializable(with = TextUnitSerializer::class) val restLine: TextUnit
)
object TextIndentSerializer : KSerializer<TextIndent?> {
override val descriptor: SerialDescriptor = TextIndentSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: TextIndent?) {
if (value == null) {
encoder.encodeNull()
} else {
encoder.encodeSerializableValue(TextIndentSurrogate.serializer(), TextIndentSurrogate(value.firstLine, value.restLine))
}
}
override fun deserialize(decoder: Decoder): TextIndent? {
return if (decoder.decodeNotNullMark()) {
val surrogate = decoder.decodeSerializableValue(TextIndentSurrogate.serializer())
TextIndent(surrogate.firstLine, surrogate.restLine)
} else {
null
}
}
}
@Serializable
private data class ParagraphStyleSurrogate(
@Serializable(with = TextAlignSerializer::class) val textAlign: TextAlign? = null,
@Serializable(with = TextDirectionSerializer::class) val textDirection: TextDirection? = null,
@Serializable(with = TextUnitSerializer::class) val lineHeight: TextUnit = TextUnit.Unspecified,
@Serializable(with = TextIndentSerializer::class) val textIndent: TextIndent? = null,
@Serializable(with = LineBreakSerializer::class) val lineBreak: LineBreak? = null,
@Serializable(with = HyphensSerializer::class) val hyphens: Hyphens? = null
)
object ParagraphStyleSerializer : KSerializer<ParagraphStyle> {
override val descriptor: SerialDescriptor = ParagraphStyleSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: ParagraphStyle) {
val surrogate = ParagraphStyleSurrogate(
textAlign = value.textAlign,
textDirection = value.textDirection,
lineHeight = value.lineHeight,
textIndent = value.textIndent,
lineBreak = value.lineBreak,
hyphens = value.hyphens
)
encoder.encodeSerializableValue(ParagraphStyleSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): ParagraphStyle {
val surrogate = decoder.decodeSerializableValue(ParagraphStyleSurrogate.serializer())
return ParagraphStyle(
textAlign = surrogate.textAlign ?: TextAlign.Unspecified,
textDirection = surrogate.textDirection ?: TextDirection.Unspecified,
lineHeight = surrogate.lineHeight,
textIndent = surrogate.textIndent,
lineBreak = surrogate.lineBreak ?: LineBreak.Unspecified,
hyphens = surrogate.hyphens ?: Hyphens.Unspecified
)
}
}
object AnnotatedStringSerializer : KSerializer<AnnotatedString> {
@Serializable
private data class RangeSurrogate<T>(val item: T, val start: Int, val end: Int, val tag: String)
@Serializable
private data class AnnotatedStringSurrogate(
val text: String,
val spanStyles: List<RangeSurrogate<@Serializable(with = SpanStyleSerializer::class) SpanStyle>>,
val paragraphStyles: List<RangeSurrogate<@Serializable(with = ParagraphStyleSerializer::class) ParagraphStyle>>,
val stringAnnotations: List<RangeSurrogate<String>>
)
override val descriptor: SerialDescriptor = AnnotatedStringSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: AnnotatedString) {
val surrogate = AnnotatedStringSurrogate(
text = value.text,
spanStyles = value.spanStyles.map { RangeSurrogate(it.item, it.start, it.end, it.tag) },
paragraphStyles = value.paragraphStyles.map { RangeSurrogate(it.item, it.start, it.end, it.tag) },
stringAnnotations = value.getStringAnnotations(0, value.length).map { RangeSurrogate(it.item, it.start, it.end, it.tag) }
)
encoder.encodeSerializableValue(AnnotatedStringSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): AnnotatedString {
val surrogate = decoder.decodeSerializableValue(AnnotatedStringSurrogate.serializer())
return AnnotatedString.Builder(surrogate.text).apply {
surrogate.spanStyles.forEach { addStyle(it.item, it.start, it.end) }
surrogate.paragraphStyles.forEach { addStyle(it.item, it.start, it.end) }
surrogate.stringAnnotations.forEach { addStringAnnotation(it.tag, it.item, it.start, it.end) }
}.toAnnotatedString()
}
}
object FontFamilySerializer : KSerializer<FontFamily?> {
override val descriptor = PrimitiveSerialDescriptor("FontFamily", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: FontFamily?) {
val name = FontFamilyMapper.fontFamilyToName(value ?: return encoder.encodeNull())
if (name != null) {
encoder.encodeString(name)
} else {
encoder.encodeNull()
}
}
override fun deserialize(decoder: Decoder): FontFamily? {
if (decoder.decodeNotNullMark()) {
return FontFamilyMapper.nameToFontFamily(decoder.decodeString())
}
return null
}
}

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlin.math.max
import kotlin.math.roundToInt
@Composable
@ -68,6 +69,9 @@ fun MagnifierComposable(
Canvas(modifier = Modifier.fillMaxSize()) {
val magnifierWidthPx = size.width
val magnifierHeightPx = size.height
if (magnifierWidthPx <= 0f || magnifierHeightPx <= 0f || zoomFactor <= 0f) {
return@Canvas
}
Timber.d("Magnifier: START. scale=$currentScale, centerOnBitmap=$magnifierCenterOnBitmap")
@ -109,8 +113,10 @@ fun MagnifierComposable(
val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f)
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
val clampedSrcLeft = srcLeft.coerceIn(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
val clampedSrcTop = srcTop.coerceIn(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
val maxSrcLeft = max(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
val maxSrcTop = max(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
@ -174,8 +180,10 @@ fun MagnifierComposable(
val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f)
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
val clampedSrcLeft = srcLeft.coerceIn(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
val clampedSrcTop = srcTop.coerceIn(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
val maxSrcLeft = max(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
val maxSrcTop = max(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
@ -226,4 +234,4 @@ fun MagnifierComposable(
}
}
}
}
}

View file

@ -1,5 +1,7 @@
package com.aryan.reader.pdf
import com.aryan.reader.shared.pdf.PdfiumAnnotationSubtype
object NativePdfiumBridge {
init {
System.loadLibrary("native-lib")
@ -31,9 +33,9 @@ object NativePdfiumBridge {
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
@JvmStatic external fun checkActionSupport(): Boolean
const val ANNOT_TEXT = 1 // Sticky Note
const val ANNOT_LINK = 2 // Link
const val ANNOT_HIGHLIGHT = 8 // Highlight
const val ANNOT_INK = 12 // Freehand drawing
const val ANNOT_WIDGET = 19
}
const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT
const val ANNOT_LINK = PdfiumAnnotationSubtype.LINK
const val ANNOT_HIGHLIGHT = PdfiumAnnotationSubtype.HIGHLIGHT
const val ANNOT_INK = PdfiumAnnotationSubtype.INK
const val ANNOT_WIDGET = PdfiumAnnotationSubtype.WIDGET
}

View file

@ -84,7 +84,9 @@ fun VerticalScrollbar(
if (viewportRatio >= 1f) return@derivedStateOf null
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
val maxThumbHeight = viewportHeight / 2f
val minThumbHeight = minOf(80f, maxThumbHeight)
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight)
val firstItemIndex = listState.firstVisibleItemIndex
val firstItemOffset = listState.firstVisibleItemScrollOffset

View file

@ -69,9 +69,13 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
@ -119,6 +123,7 @@ import androidx.core.graphics.scale
import androidx.core.graphics.set
import com.aryan.reader.R
import com.aryan.reader.SearchResult
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
@ -127,6 +132,7 @@ import com.aryan.reader.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
@ -180,6 +186,13 @@ data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
private const val PDF_TILE_SIZE_DP = 256
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
private const val PDF_TILE_SCALE_TOLERANCE = 0.06f
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
private const val PDF_PAGINATION_PAN_FLING_MULTIPLIER = 0.72f
enum class LinkSource {
ANNOTATION, TEXT_CONTENT
}
@ -426,7 +439,10 @@ data class PageStaticData(
val colorFilter: StableHolder<ColorFilter?>,
val isDarkMode: Boolean,
val excludeImages: Boolean,
val imageRects: StableHolder<List<android.graphics.Rect>>
val imageRects: StableHolder<List<android.graphics.Rect>>,
val textureBitmap: StableHolder<ImageBitmap?>,
val textureAlpha: Float,
val textureBlendMode: BlendMode
)
@Stable
@ -497,6 +513,7 @@ internal fun PdfPageComposable(
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
activeTextureAlpha: Float = 0.55f,
excludeImages: Boolean = false,
onDoubleTap: ((Offset) -> Unit)? = null,
isEditMode: Boolean = false,
@ -552,7 +569,7 @@ internal fun PdfPageComposable(
var isLoadingPage by remember { mutableStateOf(true) }
var pageErrorMessage by remember { mutableStateOf<String?>(null) }
val density = LocalDensity.current
LocalContext.current
val context = LocalContext.current
val viewConfiguration = LocalViewConfiguration.current
val coroutineScope = rememberCoroutineScope()
var isStylusEraserOverride by remember { mutableStateOf(false) }
@ -564,6 +581,7 @@ internal fun PdfPageComposable(
var isTransforming by remember { mutableStateOf(false) }
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var paginationPanFlingJob by remember { mutableStateOf<Job?>(null) }
LaunchedEffect(scale, offset) {
onZoomAndPanChanged?.invoke(scale, offset)
@ -590,8 +608,10 @@ internal fun PdfPageComposable(
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
var tiles by remember { mutableStateOf<List<PdfTile>>(emptyList()) }
val tileSizeDp = 256.dp
val tileSizeDp = PDF_TILE_SIZE_DP.dp
val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
val latestEffectiveScale by rememberUpdatedState(effectiveScale)
val latestEffectiveOffset by rememberUpdatedState(effectiveOffset)
SideEffect {
Timber.tag("PdfDrawPerf")
@ -633,6 +653,8 @@ internal fun PdfPageComposable(
var actualBitmapHeightPx by remember { mutableIntStateOf(0) }
var currentPageRotation by remember { mutableIntStateOf(0) }
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
val canvasWidthPx = remember { mutableFloatStateOf(0f) }
val canvasHeightPx = remember { mutableFloatStateOf(0f) }
@ -687,6 +709,15 @@ internal fun PdfPageComposable(
activeTheme.backgroundColor
}
}
val textureBitmap = remember(activeTheme.textureId) {
loadReaderTextureBitmap(context, activeTheme.textureId)
}
val effectiveTextureAlpha = remember(activeTheme.textureId, activeTextureAlpha) {
if (activeTheme.textureId == null) 0f else activeTextureAlpha.coerceIn(0f, 1f)
}
val textureBlendMode = remember(activeTheme.textureId, activeTheme.isDark, activeTheme.id) {
if (activeTheme.isDark || activeTheme.id == "reverse") BlendMode.Screen else BlendMode.Multiply
}
val centeringOffsetX by remember(canvasWidthPx.floatValue, actualBitmapWidthPx) {
derivedStateOf { (canvasWidthPx.floatValue - actualBitmapWidthPx) / 2f }
@ -1146,13 +1177,13 @@ internal fun PdfPageComposable(
try {
val pagePtr = pageWrapper.getNativePointer()
if (pagePtr != 0L) {
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr)
val imgRects = mutableListOf<android.graphics.Rect>()
val outRect = FloatArray(4)
for (i in 0 until objCount) {
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, outRect)) {
if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, outRect)) {
val pdfRectF = android.graphics.RectF(
min(outRect[0], outRect[2]),
max(outRect[1], outRect[3]),
@ -1180,26 +1211,26 @@ internal fun PdfPageComposable(
val pagePtr = pageWrapper.getNativePointer()
if (pagePtr != 0L) {
val count = NativePdfiumBridge.getAnnotCount(pagePtr)
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
if (count > 0) {
val count = NativePdfiumBridge.getAnnotCount(pagePtr)
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
if (count > 0) {
val allAnnots = (0 until count).mapNotNull { i ->
val subtype = NativePdfiumBridge.getAnnotSubtype(pagePtr, i)
val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
if (subtype == annotLink) return@mapNotNull null
var contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "Contents")
var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
if (contents.isNullOrBlank()) {
contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "RC")
contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
}
val name = NativePdfiumBridge.getAnnotString(pagePtr, i, "NM")
val irt = NativePdfiumBridge.getAnnotString(pagePtr, i, "IRT")
val author = NativePdfiumBridge.getAnnotString(pagePtr, i, "T")
val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
val pdfRectArray = NativePdfiumBridge.getAnnotRect(pagePtr, i)
val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
val pdfRectF = if (pdfRectArray != null) {
android.graphics.RectF(
min(pdfRectArray[0], pdfRectArray[2]),
@ -1311,8 +1342,7 @@ internal fun PdfPageComposable(
}
LaunchedEffect(
effectiveScale,
effectiveOffset,
needsTilingNow,
actualBitmapWidthPx,
actualBitmapHeightPx,
canvasWidthPx.floatValue,
@ -1323,8 +1353,7 @@ internal fun PdfPageComposable(
virtualPage,
isActivePage
) {
val needsTiling = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
if (!needsTiling) {
if (!needsTilingNow) {
if (tiles.isNotEmpty()) {
val oldTiles = tiles
tiles = emptyList()
@ -1358,24 +1387,37 @@ internal fun PdfPageComposable(
snapshotFlow {
val rect = visibleScreenRect()
if (rect == null) null
else {
val observedScale = latestEffectiveScale
if (isVerticalScroll && rect != null) {
val qTop = rect.top / (tileSizePx / 2)
val qLeft = rect.left / (tileSizePx / 2)
val qBottom = rect.bottom / (tileSizePx / 2)
val qRight = rect.right / (tileSizePx / 2)
listOf(qTop, qLeft, qBottom, qRight)
listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt())
} else if (!isVerticalScroll) {
val observedOffset = latestEffectiveOffset
val pivotX = screenWidth / 2f
val pivotY = screenHeight / 2f
val pxTl = (((0 - observedOffset.x) - pivotX) / observedScale + pivotX) - centeringOffsetX
val pyTl = (((0 - observedOffset.y) - pivotY) / observedScale + pivotY) - centeringOffsetY
val pxBr = (((screenWidth - observedOffset.x) - pivotX) / observedScale + pivotX) - centeringOffsetX
val pyBr = (((screenHeight - observedOffset.y) - pivotY) / observedScale + pivotY) - centeringOffsetY
val qTop = pyTl.toInt() / (tileSizePx / 2)
val qLeft = pxTl.toInt() / (tileSizePx / 2)
val qBottom = pyBr.toInt() / (tileSizePx / 2)
val qRight = pxBr.toInt() / (tileSizePx / 2)
listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt())
} else {
null
}
}.conflate().collectLatest { _ ->
delay(150)
val tileCalcStart = System.nanoTime()
if (!isActive) return@collectLatest
if (isScrolling && effectiveScale > 1f) {
return@collectLatest
}
val renderScale = latestEffectiveScale
val renderOffset = latestEffectiveOffset
val currentVisibleRect = visibleScreenRect()
@ -1404,14 +1446,14 @@ internal fun PdfPageComposable(
val pivotX = screenWidth / 2f
val pivotY = screenHeight / 2f
pxTl = (((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
pyTl = (((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
pxBr = (((screenWidth - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
pyBr = (((screenHeight - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
pxTl = (((0 - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX
pyTl = (((0 - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY
pxBr = (((screenWidth - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX
pyBr = (((screenHeight - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY
}
val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt())
val inset = if (effectiveScale > 2f) 0 else -tileSizePx
val inset = if (renderScale > 2f) 0 else -tileSizePx
visibleBitmapRect.inset(inset, inset)
val requiredTileIds = mutableSetOf<Int>()
@ -1431,8 +1473,10 @@ internal fun PdfPageComposable(
val currentTileIds = tiles.map { it.tileId }.toSet()
val scaleTolerance = 0.05f
val validCurrentTileIds = tiles.filter { abs(it.renderScale - effectiveScale) <= scaleTolerance }.map { it.tileId }.toSet()
val scaleTolerance = PDF_TILE_SCALE_TOLERANCE
val validCurrentTileIds = tiles.filter { abs(it.renderScale - renderScale) <= scaleTolerance }.map { it.tileId }.toSet()
val tilesToRenderIds = requiredTileIds - validCurrentTileIds
val tilesToRecycleIds = currentTileIds - requiredTileIds
val duration = (System.nanoTime() - tileCalcStart) / 1_000_000f
if (duration > 2f) {
@ -1441,21 +1485,26 @@ internal fun PdfPageComposable(
)
}
if (requiredTileIds != validCurrentTileIds) {
val tilesToRenderIds = requiredTileIds - validCurrentTileIds
val tilesToRecycleIds = currentTileIds - requiredTileIds
if (tilesToRecycleIds.isNotEmpty()) {
val (tilesToRecycle, tilesToKeep) = tiles.partition { it.tileId in tilesToRecycleIds }
tiles = tilesToKeep
withContext(Dispatchers.IO) {
tilesToRecycle.forEach { PdfBitmapPool.recycle(it.bitmap) }
}
if (tilesToRecycleIds.isNotEmpty()) {
val (tilesToRecycle, tilesToKeep) = tiles.partition { it.tileId in tilesToRecycleIds }
tiles = tilesToKeep
withContext(Dispatchers.IO) {
tilesToRecycle.forEach { PdfBitmapPool.recycle(it.bitmap) }
}
}
if (isScrolling && renderScale > 1f) {
return@collectLatest
}
if (requiredTileIds != validCurrentTileIds) {
if (tilesToRenderIds.isNotEmpty()) {
withContext(Dispatchers.IO) {
delay(PDF_TILE_IDLE_RENDER_DELAY_MS)
if (!isActive) return@collectLatest
if (isScrolling && latestEffectiveScale > 1f) return@collectLatest
val renderedTiles = withContext(Dispatchers.IO) {
val newTiles = mutableListOf<PdfTile>()
tilesToRenderIds.forEach { tileId ->
if (!isActive) return@forEach
@ -1469,14 +1518,18 @@ internal fun PdfPageComposable(
(col + 1) * tileSizePx,
(row + 1) * tileSizePx
)
val tileRenderSize = (tileSizePx * effectiveScale).toInt().coerceAtLeast(1)
val tileRenderScale = min(
renderScale,
PDF_MAX_TILE_BITMAP_SIZE_PX.toFloat() / tileSizePx.toFloat()
)
val tileRenderSize = (tileSizePx * tileRenderScale).toInt().coerceAtLeast(1)
val tileBitmap = PdfBitmapPool.get(tileRenderSize)
val fullPageRenderWidth = (actualBitmapWidthPx * effectiveScale).toInt()
val fullPageRenderHeight = (actualBitmapHeightPx * effectiveScale).toInt()
val tileRenderX = (col * tileSizePx * effectiveScale).toInt()
val tileRenderY = (row * tileSizePx * effectiveScale).toInt()
val fullPageRenderWidth = (actualBitmapWidthPx * tileRenderScale).toInt()
val fullPageRenderHeight = (actualBitmapHeightPx * tileRenderScale).toInt()
val tileRenderX = (col * tileSizePx * tileRenderScale).toInt()
val tileRenderY = (row * tileSizePx * tileRenderScale).toInt()
page?.renderPageBitmap(
bitmap = tileBitmap,
@ -1487,24 +1540,26 @@ internal fun PdfPageComposable(
renderAnnot = true
)
val newTile = PdfTile(tileBitmap, tileRect, tileId, effectiveScale)
var handedOver = false
try {
withContext(Dispatchers.Main) {
val oldTile = tiles.find { it.tileId == tileId }
tiles = tiles.filter { it.tileId != tileId } + newTile
handedOver = true
newTiles += PdfTile(tileBitmap, tileRect, tileId, renderScale)
}
newTiles
}
oldTile?.let {
coroutineScope.launch(Dispatchers.IO) {
PdfBitmapPool.recycle(it.bitmap)
}
}
}
} finally {
if (!handedOver) {
PdfBitmapPool.recycle(tileBitmap)
}
if (!isActive) {
withContext(Dispatchers.IO) {
renderedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
}
return@collectLatest
}
if (renderedTiles.isNotEmpty()) {
val renderedIds = renderedTiles.map { it.tileId }.toSet()
val replacedTiles = tiles.filter { it.tileId in renderedIds }
tiles = tiles.filterNot { it.tileId in renderedIds } + renderedTiles
if (replacedTiles.isNotEmpty()) {
coroutineScope.launch(Dispatchers.IO) {
replacedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
}
}
}
@ -2799,7 +2854,7 @@ internal fun PdfPageComposable(
Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr")
val linkInfo = NativePdfiumBridge.getLinkInfoAtPoint(
val linkInfo = PdfiumEngineProvider.bridge.getLinkInfoAtPoint(
docPtr, pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()
)
@ -2818,7 +2873,7 @@ internal fun PdfPageComposable(
}
}
val clickHandled = NativePdfiumBridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble())
val clickHandled = PdfiumEngineProvider.bridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble())
if (clickHandled) {
return@withContext 2
}
@ -3007,25 +3062,21 @@ internal fun PdfPageComposable(
awaitEachGesture {
@Suppress("UnusedVariable", "Unused") val down =
awaitFirstDown(requireUnconsumed = false)
paginationPanFlingJob?.cancel()
paginationPanFlingJob = null
velocityTracker.resetTracking()
var mode = 0
var accumulatedZoom = 1f
var accumulatedPan = Offset.Zero
var swipeAccumulatorX = 0f
var velocityAccumulator = Offset.Zero
do {
val event = awaitPointerEvent()
val canceled = event.changes.any { it.isConsumed }
val pointerCount = event.changes.size
val currentCentroid = event.calculateCentroid(useCurrent = true)
if (pointerCount > 0 && currentCentroid != Offset.Unspecified) {
velocityTracker.addPosition(
event.changes[0].uptimeMillis, currentCentroid
)
}
if (!canceled) {
val rawPanChange = event.calculatePan()
val panChange = if (isScrollLocked && pointerCount == 1) {
@ -3077,6 +3128,14 @@ internal fun PdfPageComposable(
Timber.tag("PdfZoomDebug").v("Panning: Offset $offset -> $newX, $newY (Max: $maxOffsetX, $maxOffsetY)")
offset = Offset(newX, newY)
if (event.changes.isNotEmpty() && panChange != Offset.Zero) {
velocityAccumulator += panChange
velocityTracker.addPosition(
event.changes[0].uptimeMillis,
velocityAccumulator
)
}
event.changes.forEach {
if (it.positionChanged()) it.consume()
}
@ -3189,38 +3248,54 @@ internal fun PdfPageComposable(
}
}
} else if (mode == 1 && scale > 1f) {
val velocity = velocityTracker.calculateVelocity()
val contentWidth = actualBitmapWidthPx * scale
val contentHeight = actualBitmapHeightPx * scale
val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f
val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f
val startX = offset.x
val startY = offset.y
val velocity = velocityTracker.calculateVelocity()
val flingX = if (!isScrollLocked && abs(velocity.x) > PDF_PAGINATION_PAN_FLING_MIN_VELOCITY) {
velocity.x * PDF_PAGINATION_PAN_FLING_MULTIPLIER
} else {
0f
}
val flingY = if (abs(velocity.y) > PDF_PAGINATION_PAN_FLING_MIN_VELOCITY) {
velocity.y * PDF_PAGINATION_PAN_FLING_MULTIPLIER
} else {
0f
}
coroutineScope.launch {
coroutineScope {
launch {
if (!isScrollLocked) {
Animatable(startX).animateDecay(
velocity.x, decay
) {
val newX = value.coerceIn(
-maxOffsetX, maxOffsetX
)
offset = offset.copy(x = newX)
if (flingX == 0f && flingY == 0f) {
offset = Offset(
x = offset.x.coerceIn(-maxOffsetX, maxOffsetX),
y = offset.y.coerceIn(-maxOffsetY, maxOffsetY)
)
} else {
val startOffset = offset
paginationPanFlingJob = coroutineScope.launch {
try {
coroutineScope {
launch {
if (flingX != 0f) {
Animatable(startOffset.x).animateDecay(flingX, decay) {
offset = offset.copy(
x = value.coerceIn(-maxOffsetX, maxOffsetX)
)
}
}
}
launch {
if (flingY != 0f) {
Animatable(startOffset.y).animateDecay(flingY, decay) {
offset = offset.copy(
y = value.coerceIn(-maxOffsetY, maxOffsetY)
)
}
}
}
}
}
launch {
Animatable(startY).animateDecay(
velocity.y, decay
) {
val newY = value.coerceIn(
-maxOffsetY, maxOffsetY
)
offset = offset.copy(y = newY)
}
} finally {
paginationPanFlingJob = null
}
}
}
@ -3256,7 +3331,7 @@ internal fun PdfPageComposable(
}
val buttons = currentEvent.buttons
Timber.tag("StylusEraserDiagnostic").d(
Timber.tag("StylusDebug").d(
"Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
)
@ -3881,7 +3956,10 @@ internal fun PdfPageComposable(
stableColorFilter,
isDarkMode,
excludeImages,
stableImageRects
stableImageRects,
textureBitmap,
effectiveTextureAlpha,
textureBlendMode
) {
Timber.tag("PdfDrawPerf").v(
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
@ -3899,7 +3977,10 @@ internal fun PdfPageComposable(
colorFilter = stableColorFilter,
isDarkMode = isDarkMode,
excludeImages = excludeImages,
imageRects = stableImageRects
imageRects = stableImageRects,
textureBitmap = StableHolder(textureBitmap),
textureAlpha = effectiveTextureAlpha,
textureBlendMode = textureBlendMode
)
}
@ -4263,7 +4344,10 @@ private fun PdfBitmapLayer(
colorFilter: ColorFilter? = null,
isDarkMode: Boolean = false,
excludeImages: Boolean = false,
imageRects: List<android.graphics.Rect> = emptyList()
imageRects: List<android.graphics.Rect> = emptyList(),
textureBitmap: ImageBitmap? = null,
textureAlpha: Float = 0f,
textureBlendMode: BlendMode = BlendMode.Multiply
) {
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
translate(left = centeringOffsetX, top = centeringOffsetY) {
@ -4363,6 +4447,15 @@ private fun PdfBitmapLayer(
}
}
}
if (textureBitmap != null && textureAlpha > 0f) {
drawRect(
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
size = Size(dstW.toFloat(), dstH.toFloat()),
blendMode = textureBlendMode,
alpha = textureAlpha
)
}
}
}
}
@ -4884,7 +4977,10 @@ private fun PdfPageStaticLayer(data: PageStaticData) {
colorFilter = data.colorFilter.item,
isDarkMode = data.isDarkMode,
excludeImages = data.excludeImages,
imageRects = data.imageRects.item
imageRects = data.imageRects.item,
textureBitmap = data.textureBitmap.item,
textureAlpha = data.textureAlpha,
textureBlendMode = data.textureBlendMode
)
}

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.core.content.edit
import com.aryan.reader.BuildConfig
import com.aryan.reader.ReaderTheme
import com.aryan.reader.ReaderTexture
import com.aryan.reader.epubreader.SystemUiMode
internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll"
@ -39,6 +40,8 @@ private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private const val PDF_THEME_KEY = "pdf_reader_theme"
private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
private const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
private const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
@ -76,7 +79,13 @@ val PdfBuiltInThemes = listOf(
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
ReaderTheme("pdf_natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
)
internal fun loadPdfHiddenTools(context: Context): Set<String> {
@ -89,6 +98,32 @@ internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) }
}
internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val savedTools = prefs.getString(PDF_TOOL_ORDER_KEY, null)
?.split(',')
?.filter { it.isNotBlank() }
?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
.orEmpty()
return (savedTools + PdfReaderTool.entries.filterNot { it in savedTools }).distinct()
}
internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PDF_TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
}
internal fun loadPdfBottomTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val defaultBottomTools = PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
return prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools
}
internal fun savePdfBottomTools(context: Context, bottomTools: Set<String>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putStringSet(PDF_BOTTOM_TOOLS_KEY, bottomTools) }
}
internal fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return PdfHighlightColor.entries.associateWith {
@ -139,7 +174,7 @@ internal fun loadPdfThemeId(context: Context): String {
}
internal fun loadUseOnlineDict(context: Context): Boolean {
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) return false
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
}

View file

@ -1,8 +1,9 @@
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
@file:OptIn(ExperimentalMaterial3Api::class)
package com.aryan.reader.pdf
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -11,94 +12,346 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.zIndex
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.aryan.reader.R
import com.aryan.reader.epubreader.OptionSegmentedControl
import com.aryan.reader.epubreader.SystemUiMode
enum class PdfFlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
data class PdfFlatToolItem(
val id: String,
val type: PdfFlatItemType,
val tool: PdfReaderTool? = null,
val section: PdfToolbarSection? = null,
val title: String? = null
)
fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem> {
val result = mutableListOf<PdfFlatToolItem>()
val sectionMap = mutableMapOf<PdfToolbarSection, MutableList<PdfFlatToolItem>>()
PdfToolbarSection.entries.forEach { sectionMap[it] = mutableListOf() }
list.forEach { item ->
if (item.type == PdfFlatItemType.TOOL) {
item.section?.let { sectionMap[it]?.add(item) }
}
}
PdfToolbarSection.entries.forEach { section ->
result.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title))
val tools = sectionMap[section] ?: emptyList()
if (tools.isEmpty()) {
result.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
} else {
result.addAll(tools)
}
}
list.filter { it.type == PdfFlatItemType.MORE_HEADER || it.type == PdfFlatItemType.MORE_TOOL }.forEach {
result.add(it)
}
return result
}
class PdfDragDropState(
val lazyListState: LazyListState,
val onMove: (String, String) -> Unit
) {
var draggedItemId by mutableStateOf<String?>(null)
var dragOffset by mutableStateOf(Offset.Zero)
fun onDragStart(id: String) { draggedItemId = id; dragOffset = Offset.Zero }
fun onDrag(delta: Offset) {
val draggedId = draggedItemId ?: return
dragOffset += delta
val visibleItems = lazyListState.layoutInfo.visibleItemsInfo
val currentItem = visibleItems.find { it.key == draggedId } ?: return
val center = currentItem.offset + dragOffset.y + currentItem.size / 2f
val targetItem = visibleItems.find { it.key != draggedId && center >= it.offset && center <= (it.offset + it.size) }
if (targetItem != null) {
onMove(draggedId, targetItem.key.toString())
dragOffset = dragOffset.copy(y = dragOffset.y - (targetItem.offset - currentItem.offset))
}
}
fun onDragEnd() { draggedItemId = null; dragOffset = Offset.Zero }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PdfCustomizeToolsSheet(
hiddenTools: Set<String>,
toolOrder: List<PdfReaderTool>,
bottomTools: Set<String>,
onUpdate: (Set<String>) -> Unit,
onOrderUpdate: (List<PdfReaderTool>) -> Unit,
onPlacementUpdate: (Set<String>) -> Unit,
onDismiss: () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
contentWindowInsets = { WindowInsets.navigationBars }
) {
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
Text(
text = stringResource(R.string.title_customize_toolbar),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.desc_customize_toolbar),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
val reorderableToolbarTools = setOf(
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS
)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
PdfReaderTool.entries.groupBy { it.category }.forEach { (category, tools) ->
item {
Text(
text = category,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
var localHiddenTools by remember { mutableStateOf(hiddenTools) }
var flatItems by remember {
mutableStateOf<List<PdfFlatToolItem>>(
run {
val toolbarTools = toolOrder.filter { it in reorderableToolbarTools }
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
val moreTools = toolOrder.filter { it !in reorderableToolbarTools }
val list = mutableListOf<PdfFlatToolItem>()
PdfToolbarSection.entries.forEach { section ->
val tools = when(section) {
PdfToolbarSection.TOP -> topTools
PdfToolbarSection.BOTTOM -> bottomToolsList
PdfToolbarSection.HIDDEN -> hiddenToolsList
}
items(tools) { tool ->
Row(
list.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title))
if (tools.isEmpty()) {
list.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
} else {
tools.forEach { tool ->
list.add(PdfFlatToolItem("tool_${tool.name}", PdfFlatItemType.TOOL, tool = tool, section = section))
}
}
}
list.add(PdfFlatToolItem("more_header", PdfFlatItemType.MORE_HEADER, title = "More menu"))
moreTools.forEach { tool ->
list.add(PdfFlatToolItem("more_${tool.name}", PdfFlatItemType.MORE_TOOL, tool = tool))
}
list
}
)
}
val commitDragDrop = {
val newHidden = localHiddenTools.filter { toolName ->
toolOrder.find { it.name == toolName } !in reorderableToolbarTools
}.toMutableSet()
val newBottom = mutableSetOf<String>()
val newOrder = mutableListOf<PdfReaderTool>()
flatItems.forEach { item ->
if (item.type == PdfFlatItemType.TOOL && item.tool != null) {
newOrder.add(item.tool)
if (item.section == PdfToolbarSection.HIDDEN) newHidden.add(item.tool.name)
if (item.section == PdfToolbarSection.BOTTOM) newBottom.add(item.tool.name)
}
}
val moreTools = flatItems.filter { it.type == PdfFlatItemType.MORE_TOOL }.mapNotNull { it.tool }
newOrder.addAll(moreTools)
localHiddenTools = newHidden
onUpdate(newHidden)
onPlacementUpdate(newBottom)
onOrderUpdate(newOrder)
}
val lazyListState = rememberLazyListState()
val dragDropState = remember {
PdfDragDropState(lazyListState) { fromKey, toKey ->
val fromIndex = flatItems.indexOfFirst { it.id == fromKey }
val toIndex = flatItems.indexOfFirst { it.id == toKey }
if (fromIndex == -1 || toIndex == -1 || fromIndex == toIndex) return@PdfDragDropState
val fromItem = flatItems[fromIndex]
if (fromItem.type != PdfFlatItemType.TOOL) return@PdfDragDropState
val toItem = flatItems[toIndex]
if (toItem.type == PdfFlatItemType.MORE_HEADER || toItem.type == PdfFlatItemType.MORE_TOOL) return@PdfDragDropState
val newList = flatItems.toMutableList()
val movedItem = newList.removeAt(fromIndex)
val newToIndex = newList.indexOfFirst { it.id == toKey }
val insertIndex = if (fromIndex < toIndex) newToIndex + 1 else newToIndex
newList.add(insertIndex, movedItem)
var actualSection = movedItem.section
for (i in insertIndex downTo 0) {
val item = newList[i]
if (item.type == PdfFlatItemType.SECTION_HEADER) {
actualSection = item.section
break
}
}
newList[insertIndex] = movedItem.copy(section = actualSection)
flatItems = newList
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
androidx.compose.material3.Surface(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars),
color = MaterialTheme.colorScheme.surface
) {
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.title_customize_toolbar),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
}
}
LazyColumn(
state = lazyListState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp)
) {
items(flatItems, key = { it.id }) { item ->
val isDragged = item.id == dragDropState.draggedItemId
val zIndex = if (isDragged) 1f else 0f
val elevation = if (isDragged) 8.dp else 0.dp
val scale = if (isDragged) 1.03f else 1f
val translationY = if (isDragged) dragDropState.dragOffset.y else 0f
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable {
val newSet = hiddenTools.toMutableSet()
if (newSet.contains(tool.name)) newSet.remove(tool.name)
else newSet.add(tool.name)
onUpdate(newSet)
.then(if (isDragged) Modifier else Modifier.animateItem())
.zIndex(zIndex)
.graphicsLayer {
this.translationY = translationY
this.scaleX = scale
this.scaleY = scale
this.shadowElevation = elevation.toPx()
}
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = tool.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Switch(
checked = !hiddenTools.contains(tool.name),
onCheckedChange = { isVisible ->
val newSet = hiddenTools.toMutableSet()
if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name)
onUpdate(newSet)
when (item.type) {
PdfFlatItemType.SECTION_HEADER -> {
Text(
text = item.title ?: "",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp, start = 4.dp)
)
}
)
PdfFlatItemType.EMPTY_PLACEHOLDER -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(vertical = 4.dp)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
PdfFlatItemType.TOOL -> {
PdfToolbarDragRow(
tool = item.tool!!,
isDragging = isDragged,
onDragStart = { dragDropState.onDragStart(item.id) },
onDrag = { dragDropState.onDrag(it) },
onDragEnd = {
dragDropState.onDragEnd()
flatItems = sanitizePdfPlaceholders(flatItems).toList()
commitDragDrop()
}
)
}
PdfFlatItemType.MORE_HEADER -> {
Text(
text = item.title ?: "More menu",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
)
}
PdfFlatItemType.MORE_TOOL -> {
PdfMoreToolVisibilityRow(
title = item.tool!!.title,
visible = !localHiddenTools.contains(item.tool.name),
onToggle = {
localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
localHiddenTools - item.tool.name
} else {
localHiddenTools + item.tool.name
}
onUpdate(localHiddenTools)
}
)
}
}
}
}
}
@ -107,6 +360,154 @@ fun PdfCustomizeToolsSheet(
}
}
@Composable
private fun PdfToolbarDragRow(
tool: PdfReaderTool,
isDragging: Boolean,
onDragStart: () -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit
) {
androidx.compose.material3.Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
shape = RoundedCornerShape(12.dp),
color = if (isDragging) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
) {
Row(
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
PdfToolPreviewIcon(tool)
Spacer(Modifier.width(16.dp))
Text(
text = tool.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Icon(
Icons.Default.Menu,
contentDescription = "Drag to reorder",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(48.dp)
.padding(12.dp)
.pointerInput(tool) {
detectDragGestures(
onDragStart = { onDragStart() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount)
},
onDragEnd = onDragEnd,
onDragCancel = onDragEnd
)
}
)
}
}
}
@Composable
private fun PdfToolbarDragRow(
tool: PdfReaderTool,
isDragging: Boolean,
onBounds: (Rect) -> Unit,
onDragStart: (Offset) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit
) {
var bounds by remember { mutableStateOf<Rect?>(null) }
androidx.compose.material3.Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.onGloballyPositioned {
bounds = it.boundsInWindow()
onBounds(it.boundsInWindow())
}
.pointerInput(tool) {
detectDragGesturesAfterLongPress(
onDragStart = { onDragStart(bounds?.center ?: Offset.Zero) },
onDragEnd = onDragEnd,
onDragCancel = onDragEnd,
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount)
}
)
},
shape = RoundedCornerShape(12.dp),
color = if (isDragging) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
PdfToolPreviewIcon(tool)
Spacer(Modifier.width(12.dp))
Text(
text = tool.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Icon(Icons.Default.Menu, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun PdfMoreToolVisibilityRow(
title: String,
visible: Boolean,
onToggle: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onToggle)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
if (visible) {
Icon(Icons.Default.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
}
}
}
enum class PdfToolbarSection(val title: String) {
TOP("Top Bar"),
BOTTOM("Bottom Bar"),
HIDDEN("Hidden Tools")
}
@Composable
private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
when (tool) {
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.HIGHLIGHT_ALL -> Icon(painterResource(id = R.drawable.highlight_text), contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp))
PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
}
}
@Composable
fun PdfVisualOptionsSheet(
systemUiMode: SystemUiMode,
@ -150,4 +551,4 @@ fun PdfVisualOptionsSheet(
)
}
}
}
}

View file

@ -34,7 +34,9 @@ object PdfToHtmlGenerator {
}
try {
val doc = pdfiumCore.newDocument(pfd)
val doc = PdfiumEngineProvider.withPdfium {
pdfiumCore.newDocument(pfd)
}
val totalPages = doc.getPageCount()
Timber.tag(TAG).d("Document loaded. Total pages: $totalPages")
@ -56,7 +58,9 @@ object PdfToHtmlGenerator {
writer.write(buildGlobalHtmlFooter())
}
doc.close()
PdfiumEngineProvider.withPdfium {
doc.close()
}
pfd.close()
Timber.tag(TAG).d("generateHtmlFile SUCCESS | ${System.currentTimeMillis() - t0}ms")
return@withContext true
@ -137,14 +141,14 @@ object PdfToHtmlGenerator {
val textPagePtr = getNativePointer(textPage)
val imageElements = mutableListOf<ImageElement>()
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr)
for (i in 0 until objCount) {
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) {
if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) {
val bbox = FloatArray(4)
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
val topY = bbox[3]
val dimens = IntArray(2)
val pixels = NativePdfiumBridge.extractImagePixels(pagePtr, i, dimens)
val pixels = PdfiumEngineProvider.bridge.extractImagePixels(pagePtr, i, dimens)
if (pixels != null && dimens[0] > 0 && dimens[1] > 0) {
try {
val bmp = Bitmap.createBitmap(pixels, dimens[0], dimens[1], Bitmap.Config.ARGB_8888)
@ -175,11 +179,11 @@ object PdfToHtmlGenerator {
val flags: IntArray?
val charBoxes: FloatArray?
synchronized(NativePdfiumBridge::class.java) {
sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount)
weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount)
flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount)
charBoxes = NativePdfiumBridge.getPageCharBoxes(textPagePtr, actualCount)
synchronized(PdfiumEngineProvider.lock) {
sizes = PdfiumEngineProvider.bridge.getPageFontSizes(textPagePtr, actualCount)
weights = PdfiumEngineProvider.bridge.getPageFontWeights(textPagePtr, actualCount)
flags = PdfiumEngineProvider.bridge.getPageFontFlags(textPagePtr, actualCount)
charBoxes = PdfiumEngineProvider.bridge.getPageCharBoxes(textPagePtr, actualCount)
}
if (sizes == null || weights == null || flags == null) {
@ -547,4 +551,4 @@ object PdfToHtmlGenerator {
}
return 0L
}
}
}

View file

@ -18,13 +18,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
@ -33,6 +34,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.sp
import com.aryan.reader.BuildConfig
@ -41,9 +43,23 @@ import com.aryan.reader.R
import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.epubreader.SystemUiMode
import kotlin.collections.isNotEmpty
private val pdfToolbarTools = setOf(
PdfReaderTool.DICTIONARY,
PdfReaderTool.THEME,
PdfReaderTool.LOCK_PANNING,
PdfReaderTool.SLIDER,
PdfReaderTool.TOC,
PdfReaderTool.SEARCH,
PdfReaderTool.HIGHLIGHT_ALL,
PdfReaderTool.AI_FEATURES,
PdfReaderTool.EDIT_MODE,
PdfReaderTool.TTS_CONTROLS
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun PdfTopBar(
@ -60,6 +76,8 @@ internal fun PdfTopBar(
totalPages: Int,
pagerStatePageCount: Int,
hiddenTools: Set<String>,
toolOrder: List<PdfReaderTool>,
bottomTools: Set<String>,
isScrollLocked: Boolean,
isEditMode: Boolean,
displayMode: DisplayMode,
@ -83,6 +101,16 @@ internal fun PdfTopBar(
onShowCustomizeTools: () -> Unit,
onShowOcrLanguage: () -> Unit,
onShowVisualOptions: () -> Unit,
onShowSlider: () -> Unit,
onShowToc: () -> Unit,
onSearchClick: () -> Unit,
onToggleHighlights: () -> Unit,
onShowAiHub: () -> Unit,
onToggleEditMode: () -> Unit,
onToggleTts: () -> Unit,
isTtsPlayingOrLoading: Boolean,
showAllTextHighlights: Boolean,
isHighlightingLoading: Boolean,
tapToNavigateEnabled: Boolean,
onToggleTapToNavigate: () -> Unit,
onChangeDisplayMode: (DisplayMode) -> Unit,
@ -153,35 +181,89 @@ internal fun PdfTopBar(
modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator")
)
if (!hiddenTools.contains(PdfReaderTool.THEME.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc),
onClick = onShowThemePanel
) {
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
toolOrder
.filter { it in pdfToolbarTools && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
.forEach { tool ->
when (tool) {
PdfReaderTool.THEME -> TooltipIconButton(
text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc),
onClick = onShowThemePanel
) {
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
onClick = onToggleScrollLock
) {
Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.DICTIONARY -> TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onShowDictionarySettings
) {
Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.SLIDER -> TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = onShowSlider,
enabled = !isTtsPlayingOrLoading
) {
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
}
PdfReaderTool.TOC -> TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = onShowToc,
enabled = !isTtsPlayingOrLoading
) {
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
}
PdfReaderTool.SEARCH -> TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = onSearchClick,
enabled = !isTtsPlayingOrLoading
) {
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
}
PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton(
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
onClick = onToggleHighlights
) {
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = onShowAiHub
) {
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
}
}
PdfReaderTool.EDIT_MODE -> TooltipIconButton(
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
onClick = onToggleEditMode
) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.TTS_CONTROLS -> TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts
) {
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> Unit
}
}
}
if (!hiddenTools.contains(PdfReaderTool.LOCK_PANNING.name)) {
TooltipIconButton(
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan),
description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
onClick = onToggleScrollLock
) {
Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onShowDictionarySettings
) {
Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (BuildConfig.DEBUG) {
TooltipIconButton(text = stringResource(R.string.tooltip_demo_annotations), onClick = onGenerateDemoAnnotations) {
@ -197,14 +279,25 @@ internal fun PdfTopBar(
Box {
var showMoreMenu by remember { mutableStateOf(false) }
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
TooltipIconButton(
text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc),
onClick = { showMoreMenu = true }) {
onClick = {
showHiddenToolsExpanded = false
showMoreMenu = true
}) {
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
}
DropdownMenu(expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false }) {
DropdownMenu(
expanded = showMoreMenu,
onDismissRequest = {
showHiddenToolsExpanded = false
showMoreMenu = false
}
) {
val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) }
DropdownMenuItem(
text = { Text(stringResource(R.string.title_customize_toolbar)) },
onClick = { showMoreMenu = false; onShowCustomizeTools() },
@ -212,6 +305,47 @@ internal fun PdfTopBar(
)
HorizontalDivider()
if (hiddenToolbarTools.isNotEmpty()) {
DropdownMenuItem(
text = { Text("Hidden tools") },
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showHiddenToolsExpanded) 180f else 0f)
)
}
)
if (showHiddenToolsExpanded) {
hiddenToolbarTools.forEach { tool ->
HiddenPdfToolMenuItem(
tool = tool,
isTtsPlayingOrLoading = isTtsPlayingOrLoading,
showAllTextHighlights = showAllTextHighlights,
isHighlightingLoading = isHighlightingLoading,
isEditMode = isEditMode,
isTtsSessionActive = isTtsSessionActive,
closeMenu = {
showHiddenToolsExpanded = false
showMoreMenu = false
},
onShowThemePanel = onShowThemePanel,
onToggleScrollLock = onToggleScrollLock,
onShowDictionarySettings = onShowDictionarySettings,
onShowSlider = onShowSlider,
onShowToc = onShowToc,
onSearchClick = onSearchClick,
onToggleHighlights = onToggleHighlights,
onShowAiHub = onShowAiHub,
onToggleEditMode = onToggleEditMode,
onToggleTts = onToggleTts
)
}
}
HorizontalDivider()
}
if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_ocr_language)) },
@ -402,6 +536,72 @@ internal fun PdfTopBar(
}
}
@Composable
private fun HiddenPdfToolMenuItem(
tool: PdfReaderTool,
isTtsPlayingOrLoading: Boolean,
showAllTextHighlights: Boolean,
isHighlightingLoading: Boolean,
isEditMode: Boolean,
isTtsSessionActive: Boolean,
closeMenu: () -> Unit,
onShowThemePanel: () -> Unit,
onToggleScrollLock: () -> Unit,
onShowDictionarySettings: () -> Unit,
onShowSlider: () -> Unit,
onShowToc: () -> Unit,
onSearchClick: () -> Unit,
onToggleHighlights: () -> Unit,
onShowAiHub: () -> Unit,
onToggleEditMode: () -> Unit,
onToggleTts: () -> Unit
) {
val enabled = when (tool) {
PdfReaderTool.SLIDER,
PdfReaderTool.TOC,
PdfReaderTool.SEARCH -> !isTtsPlayingOrLoading
else -> true
}
DropdownMenuItem(
text = { Text(tool.title) },
enabled = enabled,
onClick = {
closeMenu()
when (tool) {
PdfReaderTool.THEME -> onShowThemePanel()
PdfReaderTool.LOCK_PANNING -> onToggleScrollLock()
PdfReaderTool.DICTIONARY -> onShowDictionarySettings()
PdfReaderTool.SLIDER -> onShowSlider()
PdfReaderTool.TOC -> onShowToc()
PdfReaderTool.SEARCH -> onSearchClick()
PdfReaderTool.HIGHLIGHT_ALL -> onToggleHighlights()
PdfReaderTool.AI_FEATURES -> onShowAiHub()
PdfReaderTool.EDIT_MODE -> onToggleEditMode()
PdfReaderTool.TTS_CONTROLS -> onToggleTts()
else -> Unit
}
},
leadingIcon = {
when (tool) {
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.HIGHLIGHT_ALL -> {
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(20.dp))
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
PdfReaderTool.TTS_CONTROLS -> Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp))
}
}
)
}
@Composable
fun ReflowProgressOverlay(
modifier: Modifier = Modifier,
@ -447,6 +647,89 @@ fun ReflowProgressOverlay(
}
}
@Composable
fun PdfJumpHistoryBar(
modifier: Modifier = Modifier,
showStandardBars: Boolean,
searchStateActive: Boolean,
backPage: Int?,
forwardPage: Int?,
onBack: () -> Unit,
onForward: () -> Unit,
onClear: () -> Unit
) {
AnimatedVisibility(
visible = showStandardBars && !searchStateActive && (backPage != null || forwardPage != null),
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
modifier = modifier
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 3.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(
onClick = onBack,
enabled = backPage != null,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_desc_jump_back),
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = backPage?.let { stringResource(R.string.pdf_page_short, it + 1) } ?: "",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
TextButton(
onClick = onClear,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.action_clear),
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.action_clear), maxLines = 1)
}
TextButton(
onClick = onForward,
enabled = forwardPage != null,
modifier = Modifier.weight(1f)
) {
Text(
text = forwardPage?.let { stringResource(R.string.pdf_page_short, it + 1) } ?: "",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.width(4.dp))
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = stringResource(R.string.content_desc_jump_forward),
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
@Composable
fun PdfBottomBar(
modifier: Modifier = Modifier,
@ -455,14 +738,17 @@ fun PdfBottomBar(
systemUiMode: SystemUiMode,
navBarHeightDp: Dp,
hiddenTools: Set<String>,
toolOrder: List<PdfReaderTool>,
bottomTools: Set<String>,
isTtsPlayingOrLoading: Boolean,
showAllTextHighlights: Boolean,
isHighlightingLoading: Boolean,
isEditMode: Boolean,
isTtsSessionActive: Boolean,
ttsErrorMessage: String?,
jumpBackPage: Int?,
onJumpBack: () -> Unit,
onShowThemePanel: () -> Unit,
onToggleScrollLock: () -> Unit,
onShowDictionarySettings: () -> Unit,
onShowSlider: () -> Unit,
onShowToc: () -> Unit,
onSearchClick: () -> Unit,
@ -490,106 +776,91 @@ fun PdfBottomBar(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
if (jumpBackPage != null) {
TooltipIconButton(
text = stringResource(R.string.action_jump_back_to_page, jumpBackPage + 1),
description = stringResource(R.string.desc_return_to_previous_page),
onClick = onJumpBack
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.AutoMirrored.Filled.Undo,
contentDescription = stringResource(R.string.content_desc_jump_back),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Text(
text = "${jumpBackPage + 1}",
fontSize = 10.sp,
lineHeight = 10.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
toolOrder
.filter { it in pdfToolbarTools && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
.forEach { tool ->
when (tool) {
PdfReaderTool.THEME -> TooltipIconButton(
text = stringResource(R.string.tooltip_theme),
description = stringResource(R.string.tooltip_theme_desc),
onClick = onShowThemePanel
) {
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
text = stringResource(R.string.tooltip_lock_pan),
description = stringResource(R.string.tooltip_lock_pan_desc),
onClick = onToggleScrollLock
) {
Icon(Icons.Default.LockOpen, contentDescription = stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.DICTIONARY -> TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
description = stringResource(R.string.tooltip_dictionary_desc),
onClick = onShowDictionarySettings
) {
Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.SLIDER -> TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = onShowSlider,
enabled = !isTtsPlayingOrLoading
) {
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
}
PdfReaderTool.TOC -> TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = onShowToc,
enabled = !isTtsPlayingOrLoading,
modifier = Modifier.testTag("TocButton")
) {
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
}
PdfReaderTool.SEARCH -> TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = onSearchClick,
enabled = !isTtsPlayingOrLoading,
modifier = Modifier.testTag("SearchButton")
) {
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
}
PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton(
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
onClick = onToggleHighlights
) {
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = onShowAiHub
) {
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
}
}
PdfReaderTool.EDIT_MODE -> TooltipIconButton(
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
onClick = onToggleEditMode
) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.TTS_CONTROLS -> TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts
) {
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> Unit
}
}
}
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
description = stringResource(R.string.tooltip_slider_desc),
onClick = onShowSlider,
enabled = !isTtsPlayingOrLoading
) {
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
}
}
if (!hiddenTools.contains(PdfReaderTool.TOC.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_toc),
description = stringResource(R.string.tooltip_toc_desc),
onClick = onShowToc,
enabled = !isTtsPlayingOrLoading,
modifier = Modifier.testTag("TocButton")
) {
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
}
}
if (!hiddenTools.contains(PdfReaderTool.SEARCH.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_search),
description = stringResource(R.string.tooltip_search_desc),
onClick = onSearchClick,
enabled = !isTtsPlayingOrLoading,
modifier = Modifier.testTag("SearchButton")
) {
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
}
}
if (!hiddenTools.contains(PdfReaderTool.HIGHLIGHT_ALL.name)) {
TooltipIconButton(
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
onClick = onToggleHighlights
) {
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = onShowAiHub
) {
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
}
}
if (!hiddenTools.contains(PdfReaderTool.EDIT_MODE.name)) {
TooltipIconButton(
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
onClick = onToggleEditMode
) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
onClick = onToggleTts
) {
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (BuildConfig.FLAVOR != "oss") {
TooltipIconButton(

View file

@ -193,6 +193,7 @@ internal fun PdfVerticalReader(
state: VerticalPdfReaderState,
pdfDocument: StableHolder<ReaderDocument>,
activeTheme: com.aryan.reader.ReaderTheme,
activeTextureAlpha: Float = 0.55f,
excludeImages: Boolean = false,
totalPages: Int,
virtualPages: List<VirtualPage> = emptyList(),
@ -988,7 +989,7 @@ internal fun PdfVerticalReader(
}
val buttons = currentEvent.buttons
Timber.tag("StylusEraserDiagnostic").d(
Timber.tag("StylusDebug").d(
"VerticalReader | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
)
@ -1688,6 +1689,7 @@ internal fun PdfVerticalReader(
virtualPage = virtualPage,
totalPages = totalPages,
activeTheme = activeTheme,
activeTextureAlpha = activeTextureAlpha,
excludeImages = excludeImages,
externalScale = highResScale,
onScaleChanged = {},

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,120 @@
package com.aryan.reader.pdf
import com.aryan.reader.shared.pdf.PdfiumBridge
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
internal object PdfiumEngineProvider {
private val pdfiumMutex = Mutex()
val bridge: PdfiumBridge
get() = AndroidPdfiumBridge
val lock: Any = this
suspend fun <T> withPdfium(block: suspend () -> T): T =
pdfiumMutex.withLock { block() }
fun <T> withPdfiumBlocking(block: () -> T): T =
runBlocking {
pdfiumMutex.withLock { block() }
}
}
private object AndroidPdfiumBridge : PdfiumBridge {
override fun getFontSize(textPagePtr: Long, index: Int): Double =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getFontSize(textPagePtr, index)
}
override fun getFontWeight(textPagePtr: Long, index: Int): Int =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getFontWeight(textPagePtr, index)
}
override fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageFontSizes(textPagePtr, count)
}
override fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageFontWeights(textPagePtr, count)
}
override fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageFontFlags(textPagePtr, count)
}
override fun getPageCharBoxes(textPagePtr: Long, count: Int): FloatArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageCharBoxes(textPagePtr, count)
}
override fun getAnnotCount(pagePtr: Long): Int =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getAnnotCount(pagePtr)
}
override fun getAnnotSubtype(pagePtr: Long, index: Int): Int =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getAnnotSubtype(pagePtr, index)
}
override fun getAnnotRect(pagePtr: Long, index: Int): FloatArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getAnnotRect(pagePtr, index)
}
override fun getAnnotString(pagePtr: Long, index: Int, key: String): String? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getAnnotString(pagePtr, index, key)
}
override fun getPageObjectCount(pagePtr: Long): Int =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageObjectCount(pagePtr)
}
override fun getPageObjectType(pagePtr: Long, index: Int): Int =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageObjectType(pagePtr, index)
}
override fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, index, outRect)
}
override fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.extractImagePixels(pagePtr, index, dimens)
}
override fun performClick(pagePtr: Long, x: Double, y: Double): Boolean =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.performClick(pagePtr, x, y)
}
override fun getLinkInfoAtPoint(docPtr: Long, pagePtr: Long, x: Double, y: Double): String? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getLinkInfoAtPoint(docPtr, pagePtr, x, y)
}
override fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getAnnotSubtypeAtPoint(pagePtr, x, y)
}
override fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray? =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.getAnnotRectAtPoint(pagePtr, x, y)
}
override fun checkActionSupport(): Boolean =
PdfiumEngineProvider.withPdfiumBlocking {
NativePdfiumBridge.checkActionSupport()
}
}

View file

@ -28,6 +28,7 @@ import me.zhanghai.android.libarchive.ArchiveException
import okhttp3.Request
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.ZipFile
import androidx.core.graphics.createBitmap
@ -88,42 +89,97 @@ object DocumentFactory {
ArchiveDocumentWrapper(cacheFile)
} else {
val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw Exception("Failed to open PDF")
PdfDocumentWrapper(pdfiumCore.newDocument(pfd, password))
PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) })
}
}
}
// ================= PDF IMPLEMENTATION =================
private inline fun closePdfiumResource(tag: String, closeBlock: () -> Unit) {
try {
closeBlock()
} catch (e: IllegalStateException) {
if (e.message == "Already closed") {
Timber.tag(tag).d(e, "Ignoring duplicate Pdfium close")
} else {
throw e
}
}
}
class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
override suspend fun getPageCount() = pdfDocument.getPageCount()
private val isClosed = AtomicBoolean(false)
override suspend fun getPageCount() = PdfiumEngineProvider.withPdfium {
pdfDocument.getPageCount()
}
override suspend fun openPage(pageIndex: Int): ReaderPage? {
val page = pdfDocument.openPage(pageIndex) ?: return null
if (isClosed.get()) return null
val page = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else pdfDocument.openPage(pageIndex)
} ?: return null
return PdfPageWrapper(page)
}
override suspend fun getTableOfContents() = pdfDocument.getFixedTableOfContents()
override fun close() { pdfDocument.close() }
override suspend fun getTableOfContents() = PdfiumEngineProvider.withPdfium {
pdfDocument.getFixedTableOfContents()
}
override fun close() {
if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfDocumentWrapper") { pdfDocument.close() }
}
}
}
class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
override suspend fun getPageWidthPoint() = pdfPage.getPageWidthPoint()
override suspend fun getPageHeightPoint() = pdfPage.getPageHeightPoint()
override suspend fun getPageRotation() = pdfPage.getPageRotation()
private val isClosed = AtomicBoolean(false)
override suspend fun getPageWidthPoint() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else pdfPage.getPageWidthPoint()
}
override suspend fun getPageHeightPoint() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else pdfPage.getPageHeightPoint()
}
override suspend fun getPageRotation() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else pdfPage.getPageRotation()
}
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
PdfiumEngineProvider.withPdfium {
if (!isClosed.get()) {
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
}
}
}
override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF) =
pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
PdfiumEngineProvider.withPdfium {
if (isClosed.get()) Rect() else pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
}
override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int) =
pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
PdfiumEngineProvider.withPdfium {
if (isClosed.get()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
}
override suspend fun openTextPage(): ReaderTextPage = PdfTextPageWrapper(pdfPage.openTextPage())
override suspend fun openTextPage(): ReaderTextPage = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage())
}
override suspend fun getLinks(): List<ReaderLink> {
return pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
return PdfiumEngineProvider.withPdfium {
if (isClosed.get()) {
emptyList()
} else {
pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
}
}
}
override fun getNativePointer(): Long {
@ -159,29 +215,80 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
return 0L
}
override fun close() { pdfPage.close() }
override fun close() {
if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfPageWrapper") { pdfPage.close() }
}
}
}
class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
override suspend fun textPageCountChars() = textPage.textPageCountChars()
override suspend fun textPageGetText(startIndex: Int, count: Int) = textPage.textPageGetText(startIndex, count)
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
override suspend fun textPageGetCharBox(index: Int) = textPage.textPageGetCharBox(index)
override suspend fun textPageGetUnicode(index: Int): Int {
return textPage.textPageGetUnicode(index).code
private val isClosed = AtomicBoolean(false)
override suspend fun textPageCountChars() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else textPage.textPageCountChars()
}
override suspend fun loadWebLink(): ReaderWebLinks? {
val links = textPage.loadWebLink() ?: return null
return object : ReaderWebLinks {
override suspend fun countWebLinks() = links.countWebLinks()
override suspend fun getURL(linkIndex: Int, maxLength: Int) = links.getURL(linkIndex, maxLength)
override suspend fun countRects(linkIndex: Int) = links.countRects(linkIndex)
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = links.getRect(linkIndex, rectIndex)
override fun close() { links.close() }
override suspend fun textPageGetText(startIndex: Int, count: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.textPageGetText(startIndex, count)
}
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
}
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) -1 else textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
}
override suspend fun textPageGetCharBox(index: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.textPageGetCharBox(index)
}
override suspend fun textPageGetUnicode(index: Int): Int {
return PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else textPage.textPageGetUnicode(index).code
}
}
override suspend fun loadWebLink(): ReaderWebLinks? {
val links = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.loadWebLink()
} ?: return null
return object : ReaderWebLinks {
private val isClosed = AtomicBoolean(false)
override suspend fun countWebLinks() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else links.countWebLinks()
}
override suspend fun getURL(linkIndex: Int, maxLength: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else links.getURL(linkIndex, maxLength)
}
override suspend fun countRects(linkIndex: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else links.countRects(linkIndex)
}
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) RectF() else links.getRect(linkIndex, rectIndex)
}
override fun close() {
if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfWebLinksWrapper") { links.close() }
}
}
}
}
override fun close() {
if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfTextPageWrapper") { textPage.close() }
}
}
override fun close() { textPage.close() }
}
// ================= CBZ, CBR, CB7 IMPLEMENTATION =================

View file

@ -34,6 +34,7 @@ import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.flatMap
import com.aryan.reader.SearchResult
import com.aryan.reader.pdf.PdfiumEngineProvider
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@ -171,13 +172,15 @@ class PdfTextRepository(context: Context) {
var ocrUsed = false
try {
document.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage ->
val count = textPage.textPageCountChars()
if (count > 0) {
val nativeText = textPage.textPageGetText(0, count)
if (!nativeText.isNullOrBlank()) {
text = nativeText
PdfiumEngineProvider.withPdfium {
document.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage ->
val count = textPage.textPageCountChars()
if (count > 0) {
val nativeText = textPage.textPageGetText(0, count)
if (!nativeText.isNullOrBlank()) {
text = nativeText
}
}
}
}
@ -187,26 +190,35 @@ class PdfTextRepository(context: Context) {
}
if (text.isBlank()) {
var bitmap: android.graphics.Bitmap? = null
try {
document.openPage(pageIndex)?.use { page ->
val targetWidth = 1080
val ptrWidth = page.getPageWidthPoint()
val ptrHeight = page.getPageHeightPoint()
PdfiumEngineProvider.withPdfium {
document.openPage(pageIndex)?.use { page ->
val targetWidth = 1080
val ptrWidth = page.getPageWidthPoint()
val ptrHeight = page.getPageHeightPoint()
if (ptrWidth > 0 && ptrHeight > 0) {
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
if (ptrWidth > 0 && ptrHeight > 0) {
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
val bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false)
val visionText = OcrHelper.extractTextFromBitmap(bitmap, onOcrModelDownloading)
bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false)
}
}
}
bitmap?.let {
try {
val visionText = OcrHelper.extractTextFromBitmap(it, onOcrModelDownloading)
text = visionText?.text ?: ""
bitmap.recycle()
ocrUsed = true
} finally {
it.recycle()
bitmap = null
}
}
} catch (e: Exception) {
bitmap?.recycle()
Timber.tag(TAG).e(e, "OCR failed for page $pageIndex")
}
}
@ -270,11 +282,13 @@ class PdfTextRepository(context: Context) {
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
return withContext(Dispatchers.IO) {
try {
document.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage ->
textPage.textPageCountChars() > 0
}
} ?: false
PdfiumEngineProvider.withPdfium {
document.openPage(pageIndex)?.use { page ->
page.openTextPage().use { textPage ->
textPage.textPageCountChars() > 0
}
} ?: false
}
} catch (_: Exception) {
false
}
@ -585,4 +599,4 @@ class PdfTextRepository(context: Context) {
return null
}
}
}

View file

@ -34,6 +34,7 @@ import java.io.File
import java.util.Locale
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.suspendCancellableCoroutine
@ -247,11 +248,16 @@ class BaseTtsSynthesizer(private val context: Context) {
try {
withTimeout(startTimeout) {
startSignal.await()
select {
startSignal.onAwait { }
resultDeferred.onAwait { }
}
}
} catch (_: TimeoutCancellationException) {
Timber.w("BaseTts: ZOMBIE DETECTED. onStart not received within ${startTimeout}ms.")
throw ZombieEngineException()
Timber.w(
"BaseTts: onStart not received within ${startTimeout}ms for $utteranceId. " +
"Continuing to wait for onDone because some engines omit or delay onStart for file synthesis."
)
}
try {
@ -296,5 +302,4 @@ class BaseTtsSynthesizer(private val context: Context) {
Timber.d("TextToSpeech engine shut down.")
}
private class ZombieEngineException : Exception("Engine failed to start")
}

View file

@ -39,6 +39,7 @@ import androidx.media3.session.SessionToken
import com.aryan.reader.BuildConfig
import com.aryan.reader.epubreader.loadTtsPitch
import com.aryan.reader.epubreader.loadTtsSpeechRate
import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
@ -72,7 +73,7 @@ fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
?: TtsPlaybackManager.TtsMode.BASE.name
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank()
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank() || isByokCloudTtsAvailable(context)
return if (isCloudAllowed) {
try {
@ -105,8 +106,14 @@ class TtsController(context: Context) : Player.Listener {
}
fun connect() {
if (mediaController != null || controllerFuture != null) return
if (mediaController != null || controllerFuture != null) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"TtsController.connect skipped. hasController=${mediaController != null}, hasFuture=${controllerFuture != null}"
)
return
}
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("TtsController.connect building MediaController.")
val sessionToken = SessionToken(context, ComponentName(context, TtsService::class.java))
val future = MediaController.Builder(context, sessionToken).buildAsync()
controllerFuture = future
@ -129,10 +136,14 @@ class TtsController(context: Context) : Player.Listener {
mediaController?.addListener(this)
Timber.d("MediaController connected.")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"MediaController connected. playbackState=${controller.playbackState}, isPlaying=${controller.isPlaying}, mediaItems=${controller.mediaItemCount}, customLayout=${controller.customLayout.size}"
)
updateStateFromController()
startPolling()
} catch (e: Exception) {
Timber.w("Failed to connect MediaController: ${e.message}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(e, "MediaController connection failed.")
if (controllerFuture == future) {
controllerFuture = null
}
@ -158,15 +169,21 @@ class TtsController(context: Context) : Player.Listener {
chapterTitle: String?,
coverImageUri: String?,
chapterIndex: Int? = null,
totalChapters: Int? = null,
continueSession: Boolean = false,
ttsMode: TtsPlaybackManager.TtsMode,
playbackSource: String = "READER",
authToken: String? = null
) {
if (chunks.isEmpty()) {
Timber.w("TtsController: start called with empty chunks!")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsController.start aborted because chunks is empty.")
return
}
Timber.d("UI sending START command with mode: $ttsMode")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"TtsController.start. hasController=${mediaController != null}, chunks=${chunks.size}, continueSession=$continueSession, source=$playbackSource, mode=$ttsMode, book='${bookTitle.take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters"
)
val textList = ArrayList(chunks.map { it.text })
val cfiList = ArrayList(chunks.map { it.sourceCfi })
@ -181,6 +198,8 @@ class TtsController(context: Context) : Player.Listener {
putString(KEY_CHAPTER_TITLE, chapterTitle)
putString(KEY_COVER_IMAGE_URI, coverImageUri)
chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) }
totalChapters?.let { putInt(KEY_TOTAL_CHAPTERS, it) }
putBoolean(KEY_CONTINUE_SESSION, continueSession)
putString(KEY_TTS_MODE, ttsMode.name)
putString(KEY_PLAYBACK_SOURCE, playbackSource)
putString(KEY_AUTH_TOKEN, authToken)
@ -188,7 +207,15 @@ class TtsController(context: Context) : Player.Listener {
putFloat("playback_pitch", loadTtsPitch(context))
}
Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $ttsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}")
mediaController?.sendCustomCommand(START_TTS_COMMAND, args)
val controller = mediaController
if (controller == null) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e("Cannot send START command because MediaController is null.")
} else {
val result = controller.sendCustomCommand(START_TTS_COMMAND, args)
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"START command sent. playbackState=${controller.playbackState}, isPlaying=${controller.isPlaying}, mediaItems=${controller.mediaItemCount}, resultDone=${result.isDone}"
)
}
}
fun pause() {
@ -240,6 +267,9 @@ class TtsController(context: Context) : Player.Listener {
}
override fun onEvents(player: Player, events: Player.Events) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Controller onEvents. playbackState=${player.playbackState}, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}, events=$events"
)
updateStateFromController()
}
@ -247,7 +277,9 @@ class TtsController(context: Context) : Player.Listener {
mediaController?.let { controller ->
val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY
val currentMediaItem = controller.currentMediaItem
val currentTextFromMediaItem = currentMediaItem?.mediaMetadata?.subtitle?.toString()
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
val currentTextFromMediaItem = mediaItemExtras?.getString("ttsText")
?: currentMediaItem?.mediaMetadata?.subtitle?.toString()
val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING
val serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId)
val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false)
@ -255,9 +287,13 @@ class TtsController(context: Context) : Player.Listener {
val sessionFinished = customState.getBoolean("sessionFinished", false)
val playbackSource = customState.getString("playbackSource")
val serviceBookTitle = customState.getString("bookTitle")
val serviceChapterTitle = customState.getString("chapterTitle")
val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 }
val serviceTotalChapters = customState.getInt("totalChapters", -1).takeIf { it > 0 }
val serviceCurrentChunkIndex = customState.getInt("currentChunkIndex", -1)
val serviceTotalChunks = customState.getInt("totalChunks", 0)
val serviceBookProgressPercent = customState.getInt("bookProgressPercent", -1).takeIf { it >= 0 }
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
val sourceCfi = mediaItemExtras?.getString("sourceCfi")
val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
@ -279,11 +315,24 @@ class TtsController(context: Context) : Player.Listener {
} else {
if (isLoading) currentState.bookTitle else serviceBookTitle
},
chapterTitle = if (isPlaybackActive || isLoading) {
serviceChapterTitle ?: currentState.chapterTitle
} else {
serviceChapterTitle
},
chapterIndex = if (isPlaybackActive || isLoading) {
serviceChapterIndex ?: currentState.chapterIndex
} else {
serviceChapterIndex
},
totalChapters = if (isPlaybackActive || isLoading) {
serviceTotalChapters ?: currentState.totalChapters
} else {
serviceTotalChapters
},
currentChunkIndex = serviceCurrentChunkIndex,
totalChunks = serviceTotalChunks,
bookProgressPercent = serviceBookProgressPercent,
speakerId = serviceSpeaker,
sourceCfi = if (isPlaybackActive) {
sourceCfi

View file

@ -48,6 +48,7 @@ import androidx.core.net.toUri
import com.aryan.reader.paginatedreader.TimedWord
import com.aryan.reader.paginatedreader.TtsChunk
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", Bundle.EMPTY)
val STOP_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.STOP", Bundle.EMPTY)
@ -57,6 +58,7 @@ private val STATE_UPDATE_COMMAND = SessionCommand("com.aryan.reader.tts.STATE_UP
val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY)
val SLICE_CURRENT_AND_RELOAD_COMMAND = SessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD", Bundle.EMPTY)
val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS", Bundle.EMPTY)
const val TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG"
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
@ -71,6 +73,8 @@ const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS"
const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE"
const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN"
const val KEY_CHAPTER_INDEX = "KEY_CHAPTER_INDEX"
const val KEY_TOTAL_CHAPTERS = "KEY_TOTAL_CHAPTERS"
const val KEY_CONTINUE_SESSION = "KEY_CONTINUE_SESSION"
private const val PREFETCH_LOOKAHEAD = 3
@ -102,7 +106,12 @@ class TtsPlaybackManager(
val currentText: String? = null,
val errorMessage: String? = null,
val bookTitle: String? = null,
val chapterTitle: String? = null,
val chapterIndex: Int? = null,
val totalChapters: Int? = null,
val currentChunkIndex: Int = -1,
val totalChunks: Int = 0,
val bookProgressPercent: Int? = null,
val speakerId: String = DEFAULT_SPEAKER_ID,
val sourceCfi: String? = null,
val startOffsetInSource: Int = -1,
@ -124,6 +133,8 @@ class TtsPlaybackManager(
private var chapterTitle: String? = null
private var coverImageUri: String? = null
private var currentTtsMode = TtsMode.CLOUD
private var chapterIndex: Int? = null
private var totalChapters: Int? = null
init {
player.addListener(this)
@ -146,6 +157,9 @@ class TtsPlaybackManager(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"MediaSession onConnect. package=${controller.packageName}, uid=${controller.uid}"
)
val availableSessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
.add(START_TTS_COMMAND)
.add(STOP_TTS_COMMAND)
@ -186,6 +200,9 @@ class TtsPlaybackManager(
START_TTS_COMMAND -> {
val chunks = args.getStringArrayList(KEY_TEXT_CHUNKS) ?: emptyList()
Timber.d("TtsService: START command received. Size: ${chunks.size}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"START command received. chunks=${chunks.size}, continueSession=${args.getBoolean(KEY_CONTINUE_SESSION, false)}, source=${args.getString(KEY_PLAYBACK_SOURCE)}, mode=${args.getString(KEY_TTS_MODE)}, chapterIndex=${args.getInt(KEY_CHAPTER_INDEX, -1)}, totalChapters=${args.getInt(KEY_TOTAL_CHAPTERS, -1)}"
)
val cfis = args.getStringArrayList(KEY_SOURCE_CFIS)
val offsets = args.getIntegerArrayList(KEY_START_OFFSETS)
val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
@ -193,6 +210,7 @@ class TtsPlaybackManager(
val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
val coverImageUri = args.getString(KEY_COVER_IMAGE_URI)
val chapterIndex = args.getInt(KEY_CHAPTER_INDEX, -1).takeIf { it >= 0 }
val totalChapters = args.getInt(KEY_TOTAL_CHAPTERS, -1).takeIf { it > 0 }
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
@ -208,10 +226,11 @@ class TtsPlaybackManager(
val authToken = args.getString(KEY_AUTH_TOKEN)
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, ttsMode, playbackSource, args)
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, totalChapters, ttsMode, playbackSource, args)
}
STOP_TTS_COMMAND -> {
Timber.d("Received STOP command.")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("STOP command received.")
handleStopTts(userInitiated = true)
}
CHANGE_SPEAKER_COMMAND -> {
@ -342,17 +361,20 @@ class TtsPlaybackManager(
chapterTitle: String?,
coverImageUri: String?,
chapterIndex: Int?,
totalChapters: Int?,
ttsMode: TtsMode,
playbackSource: String?,
args: Bundle // Added this parameter
) {
if (chunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("handleStartTts aborted because chunks is empty.")
return
}
// --- YOUR SNIPPET START ---
val authToken = args.getString(KEY_AUTH_TOKEN)
val continueSession = args.getBoolean(KEY_CONTINUE_SESSION, false)
val speed = args.getFloat("playback_speed", 1f)
val pitch = args.getFloat("playback_pitch", 1f)
@ -365,27 +387,54 @@ class TtsPlaybackManager(
}
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"handleStartTts. continueSession=$continueSession, chunks=${chunks.size}, book='${bookTitle.orEmpty().take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters, mode=$ttsMode, playbackSource=$playbackSource"
)
if (!continueSession) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("New TTS session. Calling handleStopTts(clearState=false) before start.")
handleStopTts(clearState = false)
}
handleStopTts(clearState = false)
textChunks = chunks
currentSpeakerId = speakerId
currentTtsMode = ttsMode
this.bookTitle = bookTitle
this.chapterTitle = chapterTitle
this.coverImageUri = coverImageUri
this.chapterIndex = chapterIndex
this.totalChapters = totalChapters
onResetContext()
loadedChunks.clear()
lastPrefetchIndex = -1
_ttsState.value = TtsState(
isLoading = true,
bookTitle = bookTitle,
chapterTitle = chapterTitle,
chapterIndex = chapterIndex,
totalChapters = totalChapters,
currentChunkIndex = -1,
totalChunks = chunks.size,
bookProgressPercent = calculateBookProgressPercent(-1),
speakerId = speakerId,
playbackSource = playbackSource,
ttsMode = ttsMode.name
ttsMode = ttsMode.name,
currentText = if (continueSession) _ttsState.value.currentText else null
)
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"TTS state set to loading. bookProgress=${_ttsState.value.bookProgressPercent}, currentTextRetained=${_ttsState.value.currentText != null}"
)
if (continueSession) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Continuation start. Cancelling prefetch/tracking but keeping player session alive until replacement media is ready.")
preparationJob?.cancel()
wordTrackingJob?.cancel()
prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
clearPlaylistForContinuation()
}
currentAuthToken = authToken
preparationJob = scope.launch {
@ -411,6 +460,73 @@ class TtsPlaybackManager(
Timber.d("Speaker changed to $newSpeakerId (pending next start)")
}
private fun currentChunkIndexFromPlayer(): Int {
return player.currentMediaItem?.mediaId?.toIntOrNull()
?: player.currentMediaItemIndex
}
private fun calculateBookProgressPercent(chunkIndex: Int): Int? {
val chapter = chapterIndex ?: return null
val chapterCount = totalChapters?.takeIf { it > 0 } ?: return null
val safeChunkProgress = if (textChunks.isNotEmpty() && chunkIndex >= 0) {
((chunkIndex + 1).toDouble() / textChunks.size.toDouble()).coerceIn(0.0, 1.0)
} else {
0.0
}
return (((chapter.toDouble() + safeChunkProgress) / chapterCount.toDouble()) * 100.0)
.roundToInt()
.coerceIn(0, 100)
}
private fun markSessionFinishedNaturally(chunkIndex: Int) {
val currentState = _ttsState.value
if (currentState.isLoading && currentState.currentChunkIndex == -1) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
"Ignoring stale streamed completion while a continuation session is loading."
)
return
}
val safeChunkIndex = if (textChunks.isNotEmpty()) {
chunkIndex.coerceIn(0, textChunks.lastIndex)
} else {
-1
}
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i(
"Setting sessionFinished = true for naturally completed streamed TTS. chunk=$safeChunkIndex, totalChunks=${textChunks.size}"
)
_ttsState.value = _ttsState.value.copy(
isPlaying = false,
isLoading = false,
currentChunkIndex = safeChunkIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(safeChunkIndex),
currentWordSourceCfi = null,
currentWordStartOffset = -1,
sessionFinished = true
)
}
private fun clearPlaylistForContinuation() {
val filesToDelete = audioFiles.values.toList()
val streamsToRemove = chunkStreamIds.values.toList()
audioFiles.clear()
chunkStreamIds.clear()
loadedChunks.clear()
lastPrefetchIndex = -1
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Cleared continuation temp resources. oldFiles=${filesToDelete.size}, oldStreams=${streamsToRemove.size}"
)
scope.launch(Dispatchers.IO) {
filesToDelete.forEach { deleteTempFile(it) }
streamsToRemove.forEach { StreamRegistry.remove(it) }
}
}
private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) {
val firstChunk = textChunks.getOrNull(startAtIndex)
if (firstChunk == null) {
@ -420,6 +536,9 @@ class TtsPlaybackManager(
val chunkStartTime = System.currentTimeMillis()
Timber.tag("TTS_CLOUD_DIAG").i("Starting audio generation for first chunk (index=$startAtIndex).")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Preparing first chunk. startAtIndex=$startAtIndex, playWhenReady=$playWhenReady"
)
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, firstChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
@ -464,17 +583,30 @@ class TtsPlaybackManager(
}
player.playWhenReady = playWhenReady
Timber.tag("TTS_CLOUD_DIAG").i("ExoPlayer setMediaItem & prepare called in ${System.currentTimeMillis() - prepStartTime}ms")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Player prepared for TTS. mediaId=${mediaItem.mediaId}, title='${mediaItem.mediaMetadata.title}', playWhenReady=${player.playWhenReady}, playbackState=${player.playbackState}, mediaItems=${player.mediaItemCount}"
)
_ttsState.value = _ttsState.value.copy(
isLoading = false,
isPlaying = playWhenReady,
currentText = serverText,
chapterTitle = chapterTitle,
chapterIndex = chapterIndex,
totalChapters = totalChapters,
currentChunkIndex = startAtIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(startAtIndex),
sessionFinished = false,
sourceCfi = updatedChunk.sourceCfi,
startOffsetInSource = updatedChunk.startOffsetInSource
)
}
prefetchNextChunkAudio(startAtIndex)
} else {
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.")
_ttsState.value = _ttsState.value.copy(
isLoading = false,
errorMessage = ttsAudioData.error ?: "Failed to load audio."
)
}
}
@ -509,6 +641,9 @@ class TtsPlaybackManager(
private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) {
Timber.tag("TTS_CLOUD_DIAG").d("handleStopTts called. clearState=$clearState, userInitiated=$userInitiated")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"handleStopTts. clearState=$clearState, userInitiated=$userInitiated"
)
onResetContext()
preparationJob?.cancel()
wordTrackingJob?.cancel()
@ -527,6 +662,11 @@ class TtsPlaybackManager(
player.stop()
player.clearMediaItems()
textChunks = emptyList()
bookTitle = null
chapterTitle = null
coverImageUri = null
chapterIndex = null
totalChapters = null
lastPrefetchIndex = -1
prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() }
@ -541,17 +681,27 @@ class TtsPlaybackManager(
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
val newPlaylistIndex = player.currentMediaItemIndex
Timber.tag("TTS_CLOUD_DIAG").d("onMediaItemTransition to playlistIndex: $newPlaylistIndex, mediaId: ${mediaItem?.mediaId}, reason: $reason")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onMediaItemTransition. playlistIndex=$newPlaylistIndex, mediaId=${mediaItem?.mediaId}, reason=$reason, title='${mediaItem?.mediaMetadata?.title}', playbackState=${player.playbackState}, isPlaying=${player.isPlaying}"
)
if (newPlaylistIndex == C.INDEX_UNSET) return
val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return
val newText = mediaItem.mediaMetadata.subtitle?.toString()
val extras = mediaItem.mediaMetadata.extras
val newText = extras?.getString("ttsText") ?: mediaItem.mediaMetadata.subtitle?.toString()
val sourceCfi = extras?.getString("sourceCfi")
val startOffset = extras?.getInt("startOffset", -1) ?: -1
_ttsState.value = _ttsState.value.copy(
currentText = newText,
chapterTitle = chapterTitle,
chapterIndex = chapterIndex,
totalChapters = totalChapters,
currentChunkIndex = currentChunkIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(currentChunkIndex),
sessionFinished = false,
sourceCfi = sourceCfi,
startOffsetInSource = startOffset
)
@ -582,6 +732,9 @@ class TtsPlaybackManager(
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onIsPlayingChanged. isPlaying=$isPlaying, playbackState=${player.playbackState}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
)
var nextState = _ttsState.value.copy(isPlaying = isPlaying)
if (isPlaying) {
@ -599,14 +752,22 @@ class TtsPlaybackManager(
currentWordStartOffset = -1
)
val currentChunkIndex = player.currentMediaItemIndex
val currentChunkIndex = currentChunkIndexFromPlayer()
val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1
if (player.playbackState == Player.STATE_ENDED) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ExoPlayer STATE_ENDED. currentChunkIndex: $currentChunkIndex, isLastChunk: $isLastChunkInSession, totalChunks: ${textChunks.size}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Player reached ENDED. currentChunkIndex=$currentChunkIndex, isLastChunk=$isLastChunkInSession, totalChunks=${textChunks.size}, sessionFinishedWillBeSet=${isLastChunkInSession || textChunks.isEmpty()}"
)
if (isLastChunkInSession || textChunks.isEmpty()) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("Setting sessionFinished = true")
nextState = nextState.copy(sessionFinished = true)
nextState = nextState.copy(
currentChunkIndex = currentChunkIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(currentChunkIndex),
sessionFinished = true
)
} else {
val nextIdx = currentChunkIndex + 1
val isPrefetching = prefetchingJobs.containsKey(nextIdx)
@ -625,6 +786,7 @@ class TtsPlaybackManager(
if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
if (!nextState.sessionEndedByStop && !nextState.isLoading && preparationJob?.isActive != true) {
Timber.tag("TTS_CLOUD_DIAG").d("Auto-stopping TTS from onIsPlayingChanged (IDLE and not loading)")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Auto-stopping from IDLE/not-loading path.")
handleStopTts(userInitiated = true)
} else {
Timber.tag("TTS_CLOUD_DIAG").d("Ignoring STATE_IDLE in onIsPlayingChanged because isLoading=${nextState.isLoading}, preparationJob.isActive=${preparationJob?.isActive}")
@ -634,6 +796,7 @@ class TtsPlaybackManager(
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(error, "Player error. code=${error.errorCodeName}, message=${error.message}")
_ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}")
handleStopTts(userInitiated = true)
}
@ -719,10 +882,13 @@ class TtsPlaybackManager(
player.addMediaItem(insertPosition, nextMediaItem)
}
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && targetIndex == player.currentMediaItemIndex + 1) {
val currentChunkIndex = currentChunkIndexFromPlayer()
val isImmediateNextChunk = targetIndex == currentChunkIndex + 1
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) {
player.seekToNextMediaItem()
player.play()
} else if (wasLoading && targetIndex == player.currentMediaItemIndex + 1) {
} else if (wasLoading && isImmediateNextChunk) {
_ttsState.value = _ttsState.value.copy(isLoading = false)
}
}
@ -768,7 +934,10 @@ class TtsPlaybackManager(
if (player.hasNextMediaItem()) {
player.seekToNextMediaItem()
} else {
player.stop()
val finishedChunkIndex = currentMediaItem.mediaId.toIntOrNull()
?: currentChunkIndexFromPlayer()
markSessionFinishedNaturally(finishedChunkIndex)
player.pause()
}
}
}
@ -811,7 +980,37 @@ class TtsPlaybackManager(
}
private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem {
val progress = calculateBookProgressPercent(index)
val chunkLabel = if (textChunks.isNotEmpty()) {
"Chunk ${index + 1}/${textChunks.size}"
} else {
null
}
val chapterLabel = buildString {
val chapter = chapterIndex
val chapterCount = totalChapters
if (chapter != null && chapterCount != null) {
append("Chapter ${chapter + 1} of $chapterCount")
if (!chapterTitle.isNullOrBlank()) append(": $chapterTitle")
} else if (!chapterTitle.isNullOrBlank()) {
append(chapterTitle)
}
if (progress != null) {
if (isNotEmpty()) append(" - ")
append("$progress%")
}
if (chunkLabel != null) {
if (isNotEmpty()) append(" - ")
append(chunkLabel)
}
}.ifBlank { chapterTitle ?: chunkLabel ?: "TTS" }
val chunkPreview = text
.replace(Regex("\\s+"), " ")
.trim()
.take(180)
val extras = Bundle().apply {
putString("ttsText", text)
putString("sourceCfi", chunk.sourceCfi)
putInt("startOffset", chunk.startOffsetInSource)
if (chunk.timedWords.isNotEmpty()) {
@ -823,9 +1022,11 @@ class TtsPlaybackManager(
}
val metadata = MediaMetadata.Builder()
.setArtist(bookTitle)
.setTitle(chapterTitle)
.setSubtitle(text)
.setTitle(bookTitle ?: chapterLabel)
.setDisplayTitle(bookTitle ?: chapterLabel)
.setArtist(chapterLabel)
.setSubtitle(chunkPreview)
.setDescription(chunkPreview)
.setArtworkUri(coverImageUri?.toUri())
.setTrackNumber(index + 1)
.setTotalTrackCount(textChunks.size)
@ -865,7 +1066,12 @@ class TtsPlaybackManager(
putBoolean("isLoading", state.isLoading)
putString("errorMessage", state.errorMessage)
putString("bookTitle", state.bookTitle)
putString("chapterTitle", state.chapterTitle)
putInt("chapterIndex", state.chapterIndex ?: -1)
putInt("totalChapters", state.totalChapters ?: -1)
putInt("currentChunkIndex", state.currentChunkIndex)
putInt("totalChunks", state.totalChunks)
putInt("bookProgressPercent", state.bookProgressPercent ?: -1)
putString("speakerId", state.speakerId)
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
putString("currentWordSourceCfi", state.currentWordSourceCfi)
@ -905,5 +1111,8 @@ class TtsPlaybackManager(
else -> "UNKNOWN"
}
Timber.tag("TTS_CLOUD_DIAG").d("ExoPlayer playback state changed: $stateName")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onPlaybackStateChanged. state=$stateName, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
)
}
}

View file

@ -30,6 +30,9 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL
import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.loadAiByokSettings
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -236,16 +239,31 @@ class TtsService : MediaSessionService() {
private lateinit var cacheManager: TtsCacheManager
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
val playerState = if (::player.isInitialized) {
"playbackState=${player.playbackState}, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
} else {
"player=uninitialized"
}
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onUpdateNotification called. startInForegroundRequired=$startInForegroundRequired, hasPostNotifications=$hasNotificationPermission, $playerState"
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
if (startInForegroundRequired) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing while foreground is required. Calling stopSelf().")
stopSelf()
} else {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing. Skipping notification update.")
}
return
}
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.")
super.onUpdateNotification(session, startInForegroundRequired)
}
@ -279,7 +297,12 @@ class TtsService : MediaSessionService() {
data class Error(val message: String) : GeminiWsEvent()
}
suspend fun ensureConnected(serverUrl: String, speaker: String, authToken: String?) = connectionMutex.withLock {
suspend fun ensureConnected(
serverUrl: String,
speaker: String,
authToken: String?,
directGeminiApiKey: String? = null
) = connectionMutex.withLock {
if (webSocket != null) {
if (connectedSpeaker == speaker) {
val isSetup = try { setupDeferred.await() } catch(_: Exception) { false }
@ -290,11 +313,15 @@ class TtsService : MediaSessionService() {
webSocket = null
}
val sanitizedUrl = serverUrl.removeSuffix("/")
val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://")
val url = "$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}"
val url = if (!directGeminiApiKey.isNullOrBlank()) {
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$directGeminiApiKey"
} else {
val sanitizedUrl = serverUrl.removeSuffix("/")
val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://")
"$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}"
}
Timber.tag("TTS_CLOUD_DIAG").d("Connecting to WS: $url")
Timber.tag("TTS_CLOUD_DIAG").d("Connecting to WS: ${if (!directGeminiApiKey.isNullOrBlank()) "Gemini BYOK" else url}")
val request = Request.Builder().url(url).build()
val connectedDeferred = CompletableDeferred<Boolean>()
@ -316,7 +343,7 @@ class TtsService : MediaSessionService() {
val setupMsg = JSONObject().apply {
put("setup", JSONObject().apply {
put("model", "models/gemini-3.1-flash-live-preview")
put("model", "models/$GEMINI_CLOUD_TTS_MODEL")
put("systemInstruction", JSONObject().apply {
put("parts", org.json.JSONArray().apply {
put(JSONObject().apply {
@ -552,8 +579,17 @@ class TtsService : MediaSessionService() {
TtsAudioData(audioFile = cachedFile, serverText = text, wordTimings = emptyList(), error = null, streamUri = null)
} else {
try {
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken)
liveClient.generateChunk(text, cachedFile)
val directGeminiApiKey = if (isByokCloudTtsAvailable(this@TtsService)) {
loadAiByokSettings(this@TtsService).geminiKey
} else {
null
}
if (directGeminiApiKey.isNullOrBlank() && googleCloudWorkerTtsUrl.isBlank()) {
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = "Cloud TTS is not configured.")
} else {
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken, directGeminiApiKey)
liveClient.generateChunk(text, cachedFile)
}
} catch (e: Exception) {
Timber.tag("TTS_CLOUD_DIAG").e(e, "Cloud TTS generation failed")
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = e.message ?: "Failed to connect to TTS service")
@ -567,6 +603,11 @@ class TtsService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
Timber.d("TtsService created.")
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"TtsService onCreate. sdk=${Build.VERSION.SDK_INT}, hasPostNotifications=$hasNotificationPermission"
)
cacheManager = TtsCacheManager(this)
@ -622,6 +663,7 @@ class TtsService : MediaSessionService() {
.setHandleAudioBecomingNoisy(true)
.setMediaSourceFactory(androidx.media3.exoplayer.source.DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory))
.build()
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("ExoPlayer created for TTS service.")
playbackManager = TtsPlaybackManager(
player = player,
@ -634,21 +676,30 @@ class TtsService : MediaSessionService() {
.build()
mediaSession?.let { playbackManager.setMediaSession(it) }
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("MediaSession created and attached to playback manager. sessionAvailable=${mediaSession != null}")
}
override fun onTaskRemoved(rootIntent: Intent?) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onTaskRemoved. playWhenReady=${if (::player.isInitialized) player.playWhenReady else null}, isPlaying=${if (::player.isInitialized) player.isPlaying else null}"
)
if (!player.playWhenReady) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().")
stopSelf()
}
Timber.d("onTaskRemoved called, stopping service.")
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onGetSession. package=${controllerInfo.packageName}, sessionAvailable=${mediaSession != null}"
)
return mediaSession
}
override fun onDestroy() {
Timber.d("TtsService is being destroyed.")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.")
baseTtsSynthesizer.shutdown()
playbackManager.release()
mediaSession?.run {
@ -658,4 +709,4 @@ class TtsService : MediaSessionService() {
}
super.onDestroy()
}
}
}

View file

@ -394,4 +394,4 @@ fun createWavHeaderUnknownLength(sampleRate: Int): ByteArray {
header.putInt(0x7FFFFFFF - 36)
return header.array()
}
}

View file

@ -102,6 +102,7 @@
<!-- "Google Drive" is a brand name — do not translate. -->
<string name="drawer_backup_desc">Upload books from your synced folders to Google Drive.</string>
<string name="drawer_custom_fonts">Custom Fonts</string>
<string name="drawer_support_project">Support the Project</string>
<string name="drawer_help_feedback">Help &amp; Feedback</string>
<string name="drawer_sign_out">Sign Out</string>
@ -317,6 +318,15 @@
<string name="email_support">Email Support</string>
<string name="email_support_desc">Contact us directly via email for any other inquiries.</string>
<!-- Support Project -->
<string name="support_project_title">Support the Project</string>
<string name="support_project_heading">Help keep Episteme moving</string>
<string name="support_project_desc">Your support helps me keep maintaining and Improving Episteme for everyone!!!</string>
<string name="support_github_sponsor">Sponsor on GitHub</string>
<string name="support_github_sponsor_desc">Support development directly through GitHub Sponsors. As a thank you, you get a README shoutout in the project repo.</string>
<string name="support_patreon">Join on Patreon</string>
<string name="support_patreon_desc">As a thank you for backing the app, Patreon supporters get extra content and benefits: sneak peeks at what I am working on, early screenshots and updates, votes that help shape how new features should look and work, and a README shoutout in the project repo.</string>
<!-- Dialogs -->
<!-- "Episteme Pro" is the product tier name — do not translate "Episteme". -->
<string name="dialog_unlock_pro">Unlock Episteme Pro</string>
@ -573,10 +583,14 @@
<!-- Common.kt: ReaderThemePanel & ThemeBuilderView -->
<string name="reading_themes">Reading Themes</string>
<string name="theme_presets">Presets</string>
<string name="theme_textured_presets">Textured Presets</string>
<string name="theme_my_themes">My Themes</string>
<string name="theme_my_textured_themes">My Textured Themes</string>
<!-- "+" is a literal plus icon reference in the description. -->
<string name="theme_no_custom">No custom themes yet. Tap \'+\' to create one.</string>
<string name="theme_no_textured_custom">No custom textured themes yet.</string>
<string name="theme_new">New Theme</string>
<string name="theme_new_textured">New Textured Theme</string>
<string name="theme_edit">Edit Theme</string>
<string name="theme_name">Theme Name</string>
<!-- Short motivational quote used as preview text in the theme builder. -->
@ -587,6 +601,10 @@
<string name="theme_low_contrast_warning">⚠️ Low contrast! This might cause eye strain.</string>
<string name="theme_page_color">Page Color</string>
<string name="theme_text_color">Text Color</string>
<string name="theme_texture">Texture</string>
<string name="theme_texture_none">None</string>
<string name="theme_texture_upload">Upload</string>
<string name="theme_texture_transparency">Texture Transparency</string>
<string name="theme_color_live_preview">Live Preview</string>
<!-- Short phrase shown in the live color preview area of the theme builder. -->
<string name="theme_color_preview_text">Reading is dreaming.</string>
@ -808,7 +826,8 @@
<string name="visual_options_system_ui">System UI (Status &amp; Navigation Bars)</string>
<string name="visual_options_system_ui_desc">Control the visibility of the device\'s system bars.</string>
<string name="visual_options_progress_bar">Progress Bar</string>
<string name="visual_options_progress_bar_desc">The reading progress and chapter indicator at the bottom of the screen.</string>
<string name="visual_options_progress_bar_desc">The reading progress and chapter indicator on the reading screen.</string>
<string name="visual_options_progress_bar_position">Position</string>
<!-- "Seamless Chapter Transition" is a reading feature name. -->
<string name="visual_options_seamless_chapter">Seamless Chapter Transition</string>
<string name="visual_options_seamless_chapter_desc">Instantly load the next/previous chapter when scrolling past the end, without the pull-to-refresh animation.</string>
@ -871,6 +890,7 @@
<string name="label_paragraph_gap">Paragraph Gap</string>
<string name="label_image_size">Image Size</string>
<string name="label_horizontal_margin">Horizontal Margin</string>
<string name="label_vertical_margin">Vertical Margin</string>
<string name="label_none">None</string>
<!-- Short label for the "Original" font option in the reader settings. "Orig" is an abbreviation. -->
<string name="label_original">Orig</string>
@ -1214,6 +1234,7 @@
<string name="msg_downloading_bubble_zoom_model_progress">Downloading Bubble Zoom model… %1$d%%</string>
<string name="content_desc_exit_slider_navigation">Exit slider navigation</string>
<string name="content_desc_jump_back">Jump Back</string>
<string name="content_desc_jump_forward">Jump Forward</string>
<string name="content_desc_scroll_to_reading_page">Scroll to reading page</string>
<string name="content_desc_annotated_page">Annotated Page</string>
<string name="content_desc_close_image">Close Image</string>
@ -1261,9 +1282,9 @@
<string name="label_short">Short</string>
<string name="label_long">Long</string>
<!-- Compact TTS speed label. %1$s = formatted multiplier, e.g. 1.2. -->
<string name="tts_speed_short">Spd: %1$sx</string>
<string name="tts_speed_short">Speed: %1$sx</string>
<!-- Compact TTS pitch label. %1$s = formatted multiplier, e.g. 1.0. -->
<string name="tts_pitch_short">Ptch: %1$sx</string>
<string name="tts_pitch_short">Pitch: %1$sx</string>
<string name="content_desc_play_pause">Play/Pause</string>
<string name="content_desc_reset_speed">Reset Speed</string>
<string name="content_desc_reset_pitch">Reset Pitch</string>

View file

@ -20,4 +20,4 @@ class CloudflareRepository {
suspend fun verifyPurchase(purchaseToken: String, productId: String): Result<VerificationResponse> {
return Result.failure(Exception("Not available in OSS version"))
}
}
}

View file

@ -0,0 +1,28 @@
package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Test
class FileTypeResolverTest {
@Test
fun `transparent txt suffix preserves supported inner extension`() {
assertEquals(FileType.MD, resolveFileTypeFromName("notes.md.txt"))
assertEquals(FileType.HTML, resolveFileTypeFromName("chapter.html.txt"))
assertEquals(FileType.HTML, resolveFileTypeFromName("snippet.js.txt"))
assertEquals(FileType.EPUB, resolveFileTypeFromName("book.epub.txt"))
}
@Test
fun `plain txt remains txt when inner extension is unsupported`() {
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))
assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt"))
}
@Test
fun `extension suffix preserves transparent txt wrapper`() {
assertEquals(".md.txt", resolveFileExtensionSuffixFromName("notes.md.txt"))
assertEquals(".html.txt", resolveFileExtensionSuffixFromName("chapter.html.txt"))
assertEquals(".txt", resolveFileExtensionSuffixFromName("notes.txt"))
}
}