Merge remote-tracking branch 'origin/main'

This commit is contained in:
Hosted Weblate 2026-05-20 18:44:09 +02:00
commit b5c3d2e630
No known key found for this signature in database
GPG key ID: A3FAAA06E6569B4C
245 changed files with 37538 additions and 12460 deletions

1
.gitignore vendored
View file

@ -25,3 +25,4 @@ kcef-bundle/
kcef-bundle-linux-x64/ kcef-bundle-linux-x64/
cache/ cache/
worker/ worker/
output/

View file

@ -57,8 +57,8 @@ android {
applicationId = "com.aryan.reader" applicationId = "com.aryan.reader"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 52 versionCode = 53
versionName = "1.0.48" versionName = "1.0.49"
resourceConfigurations += configuredAppLocaleTags() resourceConfigurations += configuredAppLocaleTags()
.map { it.toAndroidResourceConfiguration() } .map { it.toAndroidResourceConfiguration() }

View file

@ -67,6 +67,24 @@
-keep class com.aryan.reader.paginatedreader.Woff2Converter { *; } -keep class com.aryan.reader.paginatedreader.Woff2Converter { *; }
# R8 can fold the large EPUB Scaffold content lambda into ChapterWebViewKt with
# hundreds of captured Compose state parameters. ART rejects that release-only
# synthetic method when opening EPUBs, so keep these file facades out of those
# optimizer passes while leaving the rest of the app minified.
-keep class com.aryan.reader.epubreader.EpubReaderScreenKt { *; }
-keep class com.aryan.reader.epubreader.EpubReaderScreenKt$* { *; }
-keep class com.aryan.reader.epubreader.ChapterWebViewKt { *; }
-keep class com.aryan.reader.epubreader.ChapterWebViewKt$* { *; }
# The PDF reader is another very large Compose surface. Keeping its file facade
# out of release optimizer folding avoids ART's compiler instruction-limit path
# and preserves the private pdfium wrapper fields read for native pointer access.
-keep class com.aryan.reader.pdf.PdfViewerScreenKt { *; }
-keep class com.aryan.reader.pdf.PdfViewerScreenKt$* { *; }
-keep class com.aryan.reader.pdf.PdfPageComposableKt { *; }
-keep class com.aryan.reader.pdf.PdfPageComposableKt$* { *; }
-keep class io.legere.pdfiumandroid.** { *; }
-dontwarn com.gemalto.jp2.** -dontwarn com.gemalto.jp2.**
# Flexmark Markdown parser rules # Flexmark Markdown parser rules

View file

@ -239,6 +239,15 @@
</intent-filter> </intent-filter>
</service> </service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider" android:authorities="${applicationId}.provider"

View file

@ -2043,6 +2043,56 @@
`); `);
} }
function parseReaderChunkInt(value, fallback) {
const parsed = parseInt(value, 10);
return isNaN(parsed) ? fallback : parsed;
}
function getReaderChunkIndex(chunkElement) {
return parseReaderChunkInt(chunkElement && chunkElement.dataset ? chunkElement.dataset.chunkIndex : null, 0);
}
function getReaderChunkElementStartIndex(chunkElement) {
const chunkIndex = getReaderChunkIndex(chunkElement);
return parseReaderChunkInt(
chunkElement && chunkElement.dataset ? chunkElement.dataset.elementStartIndex : null,
chunkIndex * 20
);
}
function getReaderChunkElementCount(chunkElement) {
return parseReaderChunkInt(
chunkElement && chunkElement.dataset ? chunkElement.dataset.elementCount : null,
20
);
}
function findReaderChunkForElementIndex(container, childNodeIndex) {
const chunks = Array.from(container.querySelectorAll(".chunk-container"));
for (let i = 0; i < chunks.length; i++) {
const chunkElement = chunks[i];
const elementStartIndex = getReaderChunkElementStartIndex(chunkElement);
const elementCount = getReaderChunkElementCount(chunkElement);
if (elementCount <= 0) continue;
if (childNodeIndex >= elementStartIndex && childNodeIndex < elementStartIndex + elementCount) {
return {
chunkElement: chunkElement,
chunkIndex: getReaderChunkIndex(chunkElement),
indexInChunk: childNodeIndex - elementStartIndex
};
}
}
const fallbackChunkIndex = Math.floor(childNodeIndex / 20);
const fallbackChunkElement = container.querySelector(`.chunk-container[data-chunk-index="${fallbackChunkIndex}"]`);
if (!fallbackChunkElement) return null;
return {
chunkElement: fallbackChunkElement,
chunkIndex: fallbackChunkIndex,
indexInChunk: childNodeIndex % 20
};
}
function resolveCfiPath(rootElement, path, requestChunkIfMissing = false) { function resolveCfiPath(rootElement, path, requestChunkIfMissing = false) {
let currentNode = rootElement; let currentNode = rootElement;
const steps = path.substring(1).split("/").map(Number); const steps = path.substring(1).split("/").map(Number);
@ -2054,10 +2104,12 @@
// Handle virtualized content container specially // Handle virtualized content container specially
if (currentNode.id === 'content-container') { if (currentNode.id === 'content-container') {
const childNodeIndex = (cfiIndex - 2) / 2; const childNodeIndex = (cfiIndex - 2) / 2;
let chunkIndex = Math.floor(childNodeIndex / 20); const chunkLookup = findReaderChunkForElementIndex(currentNode, childNodeIndex);
let indexInChunk = childNodeIndex % 20; if (!chunkLookup) return null;
let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`); let chunkIndex = chunkLookup.chunkIndex;
let indexInChunk = chunkLookup.indexInChunk;
let chunkElement = chunkLookup.chunkElement;
if (chunkElement) { if (chunkElement) {
if (chunkElement.innerHTML === "") { if (chunkElement.innerHTML === "") {
if (window.virtualization && window.virtualization.chunksData[chunkIndex]) { if (window.virtualization && window.virtualization.chunksData[chunkIndex]) {
@ -2179,8 +2231,8 @@
continue; continue;
} }
let chunkIndex = parseInt(parentNode.dataset.chunkIndex, 10); let chunkIndex = getReaderChunkIndex(parentNode);
let elementsInPrecedingChunks = chunkIndex * 20; let elementsInPrecedingChunks = getReaderChunkElementStartIndex(parentNode);
let trueIndex = elementsInPrecedingChunks + indexInChunk; let trueIndex = elementsInPrecedingChunks + indexInChunk;
let cfiIndex = trueIndex * 2 + 2; let cfiIndex = trueIndex * 2 + 2;
@ -2295,6 +2347,119 @@
console.log(TAG_BM + ": " + msg); console.log(TAG_BM + ": " + msg);
} }
function normalizeReaderImageSourceForMatch(value) {
if (!value) return "";
var normalized = String(value).split("#")[0].split("?")[0].replace(/\\/g, "/");
try {
normalized = decodeURIComponent(normalized);
} catch (e) {}
if (normalized.indexOf("file://") === 0) {
normalized = normalized.substring("file://".length);
}
return normalized.toLowerCase();
}
function getReaderImageSourceCandidates(element) {
if (!element) return [];
var values = [
element.currentSrc,
element.src,
element.href && element.href.baseVal,
element.getAttribute && element.getAttribute("src"),
element.getAttribute && element.getAttribute("href"),
element.getAttribute && element.getAttribute("xlink:href"),
element.getAttribute && element.getAttribute("data-src"),
];
return values.filter(function (value, index, array) {
return value && array.indexOf(value) === index;
});
}
function readerImageCandidateMatches(candidate, normalizedTargets) {
var normalizedCandidate = normalizeReaderImageSourceForMatch(candidate);
if (!normalizedCandidate) return false;
var candidateName = normalizedCandidate.substring(normalizedCandidate.lastIndexOf("/") + 1);
return normalizedTargets.some(function (target) {
if (!target) return false;
var targetName = target.substring(target.lastIndexOf("/") + 1);
return (
normalizedCandidate === target ||
normalizedCandidate.endsWith("/" + targetName) ||
target.endsWith("/" + candidateName) ||
(candidateName && targetName && candidateName === targetName)
);
});
}
function findReaderImageElementsBySource(source, originalSource) {
var normalizedTargets = [source, originalSource]
.map(normalizeReaderImageSourceForMatch)
.filter(Boolean);
return getReaderImageElements().filter(function (element) {
return getReaderImageSourceCandidates(element).some(function (candidate) {
return readerImageCandidateMatches(candidate, normalizedTargets);
});
});
}
function findReaderImageChunkIndex(source, originalSource) {
if (!window.virtualization || !window.virtualization.chunksData) return -1;
var normalizedTargets = [source, originalSource]
.map(normalizeReaderImageSourceForMatch)
.filter(Boolean);
var targetNames = normalizedTargets
.map(function (target) {
return target.substring(target.lastIndexOf("/") + 1);
})
.filter(Boolean);
for (var i = 0; i < window.virtualization.chunksData.length; i++) {
var chunkHtml = window.virtualization.chunksData[i];
if (!chunkHtml) continue;
var normalizedChunk = normalizeReaderImageSourceForMatch(chunkHtml);
if (
normalizedTargets.some(function (target) {
return normalizedChunk.indexOf(target) !== -1;
}) ||
targetNames.some(function (name) {
return normalizedChunk.indexOf(name) !== -1;
})
) {
return i;
}
}
return -1;
}
window.scrollToReaderImageSource = function(source, ordinal, originalSource) {
var safeOrdinal = Math.max(0, parseInt(ordinal || 0, 10) || 0);
var matches = findReaderImageElementsBySource(source, originalSource);
if (!matches.length) {
var chunkIndex = findReaderImageChunkIndex(source, originalSource);
if (chunkIndex >= 0) {
var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]');
if (chunkDiv && chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) {
chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex];
chunkDiv.style.height = "";
}
matches = findReaderImageElementsBySource(source, originalSource);
}
}
var target = matches[Math.min(safeOrdinal, Math.max(0, matches.length - 1))];
if (!target) return false;
var rect = target.getBoundingClientRect();
var targetScrollY = window.scrollY + rect.top - (window.VIEWPORT_PADDING_TOP + 10);
window.scrollTo({ top: Math.max(0, targetScrollY), behavior: "auto" });
setTimeout(function () {
if (window.reportScrollState) window.reportScrollState();
}, 80);
return true;
};
window.scrollToCfi = function(cfi) { window.scrollToCfi = function(cfi) {
let cleanCfi = cfi; let cleanCfi = cfi;

View file

@ -155,9 +155,11 @@ static FPDFAnnot_SetFlags_t set_annot_flags_func = nullptr;
static FPDFAnnot_GetFormFieldName_t get_form_field_name_func = nullptr; static FPDFAnnot_GetFormFieldName_t get_form_field_name_func = nullptr;
typedef void* (*FPDFAnnot_GetLinkedAnnot_t)(void* annot, const char* key); typedef void* (*FPDFAnnot_GetLinkedAnnot_t)(void* annot, const char* key);
typedef int (*FPDFAnnot_SetLinkedAnnot_t)(void* annot, const char* key, void* linked_annot);
typedef void (*FPDFPage_CloseAnnot_t)(void* annot); typedef void (*FPDFPage_CloseAnnot_t)(void* annot);
static FPDFAnnot_GetLinkedAnnot_t get_linked_annot_func = nullptr; static FPDFAnnot_GetLinkedAnnot_t get_linked_annot_func = nullptr;
static FPDFAnnot_SetLinkedAnnot_t set_linked_annot_func = nullptr;
static FPDFPage_CloseAnnot_t close_annot_func = nullptr; static FPDFPage_CloseAnnot_t close_annot_func = nullptr;
static FPDF_LoadDocument_t load_document_func = nullptr; static FPDF_LoadDocument_t load_document_func = nullptr;
static FPDF_CloseDocument_t close_document_func = nullptr; static FPDF_CloseDocument_t close_document_func = nullptr;
@ -219,6 +221,7 @@ static bool init_pdfium() {
get_annot_string_func = (FPDFAnnot_GetStringValue_t) dlsym(pdfium_handle, "FPDFAnnot_GetStringValue"); get_annot_string_func = (FPDFAnnot_GetStringValue_t) dlsym(pdfium_handle, "FPDFAnnot_GetStringValue");
get_annot_color_func = (FPDFAnnot_GetColor_t) dlsym(pdfium_handle, "FPDFAnnot_GetColor"); get_annot_color_func = (FPDFAnnot_GetColor_t) dlsym(pdfium_handle, "FPDFAnnot_GetColor");
get_linked_annot_func = (FPDFAnnot_GetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_GetLinkedAnnot"); get_linked_annot_func = (FPDFAnnot_GetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_GetLinkedAnnot");
set_linked_annot_func = (FPDFAnnot_SetLinkedAnnot_t) dlsym(pdfium_handle, "FPDFAnnot_SetLinkedAnnot");
close_annot_func = (FPDFPage_CloseAnnot_t) dlsym(pdfium_handle, "FPDFPage_CloseAnnot"); close_annot_func = (FPDFPage_CloseAnnot_t) dlsym(pdfium_handle, "FPDFPage_CloseAnnot");
get_annot_flags_func = (FPDFAnnot_GetFlags_t) dlsym(pdfium_handle, "FPDFAnnot_GetFlags"); get_annot_flags_func = (FPDFAnnot_GetFlags_t) dlsym(pdfium_handle, "FPDFAnnot_GetFlags");
set_annot_flags_func = (FPDFAnnot_SetFlags_t) dlsym(pdfium_handle, "FPDFAnnot_SetFlags"); set_annot_flags_func = (FPDFAnnot_SetFlags_t) dlsym(pdfium_handle, "FPDFAnnot_SetFlags");
@ -605,6 +608,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jcl
return result; return result;
} }
static constexpr int kPdfAnnotText = 1;
static constexpr int kPdfAnnotHighlight = 9; static constexpr int kPdfAnnotHighlight = 9;
static constexpr int kPdfAnnotInk = 15; static constexpr int kPdfAnnotInk = 15;
static constexpr int kAnnotColor = 0; static constexpr int kAnnotColor = 0;
@ -669,6 +673,10 @@ static std::vector<std::string> read_string_array(JNIEnv* env, jobjectArray arra
return values; return values;
} }
static int object_array_length(JNIEnv* env, jobjectArray array) {
return array ? env->GetArrayLength(array) : 0;
}
static bool set_annot_string_from_jstring(JNIEnv* env, void* annot, const char* key, jstring value) { static bool set_annot_string_from_jstring(JNIEnv* env, void* annot, const char* key, jstring value) {
if (!set_annot_string_value_func || !annot || !key || !value) return false; if (!set_annot_string_value_func || !annot || !key || !value) return false;
jsize length = env->GetStringLength(value); jsize length = env->GetStringLength(value);
@ -685,6 +693,22 @@ static bool set_annot_string_from_jstring(JNIEnv* env, void* annot, const char*
return set_annot_string_value_func(annot, key, wide.data()) != 0; return set_annot_string_value_func(annot, key, wide.data()) != 0;
} }
static bool set_annot_string_from_array(JNIEnv* env, void* annot, const char* key, jobjectArray array, size_t index) {
if (!array) return false;
jsize length = env->GetArrayLength(array);
if (index >= static_cast<size_t>(length)) return false;
auto value = static_cast<jstring>(env->GetObjectArrayElement(array, static_cast<jsize>(index)));
if (!value) return false;
if (env->GetStringLength(value) <= 0) {
env->DeleteLocalRef(value);
return false;
}
bool result = set_annot_string_from_jstring(env, annot, key, value);
env->DeleteLocalRef(value);
return result;
}
static bool set_annot_string_from_ascii(void* annot, const char* key, const std::string& value) { static bool set_annot_string_from_ascii(void* annot, const char* key, const std::string& value) {
if (!set_annot_string_value_func || !annot || !key) return false; if (!set_annot_string_value_func || !annot || !key) return false;
std::vector<unsigned short> wide(value.size() + 1); std::vector<unsigned short> wide(value.size() + 1);
@ -718,6 +742,20 @@ static FS_RECTF_BRIDGE make_pdf_rect(float left, float top, float right, float b
return FS_RECTF_BRIDGE{l, t, r, b}; return FS_RECTF_BRIDGE{l, t, r, b};
} }
static FS_RECTF_BRIDGE make_pdf_comment_rect(
float anchorRight,
float anchorTop,
float pageWidth,
float pageHeight,
int commentIndex) {
float iconSize = std::min(18.0f, std::max(10.0f, pageWidth * 0.03f));
float left = std::min(std::max(anchorRight + 2.0f, 0.0f), std::max(0.0f, pageWidth - iconSize));
float top = anchorTop - static_cast<float>(commentIndex) * (iconSize + 2.0f);
if (top > pageHeight) top = pageHeight;
if (top - iconSize < 0.0f) top = std::min(pageHeight, iconSize);
return make_pdf_rect(left, top, left + iconSize, top - iconSize, 0.0f);
}
static float get_page_width_bridge(void* page) { static float get_page_width_bridge(void* page) {
if (get_page_width_func) return get_page_width_func(page); if (get_page_width_func) return get_page_width_func(page);
if (get_page_width_double_func) return static_cast<float>(get_page_width_double_func(page)); if (get_page_width_double_func) return static_cast<float>(get_page_width_double_func(page));
@ -1066,6 +1104,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
jintArray inkPointOffsetsArray, jintArray inkPointOffsetsArray,
jintArray inkPointCountsArray, jintArray inkPointCountsArray,
jfloatArray inkPointsArray, jfloatArray inkPointsArray,
jobjectArray inkNamesArray,
jobjectArray inkContentsArray,
jintArray textPageIndicesArray, jintArray textPageIndicesArray,
jfloatArray textBoundsArray, jfloatArray textBoundsArray,
jintArray textColorsArray, jintArray textColorsArray,
@ -1086,7 +1126,16 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
jintArray highlightRectOffsetsArray, jintArray highlightRectOffsetsArray,
jintArray highlightRectCountsArray, jintArray highlightRectCountsArray,
jfloatArray highlightRectsArray, jfloatArray highlightRectsArray,
jobjectArray highlightContentsArray) { jobjectArray highlightNamesArray,
jobjectArray highlightContentsArray,
jintArray highlightCommentOffsetsArray,
jintArray highlightCommentCountsArray,
jintArray highlightCommentParentIndicesArray,
jobjectArray highlightCommentNamesArray,
jobjectArray highlightCommentAuthorsArray,
jobjectArray highlightCommentContentsArray,
jobjectArray highlightCommentCreatedDatesArray,
jobjectArray highlightCommentModifiedDatesArray) {
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex); std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
if (!init_pdfium() || !validate_export_functions()) { if (!init_pdfium() || !validate_export_functions()) {
@ -1142,6 +1191,14 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
std::vector<jint> highlightRectOffsets = read_int_array(env, highlightRectOffsetsArray); std::vector<jint> highlightRectOffsets = read_int_array(env, highlightRectOffsetsArray);
std::vector<jint> highlightRectCounts = read_int_array(env, highlightRectCountsArray); std::vector<jint> highlightRectCounts = read_int_array(env, highlightRectCountsArray);
std::vector<jfloat> highlightRects = read_float_array(env, highlightRectsArray); std::vector<jfloat> highlightRects = read_float_array(env, highlightRectsArray);
std::vector<jint> highlightCommentOffsets = read_int_array(env, highlightCommentOffsetsArray);
std::vector<jint> highlightCommentCounts = read_int_array(env, highlightCommentCountsArray);
std::vector<jint> highlightCommentParentIndices = read_int_array(env, highlightCommentParentIndicesArray);
const int highlightCommentNamesLength = object_array_length(env, highlightCommentNamesArray);
const int highlightCommentAuthorsLength = object_array_length(env, highlightCommentAuthorsArray);
const int highlightCommentContentsLength = object_array_length(env, highlightCommentContentsArray);
const int highlightCommentCreatedDatesLength = object_array_length(env, highlightCommentCreatedDatesArray);
const int highlightCommentModifiedDatesLength = object_array_length(env, highlightCommentModifiedDatesArray);
void* document = load_document_func(source.c_str(), nullptr); void* document = load_document_func(source.c_str(), nullptr);
if (!document) { if (!document) {
@ -1247,7 +1304,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
hadFailure = true; hadFailure = true;
} }
set_annot_string_from_ascii(annot, "Contents", "Ink"); set_annot_string_from_array(env, annot, "NM", inkNamesArray, i);
set_annot_string_from_array(env, annot, "Contents", inkContentsArray, i);
if (generate_content_func) generate_content_func(page); if (generate_content_func) generate_content_func(page);
close_annot_func(annot); close_annot_func(annot);
close_page_func(page); close_page_func(page);
@ -1268,6 +1326,20 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
continue; continue;
} }
void* page = load_page_func(document, pageIndex);
if (!page) {
hadFailure = true;
continue;
}
float pageWidth = get_page_width_bridge(page);
float pageHeight = get_page_height_bridge(page);
if (pageWidth <= 0.0f || pageHeight <= 0.0f) {
close_page_func(page);
hadFailure = true;
continue;
}
std::vector<FS_QUADPOINTSF_BRIDGE> quads; std::vector<FS_QUADPOINTSF_BRIDGE> quads;
quads.reserve(static_cast<size_t>(rectCount)); quads.reserve(static_cast<size_t>(rectCount));
float unionLeft = 0.0f; float unionLeft = 0.0f;
@ -1279,31 +1351,32 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
int sourceIndex = (rectOffset + j) * 4; int sourceIndex = (rectOffset + j) * 4;
float left = std::min(highlightRects[sourceIndex], highlightRects[sourceIndex + 2]); float left = std::min(highlightRects[sourceIndex], highlightRects[sourceIndex + 2]);
float right = std::max(highlightRects[sourceIndex], highlightRects[sourceIndex + 2]); float right = std::max(highlightRects[sourceIndex], highlightRects[sourceIndex + 2]);
float top = std::max(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]); float top = std::min(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]);
float bottom = std::min(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]); float bottom = std::max(highlightRects[sourceIndex + 1], highlightRects[sourceIndex + 3]);
if (right <= left || top <= bottom) continue; if (right <= left || bottom <= top) continue;
quads.push_back(FS_QUADPOINTSF_BRIDGE{left, top, right, top, left, bottom, right, bottom}); float pdfLeft = clamp_unit(left) * pageWidth;
float pdfRight = clamp_unit(right) * pageWidth;
float pdfTop = (1.0f - clamp_unit(top)) * pageHeight;
float pdfBottom = (1.0f - clamp_unit(bottom)) * pageHeight;
if (pdfRight <= pdfLeft || pdfTop <= pdfBottom) continue;
quads.push_back(FS_QUADPOINTSF_BRIDGE{pdfLeft, pdfTop, pdfRight, pdfTop, pdfLeft, pdfBottom, pdfRight, pdfBottom});
if (quads.size() == 1) { if (quads.size() == 1) {
unionLeft = left; unionLeft = pdfLeft;
unionRight = right; unionRight = pdfRight;
unionTop = top; unionTop = pdfTop;
unionBottom = bottom; unionBottom = pdfBottom;
} else { } else {
unionLeft = std::min(unionLeft, left); unionLeft = std::min(unionLeft, pdfLeft);
unionRight = std::max(unionRight, right); unionRight = std::max(unionRight, pdfRight);
unionTop = std::max(unionTop, top); unionTop = std::max(unionTop, pdfTop);
unionBottom = std::min(unionBottom, bottom); unionBottom = std::min(unionBottom, pdfBottom);
} }
} }
if (quads.empty()) { if (quads.empty()) {
hadFailure = true; close_page_func(page);
continue;
}
void* page = load_page_func(document, pageIndex);
if (!page) {
hadFailure = true; hadFailure = true;
continue; continue;
} }
@ -1329,15 +1402,66 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_exportAnnotatedPdf(
set_annot_color_func(annot, kAnnotColor, r, g, b, a); set_annot_color_func(annot, kAnnotColor, r, g, b, a);
if (set_annot_flags_func) set_annot_flags_func(annot, kAnnotFlagPrint); if (set_annot_flags_func) set_annot_flags_func(annot, kAnnotFlagPrint);
if (highlightContentsArray && i < static_cast<size_t>(env->GetArrayLength(highlightContentsArray))) { set_annot_string_from_array(env, annot, "NM", highlightNamesArray, i);
auto content = static_cast<jstring>(env->GetObjectArrayElement(highlightContentsArray, static_cast<jsize>(i))); set_annot_string_from_array(env, annot, "Contents", highlightContentsArray, i);
if (content) {
set_annot_string_from_jstring(env, annot, "Contents", content); int commentOffset = i < highlightCommentOffsets.size() ? highlightCommentOffsets[i] : 0;
env->DeleteLocalRef(content); int commentCount = i < highlightCommentCounts.size() ? highlightCommentCounts[i] : 0;
bool commentPayloadValid = commentCount <= 0 ||
(commentOffset >= 0 &&
commentOffset + commentCount <= static_cast<int>(highlightCommentParentIndices.size()) &&
commentOffset + commentCount <= highlightCommentNamesLength &&
commentOffset + commentCount <= highlightCommentAuthorsLength &&
commentOffset + commentCount <= highlightCommentContentsLength &&
commentOffset + commentCount <= highlightCommentCreatedDatesLength &&
commentOffset + commentCount <= highlightCommentModifiedDatesLength);
if (!commentPayloadValid) {
hadFailure = true;
commentCount = 0;
}
std::vector<void*> commentAnnots;
commentAnnots.resize(static_cast<size_t>(std::max(0, commentCount)), nullptr);
for (int commentIndex = 0; commentIndex < commentCount; commentIndex++) {
int globalCommentIndex = commentOffset + commentIndex;
void* commentAnnot = create_annot_func(page, kPdfAnnotText);
if (!commentAnnot) {
hadFailure = true;
continue;
}
commentAnnots[static_cast<size_t>(commentIndex)] = commentAnnot;
FS_RECTF_BRIDGE commentRect = make_pdf_comment_rect(
unionRight,
unionTop,
pageWidth,
pageHeight,
0
);
set_annot_rect_func(commentAnnot, &commentRect);
set_annot_color_func(commentAnnot, kAnnotColor, r, g, b, 255);
if (set_annot_flags_func) set_annot_flags_func(commentAnnot, kAnnotFlagPrint);
set_annot_string_from_array(env, commentAnnot, "NM", highlightCommentNamesArray, globalCommentIndex);
set_annot_string_from_array(env, commentAnnot, "T", highlightCommentAuthorsArray, globalCommentIndex);
set_annot_string_from_array(env, commentAnnot, "Contents", highlightCommentContentsArray, globalCommentIndex);
set_annot_string_from_array(env, commentAnnot, "CreationDate", highlightCommentCreatedDatesArray, globalCommentIndex);
set_annot_string_from_array(env, commentAnnot, "M", highlightCommentModifiedDatesArray, globalCommentIndex);
int parentIndex = highlightCommentParentIndices[static_cast<size_t>(globalCommentIndex)];
void* parentAnnot = annot;
if (parentIndex >= 0 && parentIndex < commentIndex) {
void* candidate = commentAnnots[static_cast<size_t>(parentIndex)];
if (candidate) parentAnnot = candidate;
}
if (set_linked_annot_func && parentAnnot) {
set_linked_annot_func(commentAnnot, "IRT", parentAnnot);
} }
} }
if (generate_content_func) generate_content_func(page); if (generate_content_func) generate_content_func(page);
for (void* commentAnnot : commentAnnots) {
if (commentAnnot) close_annot_func(commentAnnot);
}
close_annot_func(annot); close_annot_func(annot);
close_page_func(page); close_page_func(page);
} }

View file

@ -24,6 +24,7 @@ fun androidSettingsHubInput(
isDebugBuild = isDebugBuild, isDebugBuild = isDebugBuild,
isSignedIn = uiState.currentUser != null, isSignedIn = uiState.currentUser != null,
isProUser = uiState.isProUser, isProUser = uiState.isProUser,
accountAvailable = supportsSync,
syncAvailable = supportsSync, syncAvailable = supportsSync,
folderSyncAvailable = supportsSync, folderSyncAvailable = supportsSync,
aiSettingsAvailable = supportsOssAiKeys, aiSettingsAvailable = supportsOssAiKeys,
@ -36,6 +37,7 @@ fun androidSettingsHubInput(
includeRecentLimit = true, includeRecentLimit = true,
includeCustomFonts = true, includeCustomFonts = true,
includeStrictFileFilter = true, includeStrictFileFilter = true,
includePdfFileNameDisplayName = true,
includeHideReaderAi = !isOfflineBuild, includeHideReaderAi = !isOfflineBuild,
includeCloudLocalDataClear = supportsSync, includeCloudLocalDataClear = supportsSync,
supportProjectAvailable = isOssBuild, supportProjectAvailable = isOssBuild,
@ -43,6 +45,7 @@ fun androidSettingsHubInput(
isSyncEnabled = uiState.isSyncEnabled, isSyncEnabled = uiState.isSyncEnabled,
isFolderSyncEnabled = uiState.isFolderSyncEnabled, isFolderSyncEnabled = uiState.isFolderSyncEnabled,
useStrictFileFilter = uiState.useStrictFileFilter, useStrictFileFilter = uiState.useStrictFileFilter,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
isScreenCaptureProtectionEnabled = uiState.isScreenCaptureProtectionEnabled, isScreenCaptureProtectionEnabled = uiState.isScreenCaptureProtectionEnabled,
hideReaderAi = hideReaderAi hideReaderAi = hideReaderAi
) )

View file

@ -71,8 +71,8 @@ internal object AndroidSharedStateBridge {
val reduced = current.toBridgeSharedState(projectedState).reduce(action) val reduced = current.toBridgeSharedState(projectedState).reduce(action)
return current.copy( return current.copy(
searchQuery = reduced.searchQuery, searchQuery = reduced.searchQuery,
sortOrder = reduced.sortOrder.toAndroidSortOrder(), sortOrder = reduced.sortOrder,
libraryFilters = reduced.libraryFilters.toAndroidLibraryFilters(), libraryFilters = reduced.libraryFilters,
contextualActionItems = reduced.selectedBookIds.mapNotNullTo(mutableSetOf()) { androidBooksById[it] }, contextualActionItems = reduced.selectedBookIds.mapNotNullTo(mutableSetOf()) { androidBooksById[it] },
contextualActionShelfIds = reduced.selectedShelfIds, contextualActionShelfIds = reduced.selectedShelfIds,
libraryScreenStartPage = reduced.libraryScreenStartPage, libraryScreenStartPage = reduced.libraryScreenStartPage,
@ -87,12 +87,13 @@ internal object AndroidSharedStateBridge {
): ReaderScreenState { ): ReaderScreenState {
val reduced = current.toBridgeSharedState(projectedState).reduce(action) val reduced = current.toBridgeSharedState(projectedState).reduce(action)
return current.copy( return current.copy(
appThemeMode = reduced.appThemeMode.toAndroidAppThemeMode(), appThemeMode = reduced.appThemeMode,
appContrastOption = reduced.appContrastOption.toAndroidAppContrastOption(), appContrastOption = reduced.appContrastOption,
appTextDimFactorLight = reduced.appTextDimFactorLight, appTextDimFactorLight = reduced.appTextDimFactorLight,
appTextDimFactorDark = reduced.appTextDimFactorDark, appTextDimFactorDark = reduced.appTextDimFactorDark,
appSeedColor = reduced.appSeedColor, appSeedColor = reduced.appSeedColor,
customAppThemes = reduced.customAppThemes.map { it.toAndroidCustomAppTheme() } appFontPreference = reduced.appFontPreference,
customAppThemes = reduced.customAppThemes
) )
} }

View file

@ -0,0 +1,22 @@
package com.aryan.reader
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import com.aryan.reader.data.CustomFontEntity
import java.io.File
fun AppFontPreference.toAndroidAppFontFamily(customFonts: List<CustomFontEntity>): FontFamily? {
val sanitized = sanitized()
return when (sanitized.kind) {
AppFontPreferenceKind.SYSTEM -> null
AppFontPreferenceKind.SERIF -> FontFamily.Serif
AppFontPreferenceKind.SANS_SERIF -> FontFamily.SansSerif
AppFontPreferenceKind.MONOSPACE -> FontFamily.Monospace
AppFontPreferenceKind.CUSTOM -> {
val fontId = sanitized.customFontId ?: return null
val font = customFonts.firstOrNull { it.id == fontId && !it.isDeleted } ?: return null
val file = File(font.path).takeIf { it.isFile } ?: return null
runCatching { FontFamily(Font(file)) }.getOrNull()
}
}
}

View file

@ -22,13 +22,21 @@ package com.aryan.reader
import android.os.Build import android.os.Build
import timber.log.Timber import timber.log.Timber
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@ -54,6 +62,10 @@ import com.aryan.reader.epubreader.EpubReaderScreen
import com.aryan.reader.feedback.FeedbackScreen import com.aryan.reader.feedback.FeedbackScreen
import com.aryan.reader.feedback.SupportProjectScreen import com.aryan.reader.feedback.SupportProjectScreen
import com.aryan.reader.pdf.PdfViewerScreen import com.aryan.reader.pdf.PdfViewerScreen
import com.aryan.reader.shared.ReaderFeatureSurface
import com.aryan.reader.tts.ReaderTtsMiniBar
import com.aryan.reader.tts.readerTtsMiniBarBottomPaddingDp
import com.aryan.reader.tts.shouldShowReaderTtsMiniBar
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
object AppDestinations { object AppDestinations {
@ -143,18 +155,30 @@ fun AppNavigation(
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val currentBackStackEntry by navController.currentBackStackEntryAsState() val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackStackEntry?.destination?.route val currentRoute = currentBackStackEntry?.destination?.route
val ttsController = viewModel.ttsController
val ttsState by ttsController.ttsState.collectAsStateWithLifecycle()
val isOnReaderRoute = currentRoute == AppDestinations.PDF_VIEWER_ROUTE ||
currentRoute == AppDestinations.EPUB_READER_ROUTE
val showTtsMiniBar = shouldShowReaderTtsMiniBar(
ttsState = ttsState,
isOnReaderRoute = isOnReaderRoute
)
val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp(
isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE
).dp
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) { if (!uiState.isLoading) {
when (uiState.selectedFileType) { when (uiState.selectedFileType?.readerSurfaceOnAndroid()) {
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.PPTX -> { ReaderFeatureSurface.PDF_VIEWER -> {
if (uiState.selectedPdfUri != null) { if (uiState.selectedPdfUri != null) {
if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) { if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) {
navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE) navController.syncRouteTo(AppDestinations.PDF_VIEWER_ROUTE)
} }
} }
} }
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> { ReaderFeatureSurface.EPUB_READER,
ReaderFeatureSurface.TEXT_READER -> {
if (uiState.selectedEpubBook != null) { if (uiState.selectedEpubBook != null) {
if (currentRoute != AppDestinations.EPUB_READER_ROUTE) { if (currentRoute != AppDestinations.EPUB_READER_ROUTE) {
navController.syncRouteTo(AppDestinations.EPUB_READER_ROUTE) navController.syncRouteTo(AppDestinations.EPUB_READER_ROUTE)
@ -166,16 +190,12 @@ fun AppNavigation(
navController.syncRouteTo(AppDestinations.MAIN_ROUTE) navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
} }
} }
FileType.UNKNOWN -> {
if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) {
navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
}
}
} }
} }
} }
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) { Box(modifier = Modifier.fillMaxSize()) {
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
composable(AppDestinations.MAIN_ROUTE) { composable(AppDestinations.MAIN_ROUTE) {
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).") Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
MainScreen( MainScreen(
@ -379,5 +399,33 @@ fun AppNavigation(
onBackClick = { navController.popBackStackIfReady() } onBackClick = { navController.popBackStackIfReady() }
) )
} }
}
AnimatedVisibility(
visible = showTtsMiniBar,
enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)),
exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)),
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, bottom = miniBarBottomPadding)
) {
ReaderTtsMiniBar(
ttsController = ttsController,
ttsState = ttsState,
onOpenReader = {
ttsState.bookId?.let { bookId ->
viewModel.openTtsNotificationTarget(
bookId = bookId,
sourceCfi = ttsState.sourceCfi,
startOffset = ttsState.startOffsetInSource.takeIf { it >= 0 },
chapterIndex = ttsState.chapterIndex,
pageIndex = ttsState.pageIndex
)
}
},
modifier = Modifier.fillMaxWidth()
)
}
} }
} }

View file

@ -12,6 +12,8 @@ typealias BannerMessage = com.aryan.reader.shared.BannerMessage
typealias UserData = com.aryan.reader.shared.UserData typealias UserData = com.aryan.reader.shared.UserData
typealias AppThemeMode = com.aryan.reader.shared.AppThemeMode typealias AppThemeMode = com.aryan.reader.shared.AppThemeMode
typealias AppContrastOption = com.aryan.reader.shared.AppContrastOption typealias AppContrastOption = com.aryan.reader.shared.AppContrastOption
typealias AppFontPreference = com.aryan.reader.shared.AppFontPreference
typealias AppFontPreferenceKind = com.aryan.reader.shared.AppFontPreferenceKind
typealias CustomAppTheme = com.aryan.reader.shared.CustomAppTheme typealias CustomAppTheme = com.aryan.reader.shared.CustomAppTheme
data class ImportResult( data class ImportResult(
@ -50,6 +52,8 @@ data class ReaderScreenState(
val initialBookmarksJson: String? = null, val initialBookmarksJson: String? = null,
val initialHighlightsJson: String? = null, val initialHighlightsJson: String? = null,
val initialPageInBook: Int? = null, val initialPageInBook: Int? = null,
val initialPageInBookIsExplicit: Boolean = false,
val isOpeningFromTtsNotification: Boolean = false,
val shelves: List<Shelf> = emptyList(), val shelves: List<Shelf> = emptyList(),
val viewingShelfId: String? = null, val viewingShelfId: String? = null,
val isAddingBooksToShelf: Boolean = false, val isAddingBooksToShelf: Boolean = false,
@ -95,11 +99,13 @@ data class ReaderScreenState(
val showExternalFileSavePromptFor: String? = null, val showExternalFileSavePromptFor: String? = null,
val externalFileBehavior: String = "ASK", val externalFileBehavior: String = "ASK",
val useStrictFileFilter: Boolean = false, val useStrictFileFilter: Boolean = false,
val usePdfFileNameAsDisplayName: Boolean = false,
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM, val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD, val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
val appTextDimFactorLight: Float = 1.0f, val appTextDimFactorLight: Float = 1.0f,
val appTextDimFactorDark: Float = 1.0f, val appTextDimFactorDark: Float = 1.0f,
val appSeedColor: androidx.compose.ui.graphics.Color? = null, val appSeedColor: androidx.compose.ui.graphics.Color? = null,
val appFontPreference: AppFontPreference = AppFontPreference.System,
val customAppThemes: List<CustomAppTheme> = emptyList(), val customAppThemes: List<CustomAppTheme> = emptyList(),
val allTags: List<TagEntity> = emptyList(), val allTags: List<TagEntity> = emptyList(),
val showTagSelectionDialogFor: Set<String> = emptySet(), val showTagSelectionDialogFor: Set<String> = emptySet(),

View file

@ -29,6 +29,7 @@ import java.io.FileOutputStream
import java.io.InputStream import java.io.InputStream
import java.util.UUID import java.util.UUID
import androidx.core.net.toUri import androidx.core.net.toUri
import com.aryan.reader.shared.SharedFileCapabilities
private const val BOOKS_DIR = "books" private const val BOOKS_DIR = "books"
@ -103,15 +104,18 @@ class BookImporter(private val context: Context) {
} }
private fun getFileExtension(uri: Uri): String { private fun getFileExtension(uri: Uri): String {
val path = uri.path ?: return "tmp" val path = uri.path
return File(path).extension.lowercase().ifEmpty { val pathExtension = path
// Fallback for URIs that don't have a clear extension in the path ?.let(::File)
when (context.contentResolver.getType(uri)) { ?.extension
"application/pdf" -> "pdf" ?.lowercase()
"application/epub+zip" -> "epub" ?.takeIf { it.isNotBlank() }
"application/vnd.openxmlformats-officedocument.presentationml.presentation" -> "pptx" if (pathExtension != null) return pathExtension
else -> "tmp"
} val metadataType = SharedFileCapabilities.resolveFileTypeForMetadata(
} fileName = uri.lastPathSegment ?: path,
mimeType = context.contentResolver.getType(uri)
)
return metadataType?.let(SharedFileCapabilities::primaryExtensionFor) ?: "tmp"
} }
} }

View file

@ -169,6 +169,11 @@ import com.aryan.reader.epubreader.PREF_CUSTOM_THEMES
import com.aryan.reader.epubreader.PREF_READER_THEME import com.aryan.reader.epubreader.PREF_READER_THEME
import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.pdf.PdfHighlightColor import com.aryan.reader.pdf.PdfHighlightColor
import com.aryan.reader.shared.BuiltInReaderThemes
import com.aryan.reader.shared.ReaderTextureFilePrefix
import com.aryan.reader.shared.normalizeReaderTextureExtension
import com.aryan.reader.shared.readerTextureDisplayName as sharedReaderTextureDisplayName
import com.aryan.reader.shared.readerTextureMimeTypeForExtension
import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsCacheManager import com.aryan.reader.tts.TtsCacheManager
@ -212,6 +217,9 @@ import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt import kotlin.math.sqrt
typealias ReaderTexture = com.aryan.reader.shared.ReaderTexture
typealias ReaderTheme = com.aryan.reader.shared.ReaderTheme
const val aiServerBasePath = BuildConfig.AI_WORKER_URL const val aiServerBasePath = BuildConfig.AI_WORKER_URL
const val summarizeEndpoint = "/summarize" const val summarizeEndpoint = "/summarize"
const val summarizationUrl = aiServerBasePath + summarizeEndpoint const val summarizationUrl = aiServerBasePath + summarizeEndpoint
@ -465,18 +473,9 @@ data class SearchResult(
val chunkIndex: Int val chunkIndex: Int
) )
data class AiDefinitionResult( typealias AiDefinitionResult = com.aryan.reader.shared.AiDefinitionResult
val definition: String? = null,
val error: String? = null
)
data class SummarizationResult( typealias SummarizationResult = com.aryan.reader.shared.SummarizationResult
val summary: String? = null,
val error: String? = null,
val cost: Double? = null,
val freeRemaining: Int? = null,
val isCacheHit: Boolean = false
)
data class CachedSummaryItem( data class CachedSummaryItem(
val chapterIndex: Int, val chapterIndex: Int,
@ -2449,51 +2448,26 @@ fun ColorComparePill(
} }
} }
enum class ReaderTexture(val id: String, val displayName: String, val assetPath: String? = null, val resId: Int? = null) {
NATURAL_WHITE("asset:ep_naturalwhite.webp", "Natural White", "textures/ep_naturalwhite.webp"),
NATURAL_BLACK("asset:ep_naturalblack.webp", "Natural Black", "textures/ep_naturalblack.webp"),
LIGHT_VENEER("asset:light-veneer.webp", "Light Veneer", "textures/light-veneer.webp"),
RETINA_WOOD("asset:retina_wood.webp", "Retina Wood", "textures/retina_wood.webp"),
GREY_WASH("asset:grey_wash_wall.webp", "Grey Wash", "textures/grey_wash_wall.webp"),
CLASSY_FABRIC("asset:classy_fabric.webp", "Classy Fabric", "textures/classy_fabric.webp"),
RETRO_INTRO("asset:retro_intro.webp", "Retro Intro", "textures/retro_intro.webp"),
PAPER("paper", "Paper", resId = R.drawable.texture_paper),
CANVAS("canvas", "Canvas", resId = R.drawable.texture_canvas),
EINK("eink", "E-Ink", resId = R.drawable.texture_eink),
SLATE("slate", "Slate", resId = R.drawable.texture_slate)
}
private const val TEXTURE_FILE_PREFIX = "file:"
private const val READER_TEXTURE_DIR = "reader_textures" private const val READER_TEXTURE_DIR = "reader_textures"
fun readerTextureDisplayName(textureId: String?): String { fun readerTextureDisplayName(textureId: String?): String {
if (textureId == null) return "None" return sharedReaderTextureDisplayName(textureId)
return ReaderTexture.entries.find { it.id == textureId }?.displayName
?: File(textureId.removePrefix(TEXTURE_FILE_PREFIX)).nameWithoutExtension.ifBlank { "Custom Image" }
} }
fun importReaderTexture(context: Context, uri: Uri): String? { fun importReaderTexture(context: Context, uri: Uri): String? {
return try { return try {
val extension = context.contentResolver.getType(uri) val extension = normalizeReaderTextureExtension(
?.substringAfterLast('/') context.contentResolver.getType(uri)?.substringAfterLast('/')
?.lowercase(Locale.ROOT) ) ?: normalizeReaderTextureExtension(
?.let { uri.lastPathSegment?.substringAfterLast('.', "")
when (it) { )
"jpeg", "jpg" -> "jpg"
"png", "webp", "gif", "bmp" -> it
else -> null
}
} ?: uri.lastPathSegment
?.substringAfterLast('.', "")
?.lowercase(Locale.ROOT)
?.takeIf { it in setOf("jpg", "jpeg", "png", "webp", "gif", "bmp") }
?: "img" ?: "img"
val dir = File(context.filesDir, READER_TEXTURE_DIR).apply { mkdirs() } val dir = File(context.filesDir, READER_TEXTURE_DIR).apply { mkdirs() }
val output = File(dir, "texture_${System.currentTimeMillis()}.$extension") val output = File(dir, "texture_${System.currentTimeMillis()}.$extension")
context.contentResolver.openInputStream(uri)?.use { input -> context.contentResolver.openInputStream(uri)?.use { input ->
output.outputStream().use { out -> input.copyTo(out) } output.outputStream().use { out -> input.copyTo(out) }
} ?: return null } ?: return null
TEXTURE_FILE_PREFIX + output.absolutePath ReaderTextureFilePrefix + output.absolutePath
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to import reader texture") Timber.e(e, "Failed to import reader texture")
null null
@ -2564,17 +2538,17 @@ private fun calculateBitmapSampleSize(width: Int, height: Int, maxDimension: Int
fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap? { fun loadReaderTextureBitmap(context: Context, textureId: String?): ImageBitmap? {
if (textureId == null) return null if (textureId == null) return null
return try { return try {
val bitmap = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) { val bitmap = if (textureId.startsWith(ReaderTextureFilePrefix)) {
decodeSampledBitmapFile( decodeSampledBitmapFile(
path = textureId.removePrefix(TEXTURE_FILE_PREFIX), path = textureId.removePrefix(ReaderTextureFilePrefix),
maxDimension = MAX_READER_TEXTURE_DIMENSION_PX maxDimension = MAX_READER_TEXTURE_DIMENSION_PX
) )
} else { } else {
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
val resourceId = texture.androidTextureResourceId()
when { when {
texture.assetPath != null -> context.assets.open(texture.assetPath).use(BitmapFactory::decodeStream) resourceId != null -> BitmapFactory.decodeResource(context.resources, resourceId)
texture.resId != null -> BitmapFactory.decodeResource(context.resources, texture.resId) else -> context.assets.open(texture.assetPath).use(BitmapFactory::decodeStream)
else -> null
} }
} }
val safeBitmap = bitmap?.scaledToCanvasLimit( val safeBitmap = bitmap?.scaledToCanvasLimit(
@ -2595,8 +2569,8 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? {
if (textureId == null) return null if (textureId == null) return null
return try { return try {
var mimeType = "image/png" var mimeType = "image/png"
val bytes = if (textureId.startsWith(TEXTURE_FILE_PREFIX)) { val bytes = if (textureId.startsWith(ReaderTextureFilePrefix)) {
val file = File(textureId.removePrefix(TEXTURE_FILE_PREFIX)) val file = File(textureId.removePrefix(ReaderTextureFilePrefix))
mimeType = "image/png" mimeType = "image/png"
val decodedBitmap = decodeSampledBitmapFile(file.absolutePath, MAX_READER_TEXTURE_DIMENSION_PX) val decodedBitmap = decodeSampledBitmapFile(file.absolutePath, MAX_READER_TEXTURE_DIMENSION_PX)
?: return null ?: return null
@ -2617,19 +2591,19 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? {
} }
} else { } else {
val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null val texture = ReaderTexture.entries.find { it.id == textureId } ?: return null
val resourceId = texture.androidTextureResourceId()
when { when {
texture.assetPath != null -> { resourceId != null -> {
mimeType = imageMimeTypeForExtension(texture.assetPath.substringAfterLast('.', "png")) val bitmap = BitmapFactory.decodeResource(context.resources, resourceId)
context.assets.open(texture.assetPath).use { it.readBytes() }
}
texture.resId != null -> {
val bitmap = BitmapFactory.decodeResource(context.resources, texture.resId)
ByteArrayOutputStream().use { out -> ByteArrayOutputStream().use { out ->
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out) bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
out.toByteArray() out.toByteArray()
} }
} }
else -> null else -> {
mimeType = readerTextureMimeTypeForExtension(texture.assetPath.substringAfterLast('.', "png"))
context.assets.open(texture.assetPath).use { it.readBytes() }
}
} }
} ?: return null } ?: return null
"data:$mimeType;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP) "data:$mimeType;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
@ -2639,40 +2613,17 @@ fun getReaderTextureDataUri(context: Context, textureId: String?): String? {
} }
} }
private fun imageMimeTypeForExtension(extension: String): String { private fun ReaderTexture.androidTextureResourceId(): Int? {
return when (extension.lowercase(Locale.ROOT)) { return when (this) {
"jpg", "jpeg" -> "image/jpeg" ReaderTexture.PAPER -> R.drawable.texture_paper
"webp" -> "image/webp" ReaderTexture.CANVAS -> R.drawable.texture_canvas
"gif" -> "image/gif" ReaderTexture.EINK -> R.drawable.texture_eink
"bmp" -> "image/bmp" ReaderTexture.SLATE -> R.drawable.texture_slate
else -> "image/png" else -> null
} }
} }
data class ReaderTheme( val BuiltInThemes = BuiltInReaderThemes
val id: String,
val name: String,
val backgroundColor: Color,
val textColor: Color,
val isDark: Boolean,
val textureId: String? = null,
val isCustom: Boolean = false
)
val BuiltInThemes = listOf(
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false),
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
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("natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
ReaderTheme("retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
ReaderTheme("veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
ReaderTheme("grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
ReaderTheme("fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
)
fun saveReaderThemeId(context: Context, themeId: String) { fun saveReaderThemeId(context: Context, themeId: String) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
@ -2700,7 +2651,10 @@ fun getImportedTextures(context: Context): List<String> {
return try { return try {
val dir = File(context.filesDir, READER_TEXTURE_DIR) val dir = File(context.filesDir, READER_TEXTURE_DIR)
if (!dir.exists()) emptyList() if (!dir.exists()) emptyList()
else dir.listFiles()?.map { TEXTURE_FILE_PREFIX + it.absolutePath } ?: emptyList() else dir.listFiles()
?.filter { it.isFile }
?.map { ReaderTextureFilePrefix + it.absolutePath }
?: emptyList()
} catch (_: Exception) { } catch (_: Exception) {
emptyList() emptyList()
} }
@ -3181,7 +3135,7 @@ private fun TexturePickerSection(
) )
TextureChoice( TextureChoice(
label = stringResource(R.string.theme_texture_upload), label = stringResource(R.string.theme_texture_upload),
textureId = selectedTextureId?.takeIf { it.startsWith(TEXTURE_FILE_PREFIX) }, textureId = selectedTextureId?.takeIf { it.startsWith(ReaderTextureFilePrefix) },
selectedTextureId = selectedTextureId, selectedTextureId = selectedTextureId,
onTextureSelected = { onImportTexture() }, onTextureSelected = { onImportTexture() },
isUpload = true, isUpload = true,
@ -3189,7 +3143,7 @@ private fun TexturePickerSection(
) )
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
ReaderTexture.entries.filter { it.assetPath != null }.chunked(2).forEach { rowTextures -> ReaderTexture.entries.filter { it.androidTextureResourceId() == null }.chunked(2).forEach { rowTextures ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
rowTextures.forEach { texture -> rowTextures.forEach { texture ->
TextureChoice( TextureChoice(
@ -3234,7 +3188,7 @@ private fun TextureChoice(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val textureBitmap = remember(textureId) { loadReaderTextureBitmap(context, textureId) } val textureBitmap = remember(textureId) { loadReaderTextureBitmap(context, textureId) }
val selected = if (isUpload) selectedTextureId?.startsWith(TEXTURE_FILE_PREFIX) == true else selectedTextureId == textureId val selected = if (isUpload) selectedTextureId?.startsWith(ReaderTextureFilePrefix) == true else selectedTextureId == textureId
Surface( Surface(
onClick = { onTextureSelected(textureId) }, onClick = { onTextureSelected(textureId) },
modifier = modifier.height(52.dp), modifier = modifier.height(52.dp),
@ -3254,7 +3208,7 @@ private fun TextureChoice(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = if (isUpload && selectedTextureId?.startsWith(TEXTURE_FILE_PREFIX) == true) { text = if (isUpload && selectedTextureId?.startsWith(ReaderTextureFilePrefix) == true) {
readerTextureDisplayName(selectedTextureId) readerTextureDisplayName(selectedTextureId)
} else label, } else label,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@ -3868,8 +3822,11 @@ fun AiResultContentView(
val showUsageBadge = result?.isCacheHit == true || (BuildConfig.FLAVOR != "oss" && (result?.cost != null || isLoading)) val showUsageBadge = result?.isCacheHit == true || (BuildConfig.FLAVOR != "oss" && (result?.cost != null || isLoading))
if (result != null && showUsageBadge && (!result.summary.isNullOrBlank() || isLoading)) { if (result != null && showUsageBadge && (!result.summary.isNullOrBlank() || isLoading)) {
val cost = result.cost
val freeRemaining = result.freeRemaining
val isFreeGeneratedResult = cost == 0.0 && freeRemaining != null
Surface( Surface(
color = if (result.isCacheHit || (result.cost == 0.0 && result.freeRemaining != null)) Color( color = if (result.isCacheHit || isFreeGeneratedResult) Color(
0xFF4CAF50 0xFF4CAF50
).copy(alpha = 0.2f) else MaterialTheme.colorScheme.primaryContainer, ).copy(alpha = 0.2f) else MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
@ -3877,19 +3834,19 @@ fun AiResultContentView(
Text( Text(
text = if (result.isCacheHit) { text = if (result.isCacheHit) {
stringResource(R.string.ai_cache_hit_free) stringResource(R.string.ai_cache_hit_free)
} else if (result.cost != null) { } else if (cost != null) {
if (result.cost == 0.0 && result.freeRemaining != null) { if (isFreeGeneratedResult) {
stringResource(R.string.ai_generated_free_remaining, safeStringResource(R.string.ai_generated_free_remaining,
result.freeRemaining freeRemaining
) )
} else { } else {
stringResource(R.string.ai_generated_cost, result.cost.toString()) safeStringResource(R.string.ai_generated_cost, cost.toString())
} }
} else { } else {
stringResource(R.string.ai_generating_cost_calculating) stringResource(R.string.ai_generating_cost_calculating)
}, },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = if (result.isCacheHit || (result.cost == 0.0 && result.freeRemaining != null)) Color( color = if (result.isCacheHit || isFreeGeneratedResult) Color(
0xFF388E3C 0xFF388E3C
) else MaterialTheme.colorScheme.onPrimaryContainer, ) else MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)

View file

@ -7,42 +7,7 @@ internal fun resolveFileTypeFromName(fileName: String?): FileType? {
} }
internal fun resolveFileTypeFromMetadata(fileName: String?, mimeType: String?): FileType? { internal fun resolveFileTypeFromMetadata(fileName: String?, mimeType: String?): FileType? {
val normalizedMimeType = mimeType return SharedFileCapabilities.resolveFileTypeForMetadata(fileName, mimeType)
?.substringBefore(';')
?.trim()
?.lowercase()
return when (normalizedMimeType) {
"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
"application/vnd.openxmlformats-officedocument.presentationml.presentation" -> FileType.PPTX
"application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
when {
fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ
fileName?.endsWith(".fb2.zip", ignoreCase = true) == true -> FileType.FB2
else -> null
}
}
"application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> {
if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null
}
"application/x-cb7", "application/x-7z-compressed" -> {
if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null
}
"application/pdf" -> FileType.PDF
"application/epub+zip" -> FileType.EPUB
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2
"application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
"text/markdown", "text/x-markdown" -> FileType.MD
"text/html", "application/xhtml+xml" -> FileType.HTML
"text/csv", "text/comma-separated-values", "text/tab-separated-values",
"application/json", "application/xml", "text/xml",
"text/x-java-source", "text/x-python", "text/x-kotlin",
"text/javascript", "application/javascript",
"text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML
"text/plain" -> resolveFileTypeFromName(fileName) ?: FileType.TXT
else -> resolveFileTypeFromName(fileName)
}
} }
internal fun isCodeOrDataFileName(fileName: String): Boolean { internal fun isCodeOrDataFileName(fileName: String): Boolean {

View file

@ -38,6 +38,16 @@ import kotlinx.coroutines.withContext
import androidx.core.content.edit import androidx.core.content.edit
import com.aryan.reader.data.LocalSyncUtils import com.aryan.reader.data.LocalSyncUtils
import com.aryan.reader.data.FolderBookMetadata import com.aryan.reader.data.FolderBookMetadata
import com.aryan.reader.data.toSharedFolderBookMetadata
import com.aryan.reader.shared.BookItem as SharedBookItem
import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.EpubBookmark
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
import com.aryan.reader.shared.LocalFolderSyncEngine
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.SharedFolderScannedFile
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.reader.ReaderBookmark
import java.io.File import java.io.File
import android.provider.DocumentsContract import android.provider.DocumentsContract
@ -53,7 +63,6 @@ class FolderSyncWorker(
const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime" const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime"
const val KEY_METADATA_ONLY = "key_metadata_only" const val KEY_METADATA_ONLY = "key_metadata_only"
const val KEY_TARGET_FOLDER_URI = "key_target_folder_uri" const val KEY_TARGET_FOLDER_URI = "key_target_folder_uri"
private const val SCAN_DB_BATCH_SIZE = 600
private val syncMutex = Mutex() private val syncMutex = Mutex()
} }
@ -151,11 +160,7 @@ class FolderSyncWorker(
var dirsScanned = 0 var dirsScanned = 0
var filesSeen = 0 var filesSeen = 0
var supportedBooksSeen = 0 var supportedBooksSeen = 0
var newBooks = 0
var updatedBooks = 0
var unchangedBooks = 0
var dbFlushes = 0 var dbFlushes = 0
var scanDbFlushes = 0
var sidecarsImported = 0 var sidecarsImported = 0
var stoppedForUnlinkedFolder = false var stoppedForUnlinkedFolder = false
@ -179,7 +184,7 @@ class FolderSyncWorker(
return false return false
} }
ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration skipped") ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration mapped-to-shared")
val folderMetadataMap = ReaderPerfLog.measureSuspend( val folderMetadataMap = ReaderPerfLog.measureSuspend(
name = "FolderSync phase metadata-sidecars", name = "FolderSync phase metadata-sidecars",
@ -192,324 +197,148 @@ class FolderSyncWorker(
"FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString" "FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString"
) )
val preloadedSidecars = mutableMapOf<String, Pair<Long, String>>()
val existingFolderBooks = ReaderPerfLog.measureSuspend( val existingFolderBooks = ReaderPerfLog.measureSuspend(
name = "FolderSync phase load-existing-db", name = "FolderSync phase load-existing-db",
minLogMs = 25L minLogMs = 25L
) { ) {
recentFilesRepository.getFilesBySourceFolder(folderUriString) recentFilesRepository.getFilesBySourceFolder(folderUriString)
} }
val existingFolderBooksById = existingFolderBooks.associateBy { it.bookId } val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
val remoteMetadataUpdates = mutableListOf<RecentFileItem>()
folderMetadataMap.forEach { (bookId, remoteMeta) -> val scanResult = if (metadataOnly) {
val existingItem = existingFolderBooksById[bookId] AndroidFolderScanResult()
} else {
ReaderPerfLog.measureSuspend(
name = "FolderSync phase scan-folder",
minLogMs = 25L
) {
scanFolderFiles(
folderUri = folderUri,
folderUriString = folderUriString,
allowedFileTypes = allowedFileTypes
)
}
}
dirsScanned = scanResult.dirsScanned
filesSeen = scanResult.filesSeen
supportedBooksSeen = scanResult.files.size
stoppedForUnlinkedFolder = scanResult.stoppedForUnlinkedFolder
if (existingItem != null) { if (isStopped || stoppedForUnlinkedFolder) {
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) { ReaderPerfLog.w(
Timber.tag("PdfPositionDebug").w("FolderSyncWorker applies remote progress for $bookId | Local Page: ${existingItem.lastPage} -> Remote Page: ${remoteMeta.lastPage}") "FolderSync folder aborted before shared engine stopped=$isStopped " +
val itemToUpdate = existingItem.copy( "unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
lastChapterIndex = remoteMeta.lastChapterIndex, )
lastPage = remoteMeta.lastPage, return true
lastPositionCfi = remoteMeta.lastPositionCfi, }
progressPercentage = remoteMeta.progressPercentage,
bookmarksJson = remoteMeta.bookmarksJson, val nowMillis = System.currentTimeMillis()
highlightsJson = remoteMeta.highlightsJson, val folder = SyncedFolder(
customName = remoteMeta.customName, uriString = folderUriString,
locatorBlockIndex = remoteMeta.locatorBlockIndex, name = documentTree.name ?: "Local Folder",
locatorCharOffset = remoteMeta.locatorCharOffset, lastScanTime = nowMillis,
lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp, allowedFileTypes = allowedFileTypes
isRecent = remoteMeta.isRecent || existingItem.isRecent, )
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp val sharedState = SharedReaderScreenState(
) rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
remoteMetadataUpdates.add(itemToUpdate) syncedFolders = listOf(folder)
} else { )
Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}") val syncResult = LocalFolderSyncEngine.syncFolder(
} state = sharedState,
folder = folder,
files = scanResult.files,
remoteMetadata = folderMetadataMap.mapValues { it.value.toSharedFolderBookMetadata() },
nowMillis = nowMillis,
metadataOnly = metadataOnly
)
if (syncResult.idMigrations.isNotEmpty()) {
val preloadedSidecars = ReaderPerfLog.measureSuspend(
name = "FolderSync phase migration-sidecars",
minLogMs = 25L
) {
LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri).toMutableMap()
}
syncResult.idMigrations.forEach { (oldId, newId) ->
Timber.tag("FolderSync").i("Migrating folder book ID via shared engine $oldId -> $newId")
migrateFolderBookId(
folderUriString = folderUriString,
oldId = oldId,
newId = newId,
folderMetadataMap = folderMetadataMap,
preloadedSidecars = preloadedSidecars,
existingItemsMap = existingItemsMap
)
} }
} }
if (remoteMetadataUpdates.isNotEmpty()) { if (!isFolderStillLinked(folderUriString)) {
recentFilesRepository.addRecentFiles(remoteMetadataUpdates) ReaderPerfLog.w("FolderSync folder abort: folder unlinked before DB write folder=$folderUriString")
stoppedForUnlinkedFolder = true
return true
}
val scannedFilesById = scanResult.files.associateBy { it.stableBookId }
val syncedItems = syncResult.state.rawLibraryBooks.map { book ->
val existing = existingItemsMap[book.id]
val metadata = appliedMetadataFor(
book = book,
existing = existing,
metadata = folderMetadataMap[book.id]
)
book.toFolderSyncRecentFileItem(
existing = existing,
appliedMetadata = metadata,
scannedFile = scannedFilesById[book.id],
nowMillis = nowMillis
)
}
val changedItems = syncedItems.filter { item -> existingItemsMap[item.bookId] != item }
changedItems
.filter { item ->
val previous = existingItemsMap[item.bookId]
previous != null && folderFileContentChanged(previous, item)
}
.forEach { item ->
Timber.tag("FolderSync").i("File content changed for ${item.displayName}; refreshing extracted metadata.")
recentFilesRepository.clearLocalCachesForBook(item.bookId)
}
if (changedItems.isNotEmpty()) {
recentFilesRepository.addRecentFiles(changedItems)
dbFlushes++ dbFlushes++
ReaderPerfLog.d(
"FolderSync applied remote metadata updates count=${remoteMetadataUpdates.size} folder=$folderUriString"
)
} }
if (metadataOnly) { if (!metadataOnly && syncResult.removedBookIds.isNotEmpty()) {
sidecarsImported += importAnnotationSidecarsForBooks( Timber.tag("FolderSync").i("Cleaning up ${syncResult.removedBookIds.size} missing folder books.")
folderUri = folderUri, recentFilesRepository.deleteFilePermanently(syncResult.removedBookIds.toList())
folderUriString = folderUriString,
books = existingFolderBooks,
phase = "metadata-only"
)
} }
if (!metadataOnly) { val booksForAnnotationSync = if (metadataOnly) {
Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...") syncedItems
val contentResolver = appContext.contentResolver } else {
val foundBookIds = mutableSetOf<String>() ReaderPerfLog.measureSuspend(
val newOrUpdatedItems = mutableListOf<RecentFileItem>()
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>()
dirQueue.add(rootDocId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED
)
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 {
contentResolver.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)
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
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") {
dirQueue.add(docId)
}
} else {
val size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L
val lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
val type = getFileType(name, mimeType)
if (
type != null &&
type in allowedFileTypes &&
isLocalFolderSyncEligibleFile(name, mimeType) &&
!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 != docUriString) {
val collidedItem = existingItem
val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId)
if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) {
Timber.tag("FolderSync").i("Resolving folder ID collision for ${collidedItem.displayName}: ${collidedItem.bookId} -> $collidedStableId")
migrateFolderBookId(
folderUriString = folderUriString,
oldId = collidedItem.bookId,
newId = collidedStableId,
folderMetadataMap = folderMetadataMap,
preloadedSidecars = preloadedSidecars,
existingItemsMap = existingItemsMap
)
existingItem = existingItemsMap[stableId]
}
}
if (existingItem == null) {
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")
migrateFolderBookId(
folderUriString = folderUriString,
oldId = oldId,
newId = stableId,
folderMetadataMap = folderMetadataMap,
preloadedSidecars = preloadedSidecars,
existingItemsMap = existingItemsMap
)
legacyItemsByName[name]?.remove(oldItem)
existingItem = existingItemsMap[stableId]
}
}
if (existingItem == null) {
val remoteMeta = folderMetadataMap[stableId]
val newItem = RecentFileItem(
bookId = stableId,
uriString = docUri.toString(),
type = type,
displayName = name,
timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
coverImagePath = null,
title = name.substringBeforeLast('.', name),
author = remoteMeta?.author,
isAvailable = true,
isDeleted = false,
isRecent = remoteMeta?.isRecent ?: false,
sourceFolderUri = folderUriString,
lastChapterIndex = remoteMeta?.lastChapterIndex,
lastPage = remoteMeta?.lastPage,
lastPositionCfi = remoteMeta?.lastPositionCfi,
progressPercentage = remoteMeta?.progressPercentage,
bookmarksJson = remoteMeta?.bookmarksJson,
highlightsJson = remoteMeta?.highlightsJson,
customName = remoteMeta?.customName,
locatorBlockIndex = remoteMeta?.locatorBlockIndex,
locatorCharOffset = remoteMeta?.locatorCharOffset,
fileSize = size,
fileContentModifiedTimestamp = lastModified
)
newOrUpdatedItems.add(newItem)
newBooks++
} else {
var needsUpdate = false
var updatedItem = existingItem
val modifiedChanged = lastModified > 0L &&
existingItem.fileContentModifiedTimestamp != lastModified
if ((size > 0L && existingItem.fileSize != size) || modifiedChanged) {
Timber.tag("FolderSync").i("File content changed for $name; refreshing extracted metadata.")
recentFilesRepository.clearLocalCachesForBook(stableId)
updatedItem = updatedItem.copy(
fileSize = size,
fileContentModifiedTimestamp = lastModified,
lastModifiedTimestamp = lastModified,
coverImagePath = null,
title = name.substringBeforeLast('.', name),
author = null,
seriesName = null,
seriesIndex = null,
description = null,
originalTitle = null,
originalAuthor = null,
originalSeriesName = null,
originalSeriesIndex = null,
originalDescription = null,
folderTextMetadataParsed = false,
folderCoverMetadataParsed = false
)
needsUpdate = true
}
if (updatedItem.isDeleted || !updatedItem.isAvailable) {
updatedItem = updatedItem.copy(isDeleted = false, isAvailable = true)
needsUpdate = true
}
if (updatedItem.uriString != docUri.toString()) {
updatedItem = updatedItem.copy(uriString = docUri.toString())
needsUpdate = true
}
if (needsUpdate) {
newOrUpdatedItems.add(updatedItem)
updatedBooks++
} else {
unchangedBooks++
}
}
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()
}
}
}
}
}
} catch (e: Exception) {
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
}
if (stoppedForUnlinkedFolder) break
}
if (!stoppedForUnlinkedFolder && newOrUpdatedItems.isNotEmpty()) {
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
dbFlushes++
scanDbFlushes++
newOrUpdatedItems.clear()
}
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.")
recentFilesRepository.deleteFilePermanently(idsToRemove)
}
}
}
if (!metadataOnly && !isStopped && !stoppedForUnlinkedFolder) {
val booksForAnnotationSync = ReaderPerfLog.measureSuspend(
name = "FolderSync phase load-post-scan-db", name = "FolderSync phase load-post-scan-db",
minLogMs = 25L minLogMs = 25L
) { ) {
recentFilesRepository.getFilesBySourceFolder(folderUriString) recentFilesRepository.getFilesBySourceFolder(folderUriString)
} }
sidecarsImported += importAnnotationSidecarsForBooks(
folderUri = folderUri,
folderUriString = folderUriString,
books = booksForAnnotationSync,
phase = "post-scan"
)
} }
sidecarsImported += importAnnotationSidecarsForBooks(
folderUri = folderUri,
folderUriString = folderUriString,
books = booksForAnnotationSync,
phase = if (metadataOnly) "metadata-only" else "post-scan"
)
val elapsed = ReaderPerfLog.elapsedMs(folderStart) val elapsed = ReaderPerfLog.elapsedMs(folderStart)
ReaderPerfLog.i( ReaderPerfLog.i(
"FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " + "FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " +
"dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " + "dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " +
"new=$newBooks updated=$updatedBooks unchanged=$unchangedBooks " + "new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " +
"remoteUpdates=${syncResult.stats.remoteMetadataUpdates} unchanged=${syncResult.stats.unchangedBooks} " +
"removed=${syncResult.stats.removedBooks} migrated=${syncResult.stats.migratedBooks} " +
"dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " + "dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " +
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString" "unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
) )
@ -601,6 +430,294 @@ class FolderSyncWorker(
return imported return imported
} }
private data class AndroidFolderScanResult(
val files: List<SharedFolderScannedFile> = emptyList(),
val dirsScanned: Int = 0,
val filesSeen: Int = 0,
val stoppedForUnlinkedFolder: Boolean = false
)
private fun scanFolderFiles(
folderUri: android.net.Uri,
folderUriString: String,
allowedFileTypes: Set<FileType>
): AndroidFolderScanResult {
Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...")
val contentResolver = appContext.contentResolver
val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
val dirQueue = ArrayDeque<String>()
val scannedFiles = mutableListOf<SharedFolderScannedFile>()
var dirsScanned = 0
var filesSeen = 0
var stoppedForUnlinkedFolder = false
dirQueue.add(rootDocId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED
)
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 {
contentResolver.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)
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
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 != LOCAL_FOLDER_SYNC_DATA_DIR) {
dirQueue.add(docId)
}
continue
}
val type = getFileType(name, mimeType)
if (
type == null ||
type !in allowedFileTypes ||
!isLocalFolderSyncEligibleFile(name, mimeType) ||
name.endsWith(".json") ||
name.startsWith(".")
) {
continue
}
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
val relativePath = buildRelativePath(rootDocId, docId, name)
scannedFiles += SharedFolderScannedFile(
name = name,
path = docUri.toString(),
sourceFolder = folderUriString,
relativePath = relativePath,
type = type,
size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L,
lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
)
}
}
} catch (e: Exception) {
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
}
if (stoppedForUnlinkedFolder) break
}
return AndroidFolderScanResult(
files = scannedFiles,
dirsScanned = dirsScanned,
filesSeen = filesSeen,
stoppedForUnlinkedFolder = stoppedForUnlinkedFolder
)
}
private fun RecentFileItem.toFolderSyncSharedBookItem(): SharedBookItem {
return SharedBookItem(
id = bookId,
path = uriString,
type = type,
displayName = displayName,
timestamp = lastModifiedTimestamp,
coverImagePath = coverImagePath,
title = title,
author = author,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription,
progressPercentage = progressPercentage,
isRecent = isRecent,
fileSize = fileSize,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
sourceFolder = sourceFolderUri,
folderTextMetadataParsed = folderTextMetadataParsed,
seriesName = seriesName,
seriesIndex = seriesIndex,
lastPageIndex = lastPage,
readerPosition = readerPositionOrNull(),
readerBookmarks = parseReaderBookmarks(),
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
)
}
private fun SharedBookItem.toFolderSyncRecentFileItem(
existing: RecentFileItem?,
appliedMetadata: FolderBookMetadata?,
scannedFile: SharedFolderScannedFile?,
nowMillis: Long
): RecentFileItem {
val contentChanged = existing != null && folderFileContentChanged(existing, this)
val localModifiedTimestamp = when {
appliedMetadata != null -> appliedMetadata.lastModifiedTimestamp
contentChanged && fileContentModifiedTimestamp > 0L -> fileContentModifiedTimestamp
timestamp > 0L -> timestamp
else -> nowMillis
}
val legacyPosition = readerPosition
val mappedBookmarksJson = readerBookmarks.toAndroidBookmarksJson(id)
val mappedHighlightsJson = readerHighlights
.takeIf { it.isNotEmpty() }
?.let(EpubAnnotationSerializer::highlightsToJson)
val bookmarksJson = if (appliedMetadata != null || existing == null) {
mappedBookmarksJson ?: appliedMetadata?.bookmarksJson ?: existing?.bookmarksJson
} else {
existing.bookmarksJson
}
val highlightsJson = if (appliedMetadata != null || existing == null) {
mappedHighlightsJson ?: appliedMetadata?.highlightsJson ?: existing?.highlightsJson
} else {
existing.highlightsJson
}
return RecentFileItem(
bookId = id,
uriString = path,
type = type,
displayName = scannedFile?.name ?: existing?.displayName ?: displayName,
timestamp = when {
existing == null -> timestamp.takeIf { it > 0L } ?: localModifiedTimestamp
appliedMetadata?.isRecent == true -> appliedMetadata.lastModifiedTimestamp
else -> existing.timestamp
},
coverImagePath = coverImagePath,
title = title,
author = author,
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
locatorBlockIndex = appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
locatorCharOffset = appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
progressPercentage = progressPercentage,
isRecent = isRecent,
isAvailable = true,
lastModifiedTimestamp = localModifiedTimestamp,
isDeleted = false,
bookmarksJson = bookmarksJson,
sourceFolderUri = sourceFolder,
isReflowPreferred = existing?.isReflowPreferred ?: false,
customName = appliedMetadata?.customName ?: existing?.customName,
highlightsJson = highlightsJson,
fileSize = fileSize,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
seriesName = seriesName,
seriesIndex = seriesIndex,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription,
folderTextMetadataParsed = folderTextMetadataParsed,
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
tags = existing?.tags.orEmpty()
)
}
private fun appliedMetadataFor(
book: SharedBookItem,
existing: RecentFileItem?,
metadata: FolderBookMetadata?
): FolderBookMetadata? {
if (metadata == null) return null
val existingModified = existing?.lastModifiedTimestamp ?: Long.MIN_VALUE
return metadata.takeIf { existing == null || it.lastModifiedTimestamp > existingModified }
}
private fun RecentFileItem.readerPositionOrNull(): ReaderLocator? {
if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null
return ReaderLocator.fromLegacy(
chapterIndex = lastChapterIndex,
cfi = lastPositionCfi,
pageIndex = lastPage
)
}
private fun RecentFileItem.parseReaderBookmarks(): List<ReaderBookmark> {
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
.mapIndexed { index, bookmark ->
val locator = bookmark.locator.withFallbacks(
chapterIndex = bookmark.chapterIndex,
cfi = bookmark.cfi,
pageIndex = bookmark.pageInChapter?.minus(1),
textQuote = bookmark.snippet
)
val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0
ReaderBookmark(
id = "bookmark_${bookId}_$index",
pageIndex = pageIndex.coerceAtLeast(0),
chapterTitle = bookmark.chapterTitle,
preview = bookmark.snippet,
locator = locator
)
}
}
private fun List<ReaderBookmark>.toAndroidBookmarksJson(bookId: String): String? {
val bookmarks = mapIndexed { index, bookmark ->
val locator = bookmark.locator
val chapterIndex = locator.chapterIndex ?: 0
val cfi = locator.cfi ?: "android:$bookId:$index:${bookmark.pageIndex}"
EpubBookmark(
cfi = cfi,
chapterTitle = bookmark.chapterTitle,
label = null,
snippet = bookmark.preview,
pageInChapter = bookmark.pageIndex + 1,
totalPagesInChapter = null,
chapterIndex = chapterIndex,
locator = locator.withFallbacks(
chapterIndex = chapterIndex,
cfi = cfi,
pageIndex = bookmark.pageIndex,
textQuote = bookmark.preview
)
)
}
return bookmarks.takeIf { it.isNotEmpty() }?.let(EpubAnnotationSerializer::bookmarksToJson)
}
private fun folderFileContentChanged(previous: RecentFileItem, next: RecentFileItem): Boolean {
val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
return sizeChanged || modifiedChanged
}
private fun folderFileContentChanged(previous: RecentFileItem, next: SharedBookItem): Boolean {
val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
return sizeChanged || modifiedChanged
}
private fun isFolderStillLinked(folderUriString: String): Boolean { private fun isFolderStillLinked(folderUriString: String): Boolean {
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val jsonString = prefs.getString("synced_folders_list_json", null) val jsonString = prefs.getString("synced_folders_list_json", null)
@ -621,11 +738,6 @@ class FolderSyncWorker(
return resolveFileTypeFromMetadata(name, mimeType) return resolveFileTypeFromMetadata(name, mimeType)
} }
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
val relativePath = buildRelativePath(rootDocId, docId, name)
return com.aryan.reader.shared.LocalFolderSyncEngine.buildStableBookId(name, relativePath)
}
private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String { private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
val rootPath = rootDocId.substringAfter(':', "") val rootPath = rootDocId.substringAfter(':', "")
val docPath = docId.substringAfter(':', "") val docPath = docId.substringAfter(':', "")
@ -638,16 +750,6 @@ class FolderSyncWorker(
return relative.ifBlank { fallbackName } return relative.ifBlank { fallbackName }
} }
private fun computeStableIdForStoredItem(item: RecentFileItem, rootDocId: String): String? {
val uriString = item.uriString ?: return null
return try {
val docId = DocumentsContract.getDocumentId(uriString.toUri())
buildStableBookId(item.displayName, rootDocId, docId)
} catch (_: Exception) {
null
}
}
private suspend fun migrateFolderBookId( private suspend fun migrateFolderBookId(
folderUriString: String, folderUriString: String,
oldId: String, oldId: String,

View file

@ -61,6 +61,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.ui.SharedAppFontSelector
import com.aryan.reader.shared.ui.SharedFontSettingsSection
import com.aryan.reader.shared.ui.SharedFontSettingsTabs
import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.CustomFontEntity
import java.io.File import java.io.File
@ -80,6 +84,7 @@ fun FontsScreen(
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) } var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
var showGoogleFontsSheet by remember { mutableStateOf(false) } var showGoogleFontsSheet by remember { mutableStateOf(false) }
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
val pickFontLauncher = rememberFilePickerLauncher { uris -> val pickFontLauncher = rememberFilePickerLauncher { uris ->
uris.firstOrNull()?.let { viewModel.importFont(it) } uris.firstOrNull()?.let { viewModel.importFont(it) }
@ -105,7 +110,7 @@ fun FontsScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
if (fonts.isNotEmpty()) { if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
Column( Column(
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
@ -130,31 +135,61 @@ fun FontsScreen(
} }
) { padding -> ) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) { Box(modifier = Modifier.fillMaxSize().padding(padding)) {
if (fonts.isEmpty()) { val sharedFonts = remember(fonts) { fonts.toSharedCustomFontItems() }
val secondaryText = if (showGoogleFontsOption) stringResource(R.string.action_browse_google_fonts) else null Column(modifier = Modifier.fillMaxSize()) {
val secondaryClick: (() -> Unit)? = if (showGoogleFontsOption) { { showGoogleFontsSheet = true } } else null SharedFontSettingsTabs(
selectedSection = selectedSection,
EmptyState( onSectionChange = { selectedSection = it },
title = stringResource(R.string.no_custom_fonts), modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
message = stringResource(R.string.import_fonts_desc),
onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) },
modifier = Modifier.fillMaxSize(),
secondaryButtonText = secondaryText,
onSecondaryClick = secondaryClick
) )
} else {
LazyColumn( when (selectedSection) {
modifier = Modifier.fillMaxSize(), SharedFontSettingsSection.READER_FONTS -> {
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), if (fonts.isEmpty()) {
verticalArrangement = Arrangement.spacedBy(12.dp) val secondaryText = if (showGoogleFontsOption) stringResource(R.string.action_browse_google_fonts) else null
) { val secondaryClick: (() -> Unit)? = if (showGoogleFontsOption) { { showGoogleFontsSheet = true } } else null
items(fonts, key = { it.id }) { font ->
FontListItem( EmptyState(
font = font, title = stringResource(R.string.no_custom_fonts),
onDelete = { message = stringResource(R.string.import_fonts_desc),
fontToDelete = font onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) },
showDeleteDialog = true modifier = Modifier.weight(1f),
secondaryButtonText = secondaryText,
onSecondaryClick = secondaryClick
)
} else {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(fonts, key = { it.id }) { font ->
FontListItem(
font = font,
onDelete = {
fontToDelete = font
showDeleteDialog = true
}
)
}
} }
}
}
SharedFontSettingsSection.APP_TEXT -> {
SharedAppFontSelector(
preference = uiState.appFontPreference,
customFonts = sharedFonts,
onPreferenceChange = viewModel::setAppFontPreference,
fontFamilyForPreview = { font ->
val file = File(font.path)
if (file.isFile) {
runCatching { FontFamily(Font(file)) }.getOrNull()
} else {
null
}
},
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
) )
} }
} }
@ -424,6 +459,22 @@ fun FontListItem(
} }
} }
private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontItem> {
return filterNot { it.isDeleted }
.sortedBy { it.displayName.lowercase() }
.map { font ->
CustomFontItem(
id = font.id,
displayName = font.displayName,
fileName = font.fileName,
fileExtension = font.fileExtension,
path = font.path,
timestamp = font.timestamp,
isDeleted = font.isDeleted
)
}
}
@Composable @Composable
fun DeleteFontConfirmationDialog( fun DeleteFontConfirmationDialog(
fontName: String, fontName: String,

View file

@ -37,6 +37,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -351,6 +352,9 @@ fun HomeScreen(
showStrictFilterDialog = true showStrictFilterDialog = true
} }
}, },
onUsePdfFileNameAsDisplayNameToggle = {
viewModel.setUsePdfFileNameAsDisplayName(!uiState.usePdfFileNameAsDisplayName)
},
onAppThemeClick = { showAppThemePanel = true }, onAppThemeClick = { showAppThemePanel = true },
onSettingsClick = { onSettingsClick = {
navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE)
@ -432,7 +436,8 @@ fun HomeScreen(
onRefresh = { viewModel.refreshLibrary() }, onRefresh = { viewModel.refreshLibrary() },
isRefreshing = uiState.isRefreshing, isRefreshing = uiState.isRefreshing,
isSyncEnabled = uiState.isSyncEnabled, isSyncEnabled = uiState.isSyncEnabled,
hasSyncedFolder = uiState.syncedFolders.isNotEmpty() hasSyncedFolder = uiState.syncedFolders.isNotEmpty(),
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
) )
} }
} }
@ -500,6 +505,7 @@ fun HomeScreen(
if (showInfoDialog) { if (showInfoDialog) {
FileInfoDialog( FileInfoDialog(
item = item, item = item,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { onDismiss = {
showInfoDialog = false showInfoDialog = false
itemForInfoDialog = null itemForInfoDialog = null
@ -629,7 +635,8 @@ private fun RecentFilesContent(
onRefresh: () -> Unit, onRefresh: () -> Unit,
isRefreshing: Boolean, isRefreshing: Boolean,
isSyncEnabled: Boolean, isSyncEnabled: Boolean,
hasSyncedFolder: Boolean hasSyncedFolder: Boolean,
usePdfFileNameAsDisplayName: Boolean
) { ) {
val canRefresh = isSyncEnabled || hasSyncedFolder val canRefresh = isSyncEnabled || hasSyncedFolder
val selectedItemUris = remember(selectedContextItems) { val selectedItemUris = remember(selectedContextItems) {
@ -653,7 +660,8 @@ private fun RecentFilesContent(
onItemLongClick = onItemLongClick, onItemLongClick = onItemLongClick,
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
contentPadding = PaddingValues(top = 8.dp, bottom = 100.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 100.dp),
downloadingBookIds = downloadingBookIds downloadingBookIds = downloadingBookIds,
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
) )
Row( Row(
@ -702,6 +710,7 @@ private fun RecentFilesGrid(
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
contentPadding: PaddingValues = PaddingValues(vertical = 8.dp), contentPadding: PaddingValues = PaddingValues(vertical = 8.dp),
downloadingBookIds: Set<String>, downloadingBookIds: Set<String>,
usePdfFileNameAsDisplayName: Boolean,
) { ) {
val gridCells = when (windowSizeClass.widthSizeClass) { val gridCells = when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> GridCells.Fixed(3) WindowWidthSizeClass.Compact -> GridCells.Fixed(3)
@ -741,7 +750,7 @@ private fun RecentFilesGrid(
onClick = { onItemClick(tab) }, onClick = { onItemClick(tab) },
label = { label = {
Text( Text(
text = tab.customName ?: tab.title ?: tab.displayName, text = tab.cardTitle(usePdfFileNameAsDisplayName),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 150.dp) modifier = Modifier.widthIn(max = 150.dp)
@ -783,7 +792,8 @@ private fun RecentFilesGrid(
isPinned = item.bookId in pinnedHomeBookIds, isPinned = item.bookId in pinnedHomeBookIds,
onClick = { onItemClick(item) }, onClick = { onItemClick(item) },
onLongClick = { onItemLongClick(item) }, onLongClick = { onItemLongClick(item) },
isDownloading = item.bookId in downloadingBookIds isDownloading = item.bookId in downloadingBookIds,
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
) )
} }
} }
@ -800,6 +810,7 @@ fun RecentFileCard(
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
isDownloading: Boolean, isDownloading: Boolean,
usePdfFileNameAsDisplayName: Boolean = false,
) { ) {
val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt() val progressPercent = item.progressPercentage?.takeIf { it > 0f }?.coerceIn(0f, 100f)?.toInt()
val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " " val authorText = item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: " "
@ -822,11 +833,14 @@ fun RecentFileCard(
) )
) { ) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Box( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(0.74f) .aspectRatio(0.74f)
) { ) {
val useCompactCoverBadges = maxWidth < 128.dp
val coverBadgePadding = if (useCompactCoverBadges) 5.dp else 8.dp
ThemedBookCover( ThemedBookCover(
item = item, item = item,
contentDescription = item.displayName, contentDescription = item.displayName,
@ -894,30 +908,25 @@ fun RecentFileCard(
} }
} }
Box(modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp)) { Row(
FileTypeBadge(type = item.type, overlay = true) modifier = Modifier
} .align(Alignment.BottomCenter)
.fillMaxWidth()
progressPercent?.let { percent -> .padding(coverBadgePadding),
Surface( verticalAlignment = Alignment.CenterVertically
modifier = Modifier ) {
.align(Alignment.BottomStart) progressPercent?.let { percent ->
.padding(8.dp), CoverProgressBadge(
shape = RoundedCornerShape(50), percent = percent,
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f), compact = useCompactCoverBadges
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
border = androidx.compose.foundation.BorderStroke(
1.dp,
Color.White.copy(alpha = 0.14f)
)
) {
Text(
text = "$percent%",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
) )
} }
Spacer(modifier = Modifier.weight(1f))
FileTypeBadge(
type = item.type,
overlay = true,
compact = useCompactCoverBadges
)
} }
} }
@ -929,7 +938,7 @@ fun RecentFileCard(
horizontalAlignment = Alignment.Start horizontalAlignment = Alignment.Start
) { ) {
Text( Text(
text = item.cardTitle(), text = item.cardTitle(usePdfFileNameAsDisplayName),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
@ -1008,6 +1017,39 @@ fun RecentFileCard(
} }
} }
@Composable
private fun CoverProgressBadge(
percent: Int,
compact: Boolean,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f),
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
border = androidx.compose.foundation.BorderStroke(
1.dp,
Color.White.copy(alpha = 0.14f)
)
) {
Text(
text = "$percent%",
style = if (compact) {
MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp)
} else {
MaterialTheme.typography.labelSmall
},
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier.padding(
horizontal = if (compact) 6.dp else 10.dp,
vertical = if (compact) 3.dp else 4.dp
)
)
}
}
@Suppress("unused", "KotlinConstantConditions") @Suppress("unused", "KotlinConstantConditions")
@Composable @Composable
fun DefaultTopAppBar( fun DefaultTopAppBar(
@ -1024,6 +1066,7 @@ fun DefaultTopAppBar(
onTabsToggle: (Boolean) -> Unit, onTabsToggle: (Boolean) -> Unit,
onExternalFileBehaviorClick: () -> Unit, onExternalFileBehaviorClick: () -> Unit,
onStrictFilterToggleClick: () -> Unit, onStrictFilterToggleClick: () -> Unit,
onUsePdfFileNameAsDisplayNameToggle: () -> Unit,
onAppThemeClick: () -> Unit, onAppThemeClick: () -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onTestPanelDetectionClick: () -> Unit, onTestPanelDetectionClick: () -> Unit,
@ -1132,6 +1175,15 @@ fun DefaultTopAppBar(
} }
}) })
DropdownMenuItem(text = { Text(stringResource(R.string.options_use_pdf_filename_display_name)) }, onClick = {
onUsePdfFileNameAsDisplayNameToggle()
showOptionsMenu = false
}, trailingIcon = {
if (uiState.usePdfFileNameAsDisplayName) {
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
}
})
HorizontalDivider() HorizontalDivider()
DropdownMenuItem(text = { Text(stringResource(R.string.options_language)) }, onClick = { DropdownMenuItem(text = { Text(stringResource(R.string.options_language)) }, onClick = {
@ -1262,7 +1314,7 @@ private fun AppDrawerContent(
Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.FormatListNumbered, contentDescription = stringResource(R.string.credits_tab), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onTertiaryContainer) Icon(Icons.Default.FormatListNumbered, contentDescription = stringResource(R.string.credits_tab), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onTertiaryContainer)
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.credits_count, uiState.credits), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer) Text(safeStringResource(R.string.credits_count, uiState.credits), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer)
} }
} }
} }

View file

@ -1,6 +1,7 @@
package com.aryan.reader package com.aryan.reader
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.shared.ReaderFeatureSurface
import com.aryan.reader.shared.ReaderPlatform import com.aryan.reader.shared.ReaderPlatform
import com.aryan.reader.shared.SharedFileCapabilities import com.aryan.reader.shared.SharedFileCapabilities
@ -11,13 +12,16 @@ typealias SortOrder = com.aryan.reader.shared.SortOrder
typealias ReadStatusFilter = com.aryan.reader.shared.ReadStatusFilter typealias ReadStatusFilter = com.aryan.reader.shared.ReadStatusFilter
typealias LibraryFilters = com.aryan.reader.shared.LibraryFilters typealias LibraryFilters = com.aryan.reader.shared.LibraryFilters
typealias SyncedFolder = com.aryan.reader.shared.SyncedFolder typealias SyncedFolder = com.aryan.reader.shared.SyncedFolder
typealias ShelfType = com.aryan.reader.shared.ShelfType
internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID) internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID) internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES
internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER } internal fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? {
return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID)
}
data class Shelf( data class Shelf(
val id: String, val id: String,

View file

@ -141,6 +141,7 @@ import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity import com.aryan.reader.data.TagEntity
import com.aryan.reader.opds.OpdsAcquisition import com.aryan.reader.opds.OpdsAcquisition
import com.aryan.reader.opds.OpdsCatalog import com.aryan.reader.opds.OpdsCatalog
import com.aryan.reader.opds.OpdsDownloadState
import com.aryan.reader.opds.OpdsEntry import com.aryan.reader.opds.OpdsEntry
import com.aryan.reader.opds.OpdsViewModel import com.aryan.reader.opds.OpdsViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -343,7 +344,8 @@ fun LibraryScreen(
) )
}, },
onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog, onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog,
onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) } onSettingsClick = { navController.navigate(AppDestinations.SETTINGS_SCREEN_ROUTE) },
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
) )
@ -392,6 +394,7 @@ fun LibraryScreen(
if (showInfoDialog) { if (showInfoDialog) {
FileInfoDialog( FileInfoDialog(
item = item, item = item,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { onDismiss = {
showInfoDialog = false showInfoDialog = false
itemForInfoDialog = null itemForInfoDialog = null
@ -458,7 +461,8 @@ fun ShelfScreen(
onBookClick = { item -> viewModel.toggleBookSelectionForAdding(item.bookId) }, onBookClick = { item -> viewModel.toggleBookSelectionForAdding(item.bookId) },
onBack = viewModel::dismissAddBooksToShelf, onBack = viewModel::dismissAddBooksToShelf,
onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfId) }, onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfId) },
downloadingBookIds = uiState.downloadingBookIds downloadingBookIds = uiState.downloadingBookIds,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
) )
} else { } else {
ShelfDetailScreen( ShelfDetailScreen(
@ -483,7 +487,8 @@ fun ShelfScreen(
onDeleteClick = { showRemoveFromShelfDialog = true }, onDeleteClick = { showRemoveFromShelfDialog = true },
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) }, onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) }, onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
downloadingBookIds = uiState.downloadingBookIds downloadingBookIds = uiState.downloadingBookIds,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
) )
} }
} }
@ -523,6 +528,7 @@ fun ShelfScreen(
if (showInfoDialog) { if (showInfoDialog) {
FileInfoDialog( FileInfoDialog(
item = item, item = item,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { showInfoDialog = false; itemForInfoDialog = null }, onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) }, onSaveMetadata = { metadata -> viewModel.updateBookMetadata(item.bookId, metadata) },
onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) }, onSaveDisplayName = { name -> viewModel.updateCustomName(item.bookId, name) },
@ -588,6 +594,7 @@ fun LibraryScreenContent(
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit, onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit, onDeleteCatalogStreams: (String) -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
usePdfFileNameAsDisplayName: Boolean,
) { ) {
val isBookContextualModeActive = selectedItems.isNotEmpty() val isBookContextualModeActive = selectedItems.isNotEmpty()
val isShelfContextualModeActive = selectedShelves.isNotEmpty() val isShelfContextualModeActive = selectedShelves.isNotEmpty()
@ -860,7 +867,8 @@ fun LibraryScreenContent(
isPinned = item.bookId in pinnedLibraryBookIds, isPinned = item.bookId in pinnedLibraryBookIds,
onItemClick = { onItemClick(item) }, onItemClick = { onItemClick(item) },
onItemLongClick = { onItemLongClick(item) }, onItemLongClick = { onItemLongClick(item) },
isDownloading = item.bookId in downloadingBookIds isDownloading = item.bookId in downloadingBookIds,
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
) )
} }
} }
@ -1027,6 +1035,7 @@ private fun ShelfDetailScreen(
onRenameShelf: () -> Unit, onRenameShelf: () -> Unit,
onDeleteShelf: () -> Unit, onDeleteShelf: () -> Unit,
downloadingBookIds: Set<String>, downloadingBookIds: Set<String>,
usePdfFileNameAsDisplayName: Boolean,
) { ) {
val isContextualModeActive = selectedItems.isNotEmpty() val isContextualModeActive = selectedItems.isNotEmpty()
val isFolderShelf = shelf.type == ShelfType.FOLDER val isFolderShelf = shelf.type == ShelfType.FOLDER
@ -1162,7 +1171,11 @@ private fun ShelfDetailScreen(
Text( Text(
text = when { text = when {
isFolderShelf && shelf.childShelfCount > 0 && shelf.directBookCount > 0 -> isFolderShelf && shelf.childShelfCount > 0 && shelf.directBookCount > 0 ->
"${pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount)}${getBookCountString(shelf.directBookCount)}" stringResource(
R.string.folder_subtitle_folder_book_counts,
pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount),
getBookCountString(shelf.directBookCount)
)
isFolderShelf && shelf.childShelfCount > 0 -> isFolderShelf && shelf.childShelfCount > 0 ->
pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount) pluralStringResource(R.plurals.folder_count, shelf.childShelfCount, shelf.childShelfCount)
isFolderShelf -> getBookCountString(shelf.directBookCount) isFolderShelf -> getBookCountString(shelf.directBookCount)
@ -1322,7 +1335,8 @@ private fun ShelfDetailScreen(
isSelected = selectedItems.any { it.bookId == item.bookId }, isSelected = selectedItems.any { it.bookId == item.bookId },
onItemClick = { onBookClick(item) }, onItemClick = { onBookClick(item) },
onItemLongClick = { onBookLongClick(item) }, onItemLongClick = { onBookLongClick(item) },
isDownloading = item.bookId in downloadingBookIds isDownloading = item.bookId in downloadingBookIds,
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
) )
} }
} }
@ -1344,6 +1358,7 @@ private fun AddBooksModeScreen(
onBack: () -> Unit, onBack: () -> Unit,
onAddSelectedBooks: () -> Unit, onAddSelectedBooks: () -> Unit,
downloadingBookIds: Set<String>, downloadingBookIds: Set<String>,
usePdfFileNameAsDisplayName: Boolean,
) { ) {
var showSortMenu by remember { mutableStateOf(false) } var showSortMenu by remember { mutableStateOf(false) }
@ -1445,7 +1460,8 @@ private fun AddBooksModeScreen(
isSelected = isSelected, isSelected = isSelected,
onItemClick = { onBookClick(item) }, onItemClick = { onBookClick(item) },
onItemLongClick = { onBookClick(item) }, onItemLongClick = { onBookClick(item) },
isDownloading = item.bookId in downloadingBookIds isDownloading = item.bookId in downloadingBookIds,
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
) )
} }
} }
@ -1631,6 +1647,7 @@ private fun LibraryListItem(
onItemClick: () -> Unit, onItemClick: () -> Unit,
onItemLongClick: () -> Unit, onItemLongClick: () -> Unit,
isDownloading: Boolean, isDownloading: Boolean,
usePdfFileNameAsDisplayName: Boolean = false,
) { ) {
androidx.compose.material3.ElevatedCard( androidx.compose.material3.ElevatedCard(
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
@ -1695,7 +1712,7 @@ private fun LibraryListItem(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = item.cardTitle(), text = item.cardTitle(usePdfFileNameAsDisplayName),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
@ -2358,8 +2375,7 @@ fun OpdsTab(
opdsViewModel: OpdsViewModel = viewModel() opdsViewModel: OpdsViewModel = viewModel()
) { ) {
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle() val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
val downloadingState by opdsViewModel.downloadingState.collectAsStateWithLifecycle() val downloadingState = uiState.downloadingState
val downloadingEntries by opdsViewModel.downloadingEntries.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) } var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
var showCatalogDialog by remember { mutableStateOf(false) } var showCatalogDialog by remember { mutableStateOf(false) }
@ -2836,7 +2852,7 @@ fun OpdsNavigationCard(entry: OpdsEntry, onClick: (String) -> Unit) {
fun OpdsBookCard( fun OpdsBookCard(
entry: OpdsEntry, entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>, localLibraryFiles: List<RecentFileItem>,
downloadState: OpdsViewModel.DownloadState?, downloadState: OpdsDownloadState?,
onDownloadClick: (OpdsAcquisition) -> Unit, onDownloadClick: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit, onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit, onStreamClick: () -> Unit,
@ -2967,7 +2983,7 @@ fun OpdsBookCard(
fun OpdsBookDetailsSheet( fun OpdsBookDetailsSheet(
entry: OpdsEntry, entry: OpdsEntry,
localLibraryFiles: List<RecentFileItem>, localLibraryFiles: List<RecentFileItem>,
downloadState: OpdsViewModel.DownloadState?, downloadState: OpdsDownloadState?,
onDownloadFormat: (OpdsAcquisition) -> Unit, onDownloadFormat: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit, onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit, onStreamClick: () -> Unit,

View file

@ -142,13 +142,13 @@ fun applyLibraryFilters(files: List<RecentFileItem>, filters: LibraryFilters): L
return files.mapSharedResults( return files.mapSharedResults(
sharedApplyLibraryFilters( sharedApplyLibraryFilters(
books = files.map { it.toSharedProjectionBookItem() }, books = files.map { it.toSharedProjectionBookItem() },
filters = filters.toSharedLibraryFilters() filters = filters
) )
) )
} }
fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFileItem> { fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFileItem> {
return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder.toSharedSortOrder())) return files.mapSharedResults(sharedSortBooks(files.map { it.toSharedProjectionBookItem() }, sortOrder))
} }
private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<com.aryan.reader.shared.BookItem>): List<RecentFileItem> { private fun List<RecentFileItem>.mapSharedResults(sharedBooks: List<com.aryan.reader.shared.BookItem>): List<RecentFileItem> {

View file

@ -37,6 +37,7 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
@ -49,6 +50,12 @@ import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.aryan.reader.tts.ACTION_OPEN_TTS_SESSION
import com.aryan.reader.tts.EXTRA_TTS_BOOK_ID
import com.aryan.reader.tts.EXTRA_TTS_CHAPTER_INDEX
import com.aryan.reader.tts.EXTRA_TTS_PAGE_INDEX
import com.aryan.reader.tts.EXTRA_TTS_SOURCE_CFI
import com.aryan.reader.tts.EXTRA_TTS_START_OFFSET
@UnstableApi @UnstableApi
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@ -89,6 +96,7 @@ class MainActivity : AppCompatActivity() {
setContent { setContent {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val customFonts by viewModel.customFonts.collectAsStateWithLifecycle()
ScreenCaptureProtectionEffect(enabled = uiState.isScreenCaptureProtectionEnabled) ScreenCaptureProtectionEffect(enabled = uiState.isScreenCaptureProtectionEnabled)
@ -99,13 +107,17 @@ class MainActivity : AppCompatActivity() {
} }
val textDimFactor = if (darkTheme) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight val textDimFactor = if (darkTheme) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight
val appFontFamily = remember(uiState.appFontPreference, customFonts) {
uiState.appFontPreference.toAndroidAppFontFamily(customFonts)
}
AppTheme( AppTheme(
darkTheme = darkTheme, darkTheme = darkTheme,
dynamicColor = uiState.appSeedColor == null, dynamicColor = uiState.appSeedColor == null,
seedColor = uiState.appSeedColor, seedColor = uiState.appSeedColor,
contrastLevel = uiState.appContrastOption.value, contrastLevel = uiState.appContrastOption.value,
textDimFactor = textDimFactor textDimFactor = textDimFactor,
appFontFamily = appFontFamily
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -125,10 +137,26 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent)
handleIntent(intent) handleIntent(intent)
} }
private fun handleIntent(intent: Intent?) { private fun handleIntent(intent: Intent?) {
if (intent?.action == ACTION_OPEN_TTS_SESSION) {
val bookId = intent.getStringExtra(EXTRA_TTS_BOOK_ID)
if (!bookId.isNullOrBlank()) {
Timber.d("Received TTS notification intent for bookId=$bookId")
viewModel.openTtsNotificationTarget(
bookId = bookId,
sourceCfi = intent.getStringExtra(EXTRA_TTS_SOURCE_CFI),
startOffset = intent.getIntExtra(EXTRA_TTS_START_OFFSET, -1).takeIf { it >= 0 },
chapterIndex = intent.getIntExtra(EXTRA_TTS_CHAPTER_INDEX, -1).takeIf { it >= 0 },
pageIndex = intent.getIntExtra(EXTRA_TTS_PAGE_INDEX, -1).takeIf { it >= 0 }
)
}
return
}
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) { if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
Timber.d("Received VIEW intent with URI: ${intent.data}") Timber.d("Received VIEW intent with URI: ${intent.data}")
val uri = intent.data!! val uri = intent.data!!

View file

@ -86,9 +86,15 @@ import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.data.BookProcessingWorker import com.aryan.reader.paginatedreader.data.BookProcessingWorker
import com.aryan.reader.pdf.PdfCoverGenerator import com.aryan.reader.pdf.PdfCoverGenerator
import com.aryan.reader.pdf.PDF_BLANK_PAGE_PERSISTENCE_TAG
import com.aryan.reader.pdf.PdfUserHighlight import com.aryan.reader.pdf.PdfUserHighlight
import com.aryan.reader.pdf.PdfiumCoreProvider
import com.aryan.reader.pdf.PdfiumEngineProvider
import com.aryan.reader.pdf.PdfiumAnnotationExporter import com.aryan.reader.pdf.PdfiumAnnotationExporter
import com.aryan.reader.pdf.ReflowWorker import com.aryan.reader.pdf.ReflowWorker
import com.aryan.reader.pdf.pdfLayoutDebugSummary
import com.aryan.reader.pdf.remapPdfAnnotationsForLayoutChange
import com.aryan.reader.pdf.remapPdfBookmarksJsonForLayoutChange
import com.aryan.reader.pdf.data.PageLayoutRepository import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfAnnotationRepository import com.aryan.reader.pdf.data.PdfAnnotationRepository
@ -98,17 +104,18 @@ import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.pdf.data.PdfTextRepository import com.aryan.reader.pdf.data.PdfTextRepository
import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.pptx.PptxCoverGenerator import com.aryan.reader.pptx.PptxCoverGenerator
import com.aryan.reader.shared.SharedFileCapabilities
import com.aryan.reader.shared.SharedLibraryEditor import com.aryan.reader.shared.SharedLibraryEditor
import com.aryan.reader.shared.SharedImportOutcomeCounts import com.aryan.reader.shared.SharedImportOutcomeCounts
import com.aryan.reader.shared.SharedImportPlanner import com.aryan.reader.shared.SharedImportPlanner
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import com.aryan.reader.shared.AppAction as SharedAppAction import com.aryan.reader.shared.AppAction as SharedAppAction
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@ -558,6 +565,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null), activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null),
externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK", externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK",
useStrictFileFilter = prefs.getBoolean(KEY_USE_STRICT_FILE_FILTER, false), useStrictFileFilter = prefs.getBoolean(KEY_USE_STRICT_FILE_FILTER, false),
usePdfFileNameAsDisplayName = prefs.getBoolean(KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME, false),
isScreenCaptureProtectionEnabled = prefs.getBoolean(KEY_SCREEN_CAPTURE_PROTECTION, false), isScreenCaptureProtectionEnabled = prefs.getBoolean(KEY_SCREEN_CAPTURE_PROTECTION, false),
appThemeMode = try { appThemeMode = try {
AppThemeMode.valueOf(prefs.getString(KEY_APP_THEME_MODE, AppThemeMode.SYSTEM.name) ?: AppThemeMode.SYSTEM.name) AppThemeMode.valueOf(prefs.getString(KEY_APP_THEME_MODE, AppThemeMode.SYSTEM.name) ?: AppThemeMode.SYSTEM.name)
@ -568,6 +576,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
appTextDimFactorLight = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, 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)), 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, appSeedColor = if (prefs.contains(KEY_APP_SEED_COLOR)) androidx.compose.ui.graphics.Color(prefs.getInt(KEY_APP_SEED_COLOR, 0)) else null,
appFontPreference = loadAppFontPreference(prefs),
customAppThemes = loadCustomAppThemes(prefs) customAppThemes = loadCustomAppThemes(prefs)
) )
) )
@ -718,6 +727,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
selectedBookId = bookId, selectedBookId = bookId,
selectedFileType = item.type, selectedFileType = item.type,
initialPageInBook = item.lastPage, initialPageInBook = item.lastPage,
initialPageInBookIsExplicit = false,
isOpeningFromTtsNotification = false,
initialBookmarksJson = item.bookmarksJson, initialBookmarksJson = item.bookmarksJson,
isLoading = false, isLoading = false,
errorMessage = null errorMessage = null
@ -1015,57 +1026,50 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
currentBookmarksJson: String, currentBookmarksJson: String,
referenceWidth: Int, referenceWidth: Int,
referenceHeight: Int, referenceHeight: Int,
blankPageId: String? = null,
wasManuallyAdded: Boolean = false wasManuallyAdded: Boolean = false
): PageModificationResult = withContext(Dispatchers.Default) { ): PageModificationResult = withContext(Dispatchers.Default + NonCancellable) {
Timber.d("Adding page at index $insertIndex for book $bookId (manual=$wasManuallyAdded)") Timber.d("Adding page at index $insertIndex for book $bookId (manual=$wasManuallyAdded)")
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"vm.addPage.start bookId=$bookId insertIndex=$insertIndex blankPageId=$blankPageId " +
"manual=$wasManuallyAdded ref=${referenceWidth}x$referenceHeight " +
"current=${currentLayout.pdfLayoutDebugSummary()} annotationPages=${currentAnnotations.keys.sorted()} " +
"bookmarksBytes=${currentBookmarksJson.length}"
)
val newLayout = currentLayout.toMutableList() val newLayout = currentLayout.toMutableList()
val safeIndex = insertIndex.coerceIn(0, newLayout.size) val safeIndex = insertIndex.coerceIn(0, newLayout.size)
val newPage = VirtualPage.BlankPage( val newPage = VirtualPage.BlankPage(
id = UUID.randomUUID().toString(), id = blankPageId ?: UUID.randomUUID().toString(),
width = referenceWidth, width = referenceWidth,
height = referenceHeight, height = referenceHeight,
wasManuallyAdded = wasManuallyAdded wasManuallyAdded = wasManuallyAdded
) )
newLayout.add(safeIndex, newPage) newLayout.add(safeIndex, newPage)
val newAnnotations = mutableMapOf<Int, List<PdfAnnotation>>() val newAnnotations = remapPdfAnnotationsForLayoutChange(
currentAnnotations.forEach { (pageIdx, annots) -> currentLayout = currentLayout,
val newIdx = if (pageIdx >= safeIndex) pageIdx + 1 else pageIdx updatedLayout = newLayout,
val shiftedAnnots = annots.map { it.copy(pageIndex = newIdx) } annotations = currentAnnotations
newAnnotations[newIdx] = shiftedAnnots )
}
val newTotalPages = newLayout.size
val newBookmarksJson = try { val newBookmarksJson = try {
if (currentBookmarksJson.isNotBlank()) { remapPdfBookmarksJsonForLayoutChange(
val jsonArray = JSONArray(currentBookmarksJson) currentLayout = currentLayout,
val newArray = JSONArray() updatedLayout = newLayout,
for (i in 0 until jsonArray.length()) { currentBookmarksJson = currentBookmarksJson
val obj = jsonArray.getJSONObject(i) )
val bmPageIndex = obj.getInt("pageIndex")
val title = obj.getString("title")
val newBmPageIndex = if (bmPageIndex >= safeIndex) bmPageIndex + 1
else bmPageIndex
val newObj = JSONObject()
newObj.put("pageIndex", newBmPageIndex)
newObj.put("title", title)
newObj.put("totalPages", newTotalPages)
newArray.put(newObj)
}
newArray.toString()
} else {
"[]"
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error shifting bookmarks") Timber.e(e, "Error shifting bookmarks")
currentBookmarksJson currentBookmarksJson
} }
pageLayoutRepository.saveLayout(bookId, newLayout) pageLayoutRepository.saveLayout(bookId, newLayout)
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"vm.addPage.done bookId=$bookId safeIndex=$safeIndex new=${newLayout.pdfLayoutDebugSummary()} " +
"newAnnotationPages=${newAnnotations.keys.sorted()} bookmarksBytes=${newBookmarksJson.length}"
)
PageModificationResult(newLayout, newAnnotations, newBookmarksJson) PageModificationResult(newLayout, newAnnotations, newBookmarksJson)
} }
@ -1076,58 +1080,48 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
removeIndex: Int, removeIndex: Int,
currentAnnotations: Map<Int, List<PdfAnnotation>>, currentAnnotations: Map<Int, List<PdfAnnotation>>,
currentBookmarksJson: String currentBookmarksJson: String
): PageModificationResult = withContext(Dispatchers.Default) { ): PageModificationResult = withContext(Dispatchers.Default + NonCancellable) {
Timber.d("Removing page at index $removeIndex for book $bookId") Timber.d("Removing page at index $removeIndex for book $bookId")
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"vm.removePage.start bookId=$bookId removeIndex=$removeIndex " +
"current=${currentLayout.pdfLayoutDebugSummary()} annotationPages=${currentAnnotations.keys.sorted()} " +
"bookmarksBytes=${currentBookmarksJson.length}"
)
val newLayout = currentLayout.toMutableList() val newLayout = currentLayout.toMutableList()
if (removeIndex in newLayout.indices) { if (removeIndex in newLayout.indices) {
newLayout.removeAt(removeIndex) newLayout.removeAt(removeIndex)
} else { } else {
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
"vm.removePage.ignored bookId=$bookId removeIndex=$removeIndex current=${currentLayout.pdfLayoutDebugSummary()}"
)
return@withContext PageModificationResult( return@withContext PageModificationResult(
currentLayout, currentAnnotations, currentBookmarksJson currentLayout, currentAnnotations, currentBookmarksJson
) )
} }
val newAnnotations = mutableMapOf<Int, List<PdfAnnotation>>() val newAnnotations = remapPdfAnnotationsForLayoutChange(
currentAnnotations.forEach { (pageIdx, annots) -> currentLayout = currentLayout,
if (pageIdx != removeIndex) { updatedLayout = newLayout,
val newIdx = if (pageIdx > removeIndex) pageIdx - 1 else pageIdx annotations = currentAnnotations
val shiftedAnnots = annots.map { it.copy(pageIndex = newIdx) } )
newAnnotations[newIdx] = shiftedAnnots
}
}
val newTotalPages = newLayout.size
val newBookmarksJson = try { val newBookmarksJson = try {
if (currentBookmarksJson.isNotBlank()) { remapPdfBookmarksJsonForLayoutChange(
val jsonArray = JSONArray(currentBookmarksJson) currentLayout = currentLayout,
val newArray = JSONArray() updatedLayout = newLayout,
for (i in 0 until jsonArray.length()) { currentBookmarksJson = currentBookmarksJson
val obj = jsonArray.getJSONObject(i) )
val bmPageIndex = obj.getInt("pageIndex")
if (bmPageIndex == removeIndex) continue
val title = obj.getString("title")
val newBmPageIndex = if (bmPageIndex > removeIndex) bmPageIndex - 1
else bmPageIndex
val newObj = JSONObject()
newObj.put("pageIndex", newBmPageIndex)
newObj.put("title", title)
newObj.put("totalPages", newTotalPages)
newArray.put(newObj)
}
newArray.toString()
} else {
"[]"
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error shifting bookmarks") Timber.e(e, "Error shifting bookmarks")
currentBookmarksJson currentBookmarksJson
} }
pageLayoutRepository.saveLayout(bookId, newLayout) pageLayoutRepository.saveLayout(bookId, newLayout)
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"vm.removePage.done bookId=$bookId removeIndex=$removeIndex new=${newLayout.pdfLayoutDebugSummary()} " +
"newAnnotationPages=${newAnnotations.keys.sorted()} bookmarksBytes=${newBookmarksJson.length}"
)
PageModificationResult(newLayout, newAnnotations, newBookmarksJson) PageModificationResult(newLayout, newAnnotations, newBookmarksJson)
} }
@ -1325,7 +1319,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
initialCfi = null, initialCfi = null,
initialBookmarksJson = item.bookmarksJson, initialBookmarksJson = item.bookmarksJson,
initialHighlightsJson = null, initialHighlightsJson = null,
initialPageInBook = item.lastPage initialPageInBook = item.lastPage,
initialPageInBookIsExplicit = false,
isOpeningFromTtsNotification = false
) )
} }
} }
@ -1360,7 +1356,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
initialCfi = item.lastPositionCfi, initialCfi = item.lastPositionCfi,
initialBookmarksJson = item.bookmarksJson, initialBookmarksJson = item.bookmarksJson,
initialHighlightsJson = item.highlightsJson, initialHighlightsJson = item.highlightsJson,
initialPageInBook = null initialPageInBook = null,
initialPageInBookIsExplicit = false,
isOpeningFromTtsNotification = false
) )
} }
} }
@ -1652,7 +1650,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
fun deleteFont(fontId: String) { fun deleteFont(fontId: String) {
viewModelScope.launch { fontsRepository.deleteFont(fontId) } viewModelScope.launch {
fontsRepository.deleteFont(fontId)
if (_internalState.value.appFontPreference.referencesCustomFont(fontId)) {
setAppFontPreference(AppFontPreference.System)
}
}
} }
fun deleteBookPermanently(bookId: String, onDeleted: () -> Unit = {}) { fun deleteBookPermanently(bookId: String, onDeleted: () -> Unit = {}) {
@ -1943,7 +1946,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val sanitizedFilters = filters.copy( val sanitizedFilters = filters.copy(
fileTypes = filters.fileTypes.filterTo(mutableSetOf()) { it in ANDROID_READABLE_FILE_TYPES } fileTypes = filters.fileTypes.filterTo(mutableSetOf()) { it in ANDROID_READABLE_FILE_TYPES }
) )
_internalState.update { it.withSharedLibraryAction(SharedLibraryAction.FiltersChanged(sanitizedFilters.toSharedLibraryFilters())) } _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.FiltersChanged(sanitizedFilters)) }
prefs.edit { prefs.edit {
putStringSet(KEY_FILTER_FILE_TYPES, sanitizedFilters.fileTypes.map { it.name }.toSet()) putStringSet(KEY_FILTER_FILE_TYPES, sanitizedFilters.fileTypes.map { it.name }.toSet())
@ -2264,7 +2267,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
isLoading = false, isLoading = false,
errorMessage = null, errorMessage = null,
initialLocator = null, initialLocator = null,
initialPageInBook = null initialPageInBook = null,
initialPageInBookIsExplicit = false,
isOpeningFromTtsNotification = false
) )
} }
clearPersistedReaderSession() clearPersistedReaderSession()
@ -2351,7 +2356,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
bookTitle = book.title, bookTitle = book.title,
chapterTitle = book.chapters.getOrNull(nextIdx)?.title, chapterTitle = book.chapters.getOrNull(nextIdx)?.title,
coverImageUri = backgroundTtsCoverPath?.let { Uri.fromFile(File(it)).toString() }, coverImageUri = backgroundTtsCoverPath?.let { Uri.fromFile(File(it)).toString() },
bookId = bookId,
chapterIndex = nextIdx, chapterIndex = nextIdx,
totalChapters = totalChapters,
ttsMode = mode, ttsMode = mode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token
@ -3476,7 +3483,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
var description: String? = bundleResult?.description var description: String? = bundleResult?.description
var bookForMetadata = epubBook var bookForMetadata = epubBook
if (bookForMetadata == null && bundleResult == null && (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)) { if (bookForMetadata == null && bundleResult == null && type in EPUB_READER_FILE_TYPES) {
Timber.d("Parsing downloaded book for cover/metadata: $displayName") Timber.d("Parsing downloaded book for cover/metadata: $displayName")
Timber.tag("FileOpenPerf") Timber.tag("FileOpenPerf")
.d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)") .d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)")
@ -3547,7 +3554,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val finalBookMetadata = bookForMetadata val finalBookMetadata = bookForMetadata
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) && finalBookMetadata != null) { if (type in EPUB_READER_FILE_TYPES && finalBookMetadata != null) {
title = title ?: finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName title = title ?: finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName
author = author ?: finalBookMetadata.author.takeIf { author = author ?: finalBookMetadata.author.takeIf {
@ -3568,22 +3575,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (type == FileType.PDF) { if (type == FileType.PDF) {
try { try {
val pdfiumCore = PdfiumCore(appContext)
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val pdfDocument = pdfiumCore.newDocument(pfd) PdfiumEngineProvider.withPdfium {
val meta = pdfiumCore.getDocumentMeta(pdfDocument) PdfiumCoreProvider.core.newDocument(pfd).use { pdfDocument ->
val meta = pdfDocument.getDocumentMeta()
val extractedTitle = meta.title val extractedTitle = meta.title
if (!extractedTitle.isNullOrBlank() && title == displayName) { if (!extractedTitle.isNullOrBlank() && title == displayName) {
title = extractedTitle title = extractedTitle
}
val extractedAuthor = meta.author
if (!extractedAuthor.isNullOrBlank() && author == null) {
author = extractedAuthor
}
}
} }
val extractedAuthor = meta.author
if (!extractedAuthor.isNullOrBlank() && author == null) {
author = extractedAuthor
}
pdfiumCore.closeDocument(pdfDocument)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to extract PDF title using PdfiumCore") Timber.e(e, "Failed to extract PDF title using PdfiumCore")
@ -3686,7 +3693,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
fun setSortOrder(sortOrder: SortOrder) { fun setSortOrder(sortOrder: SortOrder) {
_internalState.update { it.withSharedLibraryAction(SharedLibraryAction.SortChanged(sortOrder.toSharedSortOrder())) } _internalState.update { it.withSharedLibraryAction(SharedLibraryAction.SortChanged(sortOrder)) }
prefs.edit { putString(KEY_SORT_ORDER, sortOrder.name) } prefs.edit { putString(KEY_SORT_ORDER, sortOrder.name) }
} }
@ -3814,7 +3821,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { _internalState.update {
it.copy( it.copy(
bannerMessage = BannerMessage( bannerMessage = BannerMessage(
message = appContext.getString(R.string.banner_importing_multiple, uris.size), message = appContext.resources.getQuantityString(
R.plurals.banner_importing_books_count,
uris.size,
uris.size
),
isPersistent = true isPersistent = true
), ),
contextualActionItems = emptySet() contextualActionItems = emptySet()
@ -3866,8 +3877,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
unsupportedCount = unsupportedCount, unsupportedCount = unsupportedCount,
failedCount = failedCount failedCount = failedCount
), ),
importedMessage = "Imported $importedCount books. You can find them in the Library tab.", importedMessage = appContext.resources.getQuantityString(
duplicateMessage = "Those files are already in the library.", R.plurals.banner_books_imported_library_tab,
importedCount,
importedCount
),
duplicateMessage = appContext.getString(R.string.banner_duplicate_files_already_in_library),
unsupportedMessage = appContext.getString(R.string.error_unsupported_file_type), unsupportedMessage = appContext.getString(R.string.error_unsupported_file_type),
failedMessage = appContext.getString(R.string.error_import_file_failed) failedMessage = appContext.getString(R.string.error_import_file_failed)
) )
@ -3901,6 +3916,52 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
fun openTtsNotificationTarget(
bookId: String,
sourceCfi: String?,
startOffset: Int?,
chapterIndex: Int?,
pageIndex: Int?
) {
viewModelScope.launch {
val item = recentFilesRepository.getFileByBookId(bookId)
if (item == null) {
_internalState.update {
it.copy(errorMessage = appContext.getString(R.string.error_recent_item_not_found))
}
return@launch
}
val uri = item.getUri()
if (uri == null) {
_internalState.update {
it.copy(errorMessage = appContext.getString(R.string.error_file_location_not_found))
}
return@launch
}
val initialLocator = chapterIndex?.let {
Locator(
chapterIndex = it,
blockIndex = 0,
charOffset = startOffset ?: 0
)
}
openBook(
uri = uri,
bookId = item.bookId,
type = item.type,
originalDisplayName = item.displayName,
initialPageOverride = pageIndex,
isInitialPageExplicit = pageIndex != null,
initialLocatorOverride = initialLocator,
initialCfiOverride = sourceCfi?.takeIf { it.isNotBlank() },
preserveTtsOnOpen = true
)
}
}
private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) { private fun importExternalFile(externalUri: Uri, isExternalIntent: Boolean = false) {
_internalState.update { _internalState.update {
it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet()) it.copy(isLoading = true, errorMessage = null, contextualActionItems = emptySet())
@ -4011,6 +4072,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
selectedBookId = bookId, selectedBookId = bookId,
selectedPdfUri = uri, selectedPdfUri = uri,
initialPageInBook = syncPosition, initialPageInBook = syncPosition,
initialPageInBookIsExplicit = true,
isOpeningFromTtsNotification = false,
initialBookmarksJson = item.bookmarksJson, initialBookmarksJson = item.bookmarksJson,
isLoading = false isLoading = false
) )
@ -4201,7 +4264,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
private fun openBook( private fun openBook(
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null uri: Uri,
bookId: String,
type: FileType,
originalDisplayName: String? = null,
suppressNavigation: Boolean = false,
bundleResult: CalibreBundleResult? = null,
initialPageOverride: Int? = null,
isInitialPageExplicit: Boolean = false,
initialLocatorOverride: Locator? = null,
initialCfiOverride: String? = null,
preserveTtsOnOpen: Boolean = false
) { ) {
val openBookStartTime = System.currentTimeMillis() val openBookStartTime = System.currentTimeMillis()
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type") ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
@ -4270,8 +4343,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
selectedFileType = type, selectedFileType = type,
isLoading = true, isLoading = true,
errorMessage = null, errorMessage = null,
initialLocator = null, initialLocator = initialLocatorOverride,
initialPageInBook = null initialCfi = initialCfiOverride,
initialPageInBook = initialPageOverride,
initialPageInBookIsExplicit = isInitialPageExplicit,
isOpeningFromTtsNotification = preserveTtsOnOpen
) )
} }
@ -4284,7 +4360,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { _internalState.update {
it.copy( it.copy(
selectedPdfUri = uri, selectedPdfUri = uri,
initialPageInBook = recentItem?.lastPage, initialPageInBook = initialPageOverride ?: recentItem?.lastPage,
initialPageInBookIsExplicit = isInitialPageExplicit,
isOpeningFromTtsNotification = preserveTtsOnOpen,
initialBookmarksJson = recentItem?.bookmarksJson, initialBookmarksJson = recentItem?.bookmarksJson,
isLoading = false isLoading = false
) )
@ -4308,13 +4386,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileSwitch").d("PDF state updated, suppressing navigation event for smooth transition") Timber.tag("FileSwitch").d("PDF state updated, suppressing navigation event for smooth transition")
} }
} }
} 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) { } else if (type in EPUB_READER_FILE_TYPES) {
viewModelScope.launch { viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId) val recentItem = recentFilesRepository.getFileByBookId(bookId)
Timber.tag("FileOpenPerf") Timber.tag("FileOpenPerf")
.d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms") .d("[$bookId] Branch: ${type.name} | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
val locator = val locator = initialLocatorOverride
if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) { ?: if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) {
Locator( Locator(
chapterIndex = recentItem.lastChapterIndex, chapterIndex = recentItem.lastChapterIndex,
blockIndex = recentItem.locatorBlockIndex, blockIndex = recentItem.locatorBlockIndex,
@ -4328,7 +4406,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
it.copy( it.copy(
selectedEpubUri = uri, selectedEpubUri = uri,
initialLocator = locator, initialLocator = locator,
initialCfi = recentItem?.lastPositionCfi, initialCfi = initialCfiOverride ?: recentItem?.lastPositionCfi,
initialBookmarksJson = recentItem?.bookmarksJson, initialBookmarksJson = recentItem?.bookmarksJson,
initialHighlightsJson = recentItem?.highlightsJson, initialHighlightsJson = recentItem?.highlightsJson,
) )
@ -4372,7 +4450,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
selectedFileType = null, selectedFileType = null,
selectedBookId = null, selectedBookId = null,
isLoading = false, isLoading = false,
errorMessage = appContext.getString(R.string.error_unsupported_file_type) errorMessage = appContext.getString(R.string.error_unsupported_file_type),
initialPageInBookIsExplicit = false,
isOpeningFromTtsNotification = false
) )
} }
} }
@ -5399,6 +5479,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (legacyId == newId) return@withContext if (legacyId == newId) return@withContext
Timber.tag("FolderAnnotationSync") Timber.tag("FolderAnnotationSync")
.d("Checking migration from legacyId=$legacyId to newId=$newId") .d("Checking migration from legacyId=$legacyId to newId=$newId")
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"vm.migrate.check legacyId=$legacyId newId=$newId"
)
try { try {
fun safeMigrate(legacyFile: File?, newFile: File?, tag: String) { fun safeMigrate(legacyFile: File?, newFile: File?, tag: String) {
@ -5430,6 +5513,95 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
fun layoutBlankScore(file: File?): Pair<Int, Int> {
if (file == null || !file.exists()) return 0 to 0
return try {
val array = JSONArray(file.readText())
var blankCount = 0
var manualBlankCount = 0
for (i in 0 until array.length()) {
val page = array.optJSONObject(i) ?: continue
if (page.optString("type") == "blank") {
blankCount++
if (page.optBoolean("manual", false)) manualBlankCount++
}
}
manualBlankCount to blankCount
} catch (e: Exception) {
Timber.tag("FolderAnnotationSync").w(e, "Unable to score layout for migration: ${file.name}")
0 to 0
}
}
fun safeMigrateLayout(legacyFile: File?, newFile: File?) {
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"vm.migrate.layout.start legacyId=$legacyId newId=$newId " +
"legacyPath=${legacyFile?.absolutePath} legacyExists=${legacyFile?.exists()} " +
"legacyBytes=${legacyFile?.takeIf { it.exists() }?.length() ?: 0L} " +
"legacyMtime=${legacyFile?.takeIf { it.exists() }?.lastModified() ?: 0L} " +
"newPath=${newFile?.absolutePath} newExists=${newFile?.exists()} " +
"newBytes=${newFile?.takeIf { it.exists() }?.length() ?: 0L} " +
"newMtime=${newFile?.takeIf { it.exists() }?.lastModified() ?: 0L}"
)
if (legacyFile == null || !legacyFile.exists()) {
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"vm.migrate.layout.noLegacy legacyId=$legacyId newId=$newId"
)
return
}
if (newFile == null) {
Timber.tag("FolderAnnotationSync")
.w("Destination file for layout is null. Skipping.")
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
"vm.migrate.layout.noDestination legacyId=$legacyId newId=$newId"
)
return
}
if (newFile.exists()) {
val legacyTs = legacyFile.lastModified()
val newTs = newFile.lastModified()
val legacyScore = layoutBlankScore(legacyFile)
val newScore = layoutBlankScore(newFile)
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"vm.migrate.layout.compare legacyId=$legacyId newId=$newId " +
"legacyScore=$legacyScore legacyTs=$legacyTs newScore=$newScore newTs=$newTs"
)
val shouldKeepExisting =
newScore.first > legacyScore.first ||
(newScore.first == legacyScore.first && newScore.second > legacyScore.second) ||
(newScore == legacyScore && newTs >= legacyTs)
if (shouldKeepExisting) {
Timber.tag("FolderAnnotationSync")
.i("Skipping layout migration: destination preserves newer or richer blank-page layout.")
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"vm.migrate.layout.keepExisting legacyId=$legacyId newId=$newId"
)
legacyFile.delete()
return
}
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
"vm.migrate.layout.replaceExisting legacyId=$legacyId newId=$newId"
)
newFile.delete()
}
if (legacyFile.renameTo(newFile)) {
Timber.tag("FolderAnnotationSync").i("Migrated layout successfully.")
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"vm.migrate.layout.done legacyId=$legacyId newId=$newId " +
"newExists=${newFile.exists()} newBytes=${newFile.length()} newMtime=${newFile.lastModified()}"
)
} else {
Timber.tag("FolderAnnotationSync").w("Failed to rename layout file.")
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
"vm.migrate.layout.renameFailed legacyId=$legacyId newId=$newId"
)
}
}
// 1. Annotations // 1. Annotations
safeMigrate( safeMigrate(
pdfAnnotationRepository.getAnnotationFileForSync(legacyId), pdfAnnotationRepository.getAnnotationFileForSync(legacyId),
@ -5445,10 +5617,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) )
// 3. Layout // 3. Layout
safeMigrate( safeMigrateLayout(
pageLayoutRepository.getLayoutFile(legacyId), pageLayoutRepository.getLayoutFile(legacyId),
pageLayoutRepository.getLayoutFile(newId), pageLayoutRepository.getLayoutFile(newId)
"layout"
) )
// 4. Text Boxes // 4. Text Boxes
@ -5616,6 +5787,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(useStrictFileFilter = enabled) } _internalState.update { it.copy(useStrictFileFilter = enabled) }
} }
fun setUsePdfFileNameAsDisplayName(enabled: Boolean) {
prefs.edit { putBoolean(KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME, enabled) }
_internalState.update { it.copy(usePdfFileNameAsDisplayName = enabled) }
}
fun setScreenCaptureProtectionEnabled(enabled: Boolean) { fun setScreenCaptureProtectionEnabled(enabled: Boolean) {
prefs.edit { putBoolean(KEY_SCREEN_CAPTURE_PROTECTION, enabled) } prefs.edit { putBoolean(KEY_SCREEN_CAPTURE_PROTECTION, enabled) }
_internalState.update { it.copy(isScreenCaptureProtectionEnabled = enabled) } _internalState.update { it.copy(isScreenCaptureProtectionEnabled = enabled) }
@ -5642,13 +5818,41 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return themes return themes
} }
private fun loadAppFontPreference(prefs: SharedPreferences): AppFontPreference {
val kind = try {
AppFontPreferenceKind.valueOf(
prefs.getString(KEY_APP_FONT_KIND, AppFontPreferenceKind.SYSTEM.name)
?: AppFontPreferenceKind.SYSTEM.name
)
} catch (_: Exception) {
AppFontPreferenceKind.SYSTEM
}
return AppFontPreference(
kind = kind,
customFontId = prefs.getString(KEY_APP_FONT_CUSTOM_ID, null)
).sanitized()
}
fun setAppFontPreference(preference: AppFontPreference) {
val sanitized = preference.sanitized()
_internalState.update { it.withSharedAppAction(SharedAppAction.AppFontPreferenceChanged(sanitized)) }
prefs.edit {
putString(KEY_APP_FONT_KIND, sanitized.kind.name)
if (sanitized.customFontId == null) {
remove(KEY_APP_FONT_CUSTOM_ID)
} else {
putString(KEY_APP_FONT_CUSTOM_ID, sanitized.customFontId)
}
}
}
fun setAppThemeMode(mode: AppThemeMode) { fun setAppThemeMode(mode: AppThemeMode) {
_internalState.update { it.withSharedAppAction(SharedAppAction.AppThemeChanged(mode.toSharedAppThemeMode())) } _internalState.update { it.withSharedAppAction(SharedAppAction.AppThemeChanged(mode)) }
prefs.edit { putString(KEY_APP_THEME_MODE, mode.name) } prefs.edit { putString(KEY_APP_THEME_MODE, mode.name) }
} }
fun setAppContrastOption(option: AppContrastOption) { fun setAppContrastOption(option: AppContrastOption) {
_internalState.update { it.withSharedAppAction(SharedAppAction.AppContrastChanged(option.toSharedAppContrastOption())) } _internalState.update { it.withSharedAppAction(SharedAppAction.AppContrastChanged(option)) }
prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) } prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) }
} }
@ -5674,7 +5878,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
fun addCustomAppTheme(theme: CustomAppTheme) { fun addCustomAppTheme(theme: CustomAppTheme) {
_internalState.update { it.withSharedAppAction(SharedAppAction.CustomAppThemeAdded(theme.toSharedCustomAppTheme())) } _internalState.update { it.withSharedAppAction(SharedAppAction.CustomAppThemeAdded(theme)) }
val current = _internalState.value.customAppThemes val current = _internalState.value.customAppThemes
saveCustomAppThemes(current) saveCustomAppThemes(current)
prefs.edit { putInt(KEY_APP_SEED_COLOR, theme.seedColor.toArgb()) } prefs.edit { putInt(KEY_APP_SEED_COLOR, theme.seedColor.toArgb()) }
@ -5905,6 +6109,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type" private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type"
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior" private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter" private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter"
private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name"
private const val KEY_SCREEN_CAPTURE_PROTECTION = "screen_capture_protection_enabled" private const val KEY_SCREEN_CAPTURE_PROTECTION = "screen_capture_protection_enabled"
private const val KEY_APP_THEME_MODE = "app_theme_mode" private const val KEY_APP_THEME_MODE = "app_theme_mode"
private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option" private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option"
@ -5912,23 +6117,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_APP_TEXT_DIM_FACTOR = "app_text_dim_factor" 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_LIGHT = "app_text_dim_factor_light"
private const val KEY_APP_TEXT_DIM_FACTOR_DARK = "app_text_dim_factor_dark" private const val KEY_APP_TEXT_DIM_FACTOR_DARK = "app_text_dim_factor_dark"
private const val KEY_APP_FONT_KIND = "app_font_kind"
private const val KEY_APP_FONT_CUSTOM_ID = "app_font_custom_id"
private const val KEY_CUSTOM_APP_THEMES = "custom_app_themes" private const val KEY_CUSTOM_APP_THEMES = "custom_app_themes"
val SUPPORTED_MIME_TYPES = arrayOf( val SUPPORTED_MIME_TYPES = SharedFileCapabilities.androidFilePickerMimeTypes.toTypedArray()
"application/pdf", "application/epub+zip", "application/x-mobipocket-ebook",
"application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook", "text/markdown",
"text/x-markdown", "text/plain", "text/html", "application/xhtml+xml",
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2", "application/zip",
"application/vnd.comicbook+zip", "application/x-cbz", "application/vnd.comicbook-rar",
"application/x-cbr", "application/x-rar-compressed", "application/x-cb7",
"application/x-7z-compressed", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text", "application/x-vnd.oasis.opendocument.text-flat-xml",
"text/csv", "text/comma-separated-values", "text/tab-separated-values", "application/json",
"application/xml", "text/xml", "text/x-java-source", "text/x-python", "text/x-kotlin",
"text/javascript", "application/javascript", "text/x-c", "text/x-c++",
"text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log"
)
} }
} }

View file

@ -12,7 +12,8 @@ import androidx.work.WorkerParameters
import androidx.work.WorkManager import androidx.work.WorkManager
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository import com.aryan.reader.data.RecentFilesRepository
import io.legere.pdfiumandroid.PdfiumCore import com.aryan.reader.pdf.PdfiumCoreProvider
import com.aryan.reader.pdf.PdfiumEngineProvider
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
@ -277,16 +278,14 @@ class MetadataExtractionWorker(
return parseXmlTextMetadata(xml) return parseXmlTextMetadata(xml)
} }
private fun parsePdfTextMetadata(uri: android.net.Uri): TextMetadata { private suspend fun parsePdfTextMetadata(uri: android.net.Uri): TextMetadata {
return try { return try {
val pdfiumCore = PdfiumCore(appContext)
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val pdfDocument = pdfiumCore.newDocument(pfd) PdfiumEngineProvider.withPdfium {
try { PdfiumCoreProvider.core.newDocument(pfd).use { pdfDocument ->
val meta = pdfiumCore.getDocumentMeta(pdfDocument) val meta = pdfDocument.getDocumentMeta()
TextMetadata(title = meta.title, author = meta.author) TextMetadata(title = meta.title, author = meta.author)
} finally { }
pdfiumCore.closeDocument(pdfDocument)
} }
} ?: TextMetadata() } ?: TextMetadata()
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -0,0 +1,193 @@
package com.aryan.reader
import android.content.Context
import android.view.Window
import android.view.WindowManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import kotlin.math.roundToInt
private const val READER_PREFS_NAME = "reader_prefs"
private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system"
private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value"
private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f
private const val MIN_CUSTOM_BRIGHTNESS = 0.05f
data class ReaderBrightnessSettings(
val useSystemBrightness: Boolean = true,
val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS
) {
val safeCustomBrightness: Float
get() = customBrightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
}
fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
return ReaderBrightnessSettings(
useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true),
customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS)
.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
)
}
fun saveReaderBrightnessSettings(context: Context, settings: ReaderBrightnessSettings) {
context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE).edit {
putBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, settings.useSystemBrightness)
putFloat(PREF_READER_BRIGHTNESS_VALUE, settings.safeCustomBrightness)
}
}
@Composable
fun ReaderBrightnessEffect(
window: Window?,
settings: ReaderBrightnessSettings
) {
DisposableEffect(window) {
val originalBrightness = window?.attributes?.screenBrightness
?: WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
onDispose {
window?.setReaderBrightness(originalBrightness)
}
}
LaunchedEffect(window, settings) {
val brightness = if (settings.useSystemBrightness) {
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
} else {
settings.safeCustomBrightness
}
window?.setReaderBrightness(brightness)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderBrightnessSheet(
settings: ReaderBrightnessSettings,
onSettingsChange: (ReaderBrightnessSettings) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.reader_brightness_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_done))
}
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.reader_brightness_system),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(R.string.reader_brightness_system_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = settings.useSystemBrightness,
onCheckedChange = { useSystem ->
onSettingsChange(settings.copy(useSystemBrightness = useSystem))
}
)
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.reader_brightness_custom),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(
R.string.reader_brightness_percent,
(settings.safeCustomBrightness * 100f).roundToInt()
),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = settings.safeCustomBrightness,
onValueChange = { brightness ->
onSettingsChange(
settings.copy(
useSystemBrightness = false,
customBrightness = brightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
)
)
},
valueRange = MIN_CUSTOM_BRIGHTNESS..1f
)
Text(
text = stringResource(R.string.reader_brightness_custom_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(4.dp))
}
}
}
private fun Window.setReaderBrightness(brightness: Float) {
attributes = attributes.apply {
screenBrightness = brightness
}
}

View file

@ -0,0 +1,104 @@
package com.aryan.reader
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import com.aryan.reader.data.RecentFileItem
@Composable
private fun rememberReaderFileInfoItem(
uiState: ReaderScreenState,
primaryBookId: String?,
secondaryBookId: String? = null,
uriString: String? = null
): RecentFileItem? {
return remember(
uiState.allRecentFiles,
uiState.recentFiles,
primaryBookId,
secondaryBookId,
uriString
) {
findReaderFileInfoItem(
uiState = uiState,
primaryBookId = primaryBookId,
secondaryBookId = secondaryBookId,
uriString = uriString
)
}
}
private fun findReaderFileInfoItem(
uiState: ReaderScreenState,
primaryBookId: String?,
secondaryBookId: String?,
uriString: String?
): RecentFileItem? {
val bookId = primaryBookId ?: secondaryBookId
return uiState.allRecentFiles.firstOrNull { it.bookId == bookId }
?: uiState.recentFiles.firstOrNull { it.bookId == bookId }
?: uiState.allRecentFiles.firstOrNull { it.uriString == uriString }
?: uiState.recentFiles.firstOrNull { it.uriString == uriString }
}
@Composable
internal fun ReaderFileInfoDialogs(
isFileInfoVisible: Boolean,
onFileInfoVisibleChange: (Boolean) -> Unit,
uiState: ReaderScreenState,
primaryBookId: String?,
secondaryBookId: String? = null,
uriString: String? = null,
viewModel: MainViewModel
) {
val item = rememberReaderFileInfoItem(
uiState = uiState,
primaryBookId = primaryBookId,
secondaryBookId = secondaryBookId,
uriString = uriString
)
LaunchedEffect(item?.bookId) {
if (item == null) {
onFileInfoVisibleChange(false)
}
}
item?.let { fileInfoItem ->
if (isFileInfoVisible) {
FileInfoDialog(
item = fileInfoItem,
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName,
onDismiss = { onFileInfoVisibleChange(false) },
onSaveMetadata = { metadata ->
viewModel.updateBookMetadata(fileInfoItem.bookId, metadata)
},
onSaveDisplayName = { name ->
viewModel.updateCustomName(fileInfoItem.bookId, name)
},
onRestoreMetadata = {
viewModel.restoreOriginalBookMetadata(fileInfoItem.bookId)
},
onOpenTags = {
onFileInfoVisibleChange(false)
viewModel.openTagSelection(setOf(fileInfoItem.bookId))
}
)
}
}
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
TagSelectionBottomSheet(
allTags = uiState.allTags,
selectedBookIds = uiState.showTagSelectionDialogFor,
booksWithTags = uiState.rawLibraryFiles,
onCreateAndAssign = { name ->
viewModel.createAndAssignTag(name, uiState.showTagSelectionDialogFor)
},
onToggleTag = { tagId, assign ->
viewModel.toggleTagForBooks(tagId, uiState.showTagSelectionDialogFor, assign)
},
onDismiss = viewModel::closeTagSelection
)
}
}

View file

@ -0,0 +1,137 @@
package com.aryan.reader
import android.content.Context
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.core.content.edit
import kotlin.math.max
import kotlin.math.min
private const val READER_SLIDER_CHROME_PREFS = "reader_slider_chrome_prefs"
private const val READER_SLIDER_TOGGLE_PREFIX = "reader_slider_toggle_"
private const val MIN_SLIDER_ACCENT_CONTRAST = 3f
private const val MIN_SLIDER_CONTENT_CONTRAST = 4.5f
internal data class ReaderSliderBookmarkPosition(
val startPage: Int,
val currentPage: Float
)
internal data class ReaderSliderToggleState(
val isToggledOn: Boolean,
val bookmarkPosition: ReaderSliderBookmarkPosition
)
internal data class ReaderSliderChromeColors(
val activeTrackColor: Color,
val inactiveTrackColor: Color,
val thumbColor: Color,
val bookmarkColor: Color,
val contentColor: Color,
val thumbnailSurfaceColor: Color,
val thumbnailContentColor: Color
)
internal fun readerSliderBookmarkPosition(currentPage: Int): ReaderSliderBookmarkPosition {
val sanitizedPage = currentPage.coerceAtLeast(0)
return ReaderSliderBookmarkPosition(
startPage = sanitizedPage,
currentPage = sanitizedPage.toFloat()
)
}
internal fun readerSliderToggleState(
isCurrentlyToggledOn: Boolean,
currentPage: Int
): ReaderSliderToggleState {
return ReaderSliderToggleState(
isToggledOn = !isCurrentlyToggledOn,
bookmarkPosition = readerSliderBookmarkPosition(currentPage)
)
}
internal fun shouldRenderReaderSlider(
isToggledOn: Boolean,
isBottomChromeVisible: Boolean,
isSearchActive: Boolean
): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive
internal fun readerSliderTogglePreferenceKey(bookId: String): String =
READER_SLIDER_TOGGLE_PREFIX + bookId
internal fun loadReaderSliderToggled(context: Context, bookId: String): Boolean {
return context
.getSharedPreferences(READER_SLIDER_CHROME_PREFS, Context.MODE_PRIVATE)
.getBoolean(readerSliderTogglePreferenceKey(bookId), false)
}
internal fun saveReaderSliderToggled(
context: Context,
bookId: String,
isToggledOn: Boolean
) {
context
.getSharedPreferences(READER_SLIDER_CHROME_PREFS, Context.MODE_PRIVATE)
.edit { putBoolean(readerSliderTogglePreferenceKey(bookId), isToggledOn) }
}
internal fun readerSliderChromeColors(
pageBackground: Color,
pageText: Color,
themePrimary: Color
): ReaderSliderChromeColors {
val background = specifiedColorOr(pageBackground, Color.White)
val fallbackContent = highContrastColorFor(background)
val content = specifiedColorOr(pageText, fallbackContent)
.takeIf { contrastRatio(it, background) >= MIN_SLIDER_CONTENT_CONTRAST }
?: fallbackContent
val active = specifiedColorOr(themePrimary, content)
.takeIf { contrastRatio(it, background) >= MIN_SLIDER_ACCENT_CONTRAST }
?: content
val inactiveAlpha = if (background.luminance() > 0.5f) 0.44f else 0.52f
val thumbnailSurface = blendColors(
foreground = content,
background = background,
alpha = if (background.luminance() > 0.5f) 0.08f else 0.12f
).copy(alpha = 0.96f)
return ReaderSliderChromeColors(
activeTrackColor = active,
inactiveTrackColor = content.copy(alpha = inactiveAlpha),
thumbColor = active,
bookmarkColor = active,
contentColor = content,
thumbnailSurfaceColor = thumbnailSurface,
thumbnailContentColor = content
)
}
private fun specifiedColorOr(color: Color, fallback: Color): Color {
return if (color == Color.Unspecified) fallback else color
}
private fun highContrastColorFor(background: Color): Color {
return if (contrastRatio(Color.Black, background) >= contrastRatio(Color.White, background)) {
Color.Black
} else {
Color.White
}
}
private fun contrastRatio(first: Color, second: Color): Float {
val firstLuminance = first.luminance()
val secondLuminance = second.luminance()
val lighter = max(firstLuminance, secondLuminance)
val darker = min(firstLuminance, secondLuminance)
return (lighter + 0.05f) / (darker + 0.05f)
}
private fun blendColors(foreground: Color, background: Color, alpha: Float): Color {
val clampedAlpha = alpha.coerceIn(0f, 1f)
return Color(
red = foreground.red * clampedAlpha + background.red * (1f - clampedAlpha),
green = foreground.green * clampedAlpha + background.green * (1f - clampedAlpha),
blue = foreground.blue * clampedAlpha + background.blue * (1f - clampedAlpha),
alpha = 1f
)
}

View file

@ -0,0 +1,35 @@
package com.aryan.reader
import android.content.Context
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import java.util.IllegalFormatException
import java.util.Locale
@Composable
fun safeStringResource(@StringRes id: Int, vararg formatArgs: Any?): String {
LocalConfiguration.current
return LocalContext.current.safeGetString(id, *formatArgs)
}
fun Context.safeGetString(@StringRes id: Int, vararg formatArgs: Any?): String {
return try {
resources.getString(id, *formatArgs)
} catch (_: IllegalFormatException) {
try {
englishResources().getString(id, *formatArgs)
} catch (_: IllegalFormatException) {
resources.getString(id)
}
}
}
private fun Context.englishResources() =
createConfigurationContext(
Configuration(resources.configuration).apply {
setLocale(Locale.ENGLISH)
}
).resources

View file

@ -29,11 +29,8 @@ import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epubreader.FormatSettings as AndroidFormatSettings import com.aryan.reader.epubreader.FormatSettings as AndroidFormatSettings
import com.aryan.reader.epubreader.PageInfoMode as AndroidPageInfoMode
import com.aryan.reader.epubreader.PageInfoPosition as AndroidPageInfoPosition
import com.aryan.reader.epubreader.ReaderFont as AndroidReaderFont import com.aryan.reader.epubreader.ReaderFont as AndroidReaderFont
import com.aryan.reader.epubreader.ReaderTextAlign as AndroidReaderTextAlign import com.aryan.reader.epubreader.ReaderTextAlign as AndroidReaderTextAlign
import com.aryan.reader.epubreader.SystemUiMode as AndroidSystemUiMode
import com.aryan.reader.epubreader.loadFormatSettings import com.aryan.reader.epubreader.loadFormatSettings
import com.aryan.reader.epubreader.loadPageInfoMode import com.aryan.reader.epubreader.loadPageInfoMode
import com.aryan.reader.epubreader.loadPageInfoPosition import com.aryan.reader.epubreader.loadPageInfoPosition
@ -56,12 +53,11 @@ import com.aryan.reader.pdf.loadPdfVerticalPageGapVisible
import com.aryan.reader.pdf.loadPdfPageNumberOverlayVisible import com.aryan.reader.pdf.loadPdfPageNumberOverlayVisible
import com.aryan.reader.shared.BuiltInPdfReaderThemes import com.aryan.reader.shared.BuiltInPdfReaderThemes
import com.aryan.reader.shared.CustomFontItem import com.aryan.reader.shared.CustomFontItem
import com.aryan.reader.shared.PageInfoMode as SharedPageInfoMode
import com.aryan.reader.shared.PageInfoPosition as SharedPageInfoPosition
import com.aryan.reader.shared.SharedSettingsAction import com.aryan.reader.shared.SharedSettingsAction
import com.aryan.reader.shared.SharedSettingsDestination import com.aryan.reader.shared.SharedSettingsDestination
import com.aryan.reader.shared.SystemUiMode as SharedSystemUiMode
import com.aryan.reader.shared.parentDestination import com.aryan.reader.shared.parentDestination
import com.aryan.reader.shared.toReaderSettingsFontFamily
import com.aryan.reader.shared.toSharedReaderTextAlign
import com.aryan.reader.shared.reader.ReaderReadingMode import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.SharedReaderTextAlign import com.aryan.reader.shared.reader.SharedReaderTextAlign
@ -204,6 +200,9 @@ fun SettingsScreen(
} }
} }
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR -> showBehaviorDialog = true SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR -> showBehaviorDialog = true
SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME -> {
viewModel.setUsePdfFileNameAsDisplayName(!uiState.usePdfFileNameAsDisplayName)
}
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> { SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> {
val next = !uiState.isScreenCaptureProtectionEnabled val next = !uiState.isScreenCaptureProtectionEnabled
viewModel.setScreenCaptureProtectionEnabled(next) viewModel.setScreenCaptureProtectionEnabled(next)
@ -438,9 +437,9 @@ private fun loadAndroidEpubReaderDefaultSettings(
themeId = loadReaderThemeId(context), themeId = loadReaderThemeId(context),
textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f), textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f),
customFontPath = format.customPath?.takeIf { it.isNotBlank() }, customFontPath = format.customPath?.takeIf { it.isNotBlank() },
systemUiMode = loadSystemUiMode(context).toSharedSystemUiMode(), systemUiMode = loadSystemUiMode(context),
pageInfoMode = loadPageInfoMode(context).toSharedPageInfoMode(), pageInfoMode = loadPageInfoMode(context),
pageInfoPosition = loadPageInfoPosition(context).toSharedPageInfoPosition(), pageInfoPosition = loadPageInfoPosition(context),
seamlessChapterNavigation = loadPullToTurn(context), seamlessChapterNavigation = loadPullToTurn(context),
chapterTurnDragMultiplier = loadPullToTurnMultiplier(context) chapterTurnDragMultiplier = loadPullToTurnMultiplier(context)
) )
@ -453,7 +452,7 @@ private fun loadAndroidPdfReaderDefaultSettings(
val base = ReaderSettings( val base = ReaderSettings(
themeId = loadPdfThemeId(context), themeId = loadPdfThemeId(context),
textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f), textureAlpha = (1f - loadGlobalTextureTransparency(context)).coerceIn(0f, 1f),
systemUiMode = loadPdfSystemUiMode(context).toSharedSystemUiMode(), systemUiMode = loadPdfSystemUiMode(context),
pdfVerticalPageGapVisible = loadPdfVerticalPageGapVisible(context), pdfVerticalPageGapVisible = loadPdfVerticalPageGapVisible(context),
pdfPageNumberOverlayVisible = loadPdfPageNumberOverlayVisible(context) pdfPageNumberOverlayVisible = loadPdfPageNumberOverlayVisible(context)
) )
@ -476,9 +475,9 @@ private fun saveAndroidEpubReaderDefaultSettings(
customFontPath = settings.customFontPath, customFontPath = settings.customFontPath,
textAlign = settings.textAlign.toAndroidTextAlign() textAlign = settings.textAlign.toAndroidTextAlign()
) )
saveSystemUiMode(context, settings.systemUiMode.toAndroidSystemUiMode()) saveSystemUiMode(context, settings.systemUiMode)
savePageInfoMode(context, settings.pageInfoMode.toAndroidPageInfoMode()) savePageInfoMode(context, settings.pageInfoMode)
savePageInfoPosition(context, settings.pageInfoPosition.toAndroidPageInfoPosition()) savePageInfoPosition(context, settings.pageInfoPosition)
savePullToTurn(context, settings.seamlessChapterNavigation) savePullToTurn(context, settings.seamlessChapterNavigation)
savePullToTurnMultiplier(context, settings.chapterTurnDragMultiplier) savePullToTurnMultiplier(context, settings.chapterTurnDragMultiplier)
saveReaderThemeId(context, settings.themeId ?: "system") saveReaderThemeId(context, settings.themeId ?: "system")
@ -489,7 +488,7 @@ private fun saveAndroidPdfReaderDefaultSettings(
context: Context, context: Context,
settings: ReaderSettings settings: ReaderSettings
) { ) {
savePdfSystemUiMode(context, settings.systemUiMode.toAndroidSystemUiMode()) savePdfSystemUiMode(context, settings.systemUiMode)
savePdfThemeId(context, settings.themeId ?: "no_theme") savePdfThemeId(context, settings.themeId ?: "no_theme")
savePdfVerticalPageGapVisible(context, settings.pdfVerticalPageGapVisible) savePdfVerticalPageGapVisible(context, settings.pdfVerticalPageGapVisible)
savePdfPageNumberOverlayVisible(context, settings.pdfPageNumberOverlayVisible) savePdfPageNumberOverlayVisible(context, settings.pdfPageNumberOverlayVisible)
@ -514,23 +513,7 @@ private fun List<CustomFontEntity>.toSharedCustomFontItems(): List<CustomFontIte
private fun AndroidFormatSettings.toSharedFontFamilyName(): String { private fun AndroidFormatSettings.toSharedFontFamilyName(): String {
return customPath?.substringAfterLast('/')?.substringAfterLast('\\')?.takeIf { it.isNotBlank() } return customPath?.substringAfterLast('/')?.substringAfterLast('\\')?.takeIf { it.isNotBlank() }
?: when (font) { ?: font.toReaderSettingsFontFamily()
AndroidReaderFont.ORIGINAL -> "Default"
AndroidReaderFont.MERRIWEATHER,
AndroidReaderFont.LORA -> "Serif"
AndroidReaderFont.LATO,
AndroidReaderFont.LEXEND -> "Sans"
AndroidReaderFont.ROBOTO_MONO -> "Mono"
}
}
private fun AndroidReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
return when (this) {
AndroidReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
AndroidReaderTextAlign.RIGHT -> SharedReaderTextAlign.RIGHT
AndroidReaderTextAlign.DEFAULT,
AndroidReaderTextAlign.LEFT -> SharedReaderTextAlign.START
}
} }
private fun ReaderSettings.toAndroidReaderFont(): AndroidReaderFont { private fun ReaderSettings.toAndroidReaderFont(): AndroidReaderFont {
@ -564,27 +547,3 @@ private fun ReaderSettings.toAndroidRenderMode(): RenderMode {
ReaderReadingMode.VERTICAL -> RenderMode.VERTICAL_SCROLL ReaderReadingMode.VERTICAL -> RenderMode.VERTICAL_SCROLL
} }
} }
private fun AndroidSystemUiMode.toSharedSystemUiMode(): SharedSystemUiMode {
return SharedSystemUiMode.valueOf(name)
}
private fun SharedSystemUiMode.toAndroidSystemUiMode(): AndroidSystemUiMode {
return AndroidSystemUiMode.valueOf(name)
}
private fun AndroidPageInfoMode.toSharedPageInfoMode(): SharedPageInfoMode {
return SharedPageInfoMode.valueOf(name)
}
private fun SharedPageInfoMode.toAndroidPageInfoMode(): AndroidPageInfoMode {
return AndroidPageInfoMode.valueOf(name)
}
private fun AndroidPageInfoPosition.toSharedPageInfoPosition(): SharedPageInfoPosition {
return SharedPageInfoPosition.valueOf(name)
}
private fun SharedPageInfoPosition.toAndroidPageInfoPosition(): AndroidPageInfoPosition {
return AndroidPageInfoPosition.valueOf(name)
}

View file

@ -144,6 +144,7 @@ import androidx.core.net.toUri
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import com.aryan.reader.data.BookMetadataEdit import com.aryan.reader.data.BookMetadataEdit
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.shared.SharedText
import com.aryan.reader.shared.ui.SharedMarkdownText import com.aryan.reader.shared.ui.SharedMarkdownText
import timber.log.Timber import timber.log.Timber
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -239,7 +240,8 @@ fun rememberFilePickerLauncher(
contract = ActivityResultContracts.OpenMultipleDocuments(), contract = ActivityResultContracts.OpenMultipleDocuments(),
onResult = { uris: List<Uri> -> onResult = { uris: List<Uri> ->
if (uris.isNotEmpty()) { if (uris.isNotEmpty()) {
Timber.d("${uris.size} file(s) selected.") val fileLabel = if (uris.size == 1) "file" else "files"
Timber.d("${uris.size} $fileLabel selected.")
onFilesSelected(uris) onFilesSelected(uris)
} else { } else {
Timber.d("File selection cancelled.") Timber.d("File selection cancelled.")
@ -383,6 +385,7 @@ fun DeleteConfirmationDialog(
@Composable @Composable
fun FileInfoDialog( fun FileInfoDialog(
item: RecentFileItem, item: RecentFileItem,
usePdfFileNameAsDisplayName: Boolean = false,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSaveMetadata: (BookMetadataEdit) -> Unit, onSaveMetadata: (BookMetadataEdit) -> Unit,
onSaveDisplayName: (String?) -> Unit, onSaveDisplayName: (String?) -> Unit,
@ -399,8 +402,8 @@ fun FileInfoDialog(
mutableStateOf(item.seriesIndex?.formatMetadataNumber().orEmpty()) mutableStateOf(item.seriesIndex?.formatMetadataNumber().orEmpty())
} }
var descriptionInput by remember(item.bookId, item.description) { mutableStateOf(item.description.orEmpty()) } var descriptionInput by remember(item.bookId, item.description) { mutableStateOf(item.description.orEmpty()) }
var displayNameInput by remember(item.bookId, item.customName, item.title, item.displayName) { var displayNameInput by remember(item.bookId, item.customName, item.title, item.displayName, usePdfFileNameAsDisplayName) {
mutableStateOf(item.customName ?: item.cardTitle()) mutableStateOf(item.customName ?: item.cardTitle(usePdfFileNameAsDisplayName))
} }
var showRestoreConfirmation by remember(item.bookId) { mutableStateOf(false) } var showRestoreConfirmation by remember(item.bookId) { mutableStateOf(false) }
@ -455,7 +458,7 @@ fun FileInfoDialog(
} else { } else {
stringResource(R.string.file_information) stringResource(R.string.file_information)
}, },
subtitle = item.cardTitle(), subtitle = item.cardTitle(usePdfFileNameAsDisplayName),
onClose = { onClose = {
if (isEditing) { if (isEditing) {
isEditing = false isEditing = false
@ -498,6 +501,7 @@ fun FileInfoDialog(
} else { } else {
BookMetadataInfoContent( BookMetadataInfoContent(
item = item, item = item,
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName,
formattedDate = formattedDate, formattedDate = formattedDate,
lastModifiedDate = lastModifiedDate, lastModifiedDate = lastModifiedDate,
pathText = pathTextFinal, pathText = pathTextFinal,
@ -611,6 +615,7 @@ private fun FileInfoTopBar(
@Composable @Composable
private fun BookMetadataInfoContent( private fun BookMetadataInfoContent(
item: RecentFileItem, item: RecentFileItem,
usePdfFileNameAsDisplayName: Boolean,
formattedDate: String, formattedDate: String,
lastModifiedDate: String?, lastModifiedDate: String?,
pathText: String, pathText: String,
@ -624,7 +629,7 @@ private fun BookMetadataInfoContent(
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Text( Text(
item.cardTitle(), item.cardTitle(usePdfFileNameAsDisplayName),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 3, maxLines = 3,
@ -1120,6 +1125,8 @@ private fun Double.formatMetadataNumber(): String {
@Composable @Composable
fun CustomTopBanner(bannerMessage: BannerMessage?) { fun CustomTopBanner(bannerMessage: BannerMessage?) {
val context = LocalContext.current
val bannerText = bannerMessage?.localizedMessage(context).orEmpty()
AnimatedVisibility( AnimatedVisibility(
visible = bannerMessage != null, visible = bannerMessage != null,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
@ -1138,7 +1145,7 @@ fun CustomTopBanner(bannerMessage: BannerMessage?) {
shadowElevation = 8.dp shadowElevation = 8.dp
) { ) {
Text( Text(
text = bannerMessage?.message ?: "", text = bannerText,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
color = if (bannerMessage?.isError == true) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSecondaryContainer, color = if (bannerMessage?.isError == true) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSecondaryContainer,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@ -1149,6 +1156,25 @@ fun CustomTopBanner(bannerMessage: BannerMessage?) {
} }
} }
private fun BannerMessage.localizedMessage(context: Context): String {
return text?.resolveAndroidText(context) ?: message
}
private fun SharedText.resolveAndroidText(context: Context): String {
val resources = context.resources
val packageName = context.packageName
val formatArgs = args.toTypedArray()
val quantityValue = quantity
val resolved = if (quantityValue == null) {
val id = resources.getIdentifier(name, "string", packageName)
if (id == 0) null else runCatching { resources.getString(id, *formatArgs) }.getOrNull()
} else {
val id = resources.getIdentifier(name, "plurals", packageName)
if (id == 0) null else runCatching { resources.getQuantityString(id, quantityValue, *formatArgs) }.getOrNull()
}
return resolved ?: fallbackMessage()
}
@Suppress("KotlinConstantConditions") @Suppress("KotlinConstantConditions")
@Composable @Composable
fun AboutDialog(onDismiss: () -> Unit) { fun AboutDialog(onDismiss: () -> Unit) {
@ -1429,7 +1455,12 @@ fun AutoSizeText(
} }
@Composable @Composable
fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolean = false) { fun FileTypeBadge(
type: FileType,
modifier: Modifier = Modifier,
overlay: Boolean = false,
compact: Boolean = false
) {
val containerColor = if (overlay) Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer val containerColor = if (overlay) Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer
val contentColor = if (overlay) Color.White else MaterialTheme.colorScheme.onSecondaryContainer val contentColor = if (overlay) Color.White else MaterialTheme.colorScheme.onSecondaryContainer
@ -1442,9 +1473,17 @@ fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolea
) { ) {
Text( Text(
text = if (type == FileType.UNKNOWN) "FILE" else type.name.uppercase(), text = if (type == FileType.UNKNOWN) "FILE" else type.name.uppercase(),
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp), style = if (compact) {
MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp, letterSpacing = 0.sp)
} else {
MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp)
},
fontWeight = FontWeight.ExtraBold, fontWeight = FontWeight.ExtraBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) maxLines = 1,
modifier = Modifier.padding(
horizontal = if (compact) 6.dp else 10.dp,
vertical = if (compact) 3.dp else 4.dp
)
) )
} }
} }
@ -1498,8 +1537,12 @@ fun BookTagChipsRow(
private const val UNKNOWN_AUTHOR_LABEL = "No author listed" private const val UNKNOWN_AUTHOR_LABEL = "No author listed"
fun RecentFileItem.cardTitle(): String { fun RecentFileItem.cardTitle(usePdfFileNameAsDisplayName: Boolean = false): String {
return customName ?: title?.takeIf { it.isNotBlank() } ?: displayName customName?.takeIf { it.isNotBlank() }?.let { return it }
if (usePdfFileNameAsDisplayName && type == FileType.PDF) {
return displayName
}
return title?.takeIf { it.isNotBlank() } ?: displayName
} }
fun RecentFileItem.cardAuthor(): String { fun RecentFileItem.cardAuthor(): String {

View file

@ -5,31 +5,34 @@ import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity import com.aryan.reader.data.ShelfEntity
import com.aryan.reader.data.TagEntity 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.BookItem as SharedBookItem
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
import com.aryan.reader.shared.CustomAppTheme as SharedCustomAppTheme
import com.aryan.reader.shared.EpubAnnotationSerializer import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.FileType as SharedFileType import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters 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.SharedReaderScreenState import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.Shelf as SharedShelf import com.aryan.reader.shared.Shelf as SharedShelf
import com.aryan.reader.shared.ShelfRecord import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.shared.ShelfType as SharedShelfType
import com.aryan.reader.shared.SortOrder as SharedSortOrder
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
import com.aryan.reader.shared.Tag as SharedTag import com.aryan.reader.shared.Tag as SharedTag
fun FileType.toSharedFileType(): SharedFileType = this
fun SharedFileType.toAndroidFileType(): FileType = this
fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters = this
fun SharedLibraryFilters.toAndroidLibraryFilters(): LibraryFilters = this
fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder = this
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this
fun RecentFileItem.toSharedBookItem(): SharedBookItem { fun RecentFileItem.toSharedBookItem(): SharedBookItem {
return SharedBookItem( return SharedBookItem(
id = bookId, id = bookId,
path = uriString, path = uriString,
type = type.toSharedFileType(), type = type,
displayName = customName ?: displayName, displayName = customName ?: displayName,
timestamp = timestamp, timestamp = timestamp,
coverImagePath = coverImagePath, coverImagePath = coverImagePath,
@ -67,7 +70,7 @@ fun SharedBookItem.toRecentFileItem(
return androidBooksById[id]?.copy(tags = resolvedTags) return androidBooksById[id]?.copy(tags = resolvedTags)
?.copy( ?.copy(
uriString = path, uriString = path,
type = type.toAndroidFileType(), type = type,
displayName = androidBooksById[id]?.displayName ?: displayName, displayName = androidBooksById[id]?.displayName ?: displayName,
timestamp = timestamp, timestamp = timestamp,
coverImagePath = coverImagePath, coverImagePath = coverImagePath,
@ -92,7 +95,7 @@ fun SharedBookItem.toRecentFileItem(
?: RecentFileItem( ?: RecentFileItem(
bookId = id, bookId = id,
uriString = path, uriString = path,
type = type.toAndroidFileType(), type = type,
displayName = displayName, displayName = displayName,
timestamp = timestamp, timestamp = timestamp,
coverImagePath = coverImagePath, coverImagePath = coverImagePath,
@ -169,11 +172,11 @@ fun ReaderScreenState.toSharedReaderScreenState(
return SharedReaderScreenState( return SharedReaderScreenState(
selectedBookId = selectedBookId, selectedBookId = selectedBookId,
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(), selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
selectedFileType = selectedFileType?.toSharedFileType(), selectedFileType = selectedFileType,
isLoading = isLoading, isLoading = isLoading,
errorMessage = errorMessage, errorMessage = errorMessage,
renderMode = renderMode.toSharedRenderMode(), renderMode = renderMode,
sortOrder = sortOrder.toSharedSortOrder(), sortOrder = sortOrder,
viewingShelfId = viewingShelfId, viewingShelfId = viewingShelfId,
isAddingBooksToShelf = isAddingBooksToShelf, isAddingBooksToShelf = isAddingBooksToShelf,
showCreateShelfDialog = showCreateShelfDialog, showCreateShelfDialog = showCreateShelfDialog,
@ -181,7 +184,7 @@ fun ReaderScreenState.toSharedReaderScreenState(
libraryScreenStartPage = libraryScreenStartPage, libraryScreenStartPage = libraryScreenStartPage,
showRenameShelfDialogFor = showRenameShelfDialogFor, showRenameShelfDialogFor = showRenameShelfDialogFor,
showDeleteShelfDialogFor = showDeleteShelfDialogFor, showDeleteShelfDialogFor = showDeleteShelfDialogFor,
addBooksSource = addBooksSource.toSharedAddBooksSource(), addBooksSource = addBooksSource,
booksSelectedForAdding = booksSelectedForAdding, booksSelectedForAdding = booksSelectedForAdding,
selectedBookIds = contextualActionItems.mapTo(mutableSetOf()) { it.bookId }, selectedBookIds = contextualActionItems.mapTo(mutableSetOf()) { it.bookId },
selectedShelfIds = contextualActionShelfIds, selectedShelfIds = contextualActionShelfIds,
@ -189,10 +192,10 @@ fun ReaderScreenState.toSharedReaderScreenState(
credits = credits, credits = credits,
isSyncEnabled = isSyncEnabled, isSyncEnabled = isSyncEnabled,
isFolderSyncEnabled = isFolderSyncEnabled, isFolderSyncEnabled = isFolderSyncEnabled,
bannerMessage = bannerMessage?.toSharedBannerMessage(), bannerMessage = bannerMessage,
downloadingBookIds = downloadingBookIds, downloadingBookIds = downloadingBookIds,
uploadingBookIds = uploadingBookIds, uploadingBookIds = uploadingBookIds,
syncedFolders = syncedFolders.map { it.toSharedSyncedFolder() }, syncedFolders = syncedFolders,
lastFolderScanTime = lastFolderScanTime, lastFolderScanTime = lastFolderScanTime,
hasUnreadFeedback = hasUnreadFeedback, hasUnreadFeedback = hasUnreadFeedback,
searchQuery = searchQuery, searchQuery = searchQuery,
@ -204,7 +207,7 @@ fun ReaderScreenState.toSharedReaderScreenState(
rawLibraryBooks = rawBooks.map { it.toSharedBookItem() }, rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
pinnedHomeBookIds = pinnedHomeBookIds, pinnedHomeBookIds = pinnedHomeBookIds,
pinnedLibraryBookIds = pinnedLibraryBookIds, pinnedLibraryBookIds = pinnedLibraryBookIds,
libraryFilters = libraryFilters.toSharedLibraryFilters(), libraryFilters = libraryFilters,
recentFilesLimit = recentFilesLimit, recentFilesLimit = recentFilesLimit,
isTabsEnabled = isTabsEnabled, isTabsEnabled = isTabsEnabled,
openTabIds = openTabIds, openTabIds = openTabIds,
@ -213,12 +216,14 @@ fun ReaderScreenState.toSharedReaderScreenState(
showExternalFileSavePromptFor = showExternalFileSavePromptFor, showExternalFileSavePromptFor = showExternalFileSavePromptFor,
externalFileBehavior = externalFileBehavior, externalFileBehavior = externalFileBehavior,
useStrictFileFilter = useStrictFileFilter, useStrictFileFilter = useStrictFileFilter,
appThemeMode = appThemeMode.toSharedAppThemeMode(), usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName,
appContrastOption = appContrastOption.toSharedAppContrastOption(), appThemeMode = appThemeMode,
appContrastOption = appContrastOption,
appTextDimFactorLight = appTextDimFactorLight, appTextDimFactorLight = appTextDimFactorLight,
appTextDimFactorDark = appTextDimFactorDark, appTextDimFactorDark = appTextDimFactorDark,
appSeedColor = appSeedColor, appSeedColor = appSeedColor,
customAppThemes = customAppThemes.map { it.toSharedCustomAppTheme() }, appFontPreference = appFontPreference,
customAppThemes = customAppThemes,
allTags = dbTags.map { it.toSharedTag() }, allTags = dbTags.map { it.toSharedTag() },
showTagSelectionDialogFor = showTagSelectionDialogFor showTagSelectionDialogFor = showTagSelectionDialogFor
) )
@ -271,7 +276,7 @@ fun SharedShelf.toAndroidShelf(
return Shelf( return Shelf(
id = id, id = id,
name = name, name = name,
type = type.toAndroidShelfType(), type = type,
books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) }, books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) }, directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
parentShelfId = parentShelfId, parentShelfId = parentShelfId,
@ -280,91 +285,3 @@ fun SharedShelf.toAndroidShelf(
sortKey = sortKey sortKey = sortKey
) )
} }
fun FileType.toSharedFileType(): SharedFileType {
return this
}
fun SharedFileType.toAndroidFileType(): FileType {
return this
}
fun RenderMode.toSharedRenderMode(): SharedRenderMode {
return this
}
fun SharedRenderMode.toAndroidRenderMode(): RenderMode {
return this
}
fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
return this
}
fun SharedAddBooksSource.toAndroidAddBooksSource(): AddBooksSource {
return this
}
fun SortOrder.toSharedSortOrder(): SharedSortOrder {
return this
}
fun SharedSortOrder.toAndroidSortOrder(): SortOrder {
return this
}
fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
return this
}
fun SharedReadStatusFilter.toAndroidReadStatusFilter(): ReadStatusFilter {
return this
}
fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
return this
}
fun SharedLibraryFilters.toAndroidLibraryFilters(): LibraryFilters {
return this
}
fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder {
return this
}
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder {
return this
}
private fun SharedShelfType.toAndroidShelfType(): ShelfType {
return ShelfType.valueOf(name)
}
fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage {
return this
}
fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode {
return this
}
fun SharedAppThemeMode.toAndroidAppThemeMode(): AppThemeMode {
return this
}
fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption {
return this
}
fun SharedAppContrastOption.toAndroidAppContrastOption(): AppContrastOption {
return this
}
fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme {
return this
}
fun SharedCustomAppTheme.toAndroidCustomAppTheme(): CustomAppTheme {
return this
}

View file

@ -2,7 +2,7 @@
package com.aryan.reader.data package com.aryan.reader.data
import com.aryan.reader.FileType import com.aryan.reader.FileType
import org.json.JSONObject import com.aryan.reader.shared.SharedFolderBookMetadata
data class FolderBookMetadata( data class FolderBookMetadata(
val bookId: String, val bookId: String,
@ -31,67 +31,76 @@ data class FolderBookMetadata(
val originalDescription: String? = null val originalDescription: String? = null
) { ) {
fun toJsonString(): String { fun toJsonString(): String {
val json = JSONObject() return toSharedFolderBookMetadata().toJsonString()
json.put("bookId", bookId)
json.put("displayName", displayName)
json.put("type", type)
json.put("lastChapterIndex", lastChapterIndex ?: -1)
json.put("lastPage", lastPage ?: -1)
json.put("lastPositionCfi", lastPositionCfi)
json.put("progressPercentage", progressPercentage.toDouble())
json.put("isRecent", isRecent)
json.put("lastModifiedTimestamp", lastModifiedTimestamp)
json.put("bookmarksJson", bookmarksJson)
json.put("locatorBlockIndex", locatorBlockIndex ?: -1)
json.put("locatorCharOffset", locatorCharOffset ?: -1)
json.put("customName", customName)
json.put("highlightsJson", highlightsJson)
return json.toString()
} }
companion object { companion object {
fun fromJsonString(jsonString: String): FolderBookMetadata { fun fromJsonString(jsonString: String): FolderBookMetadata {
val json = JSONObject(jsonString) return SharedFolderBookMetadata.fromJsonString(jsonString)
?.toFolderBookMetadata()
fun JSONObject.optStringNull(key: String): String? { ?: error("Invalid folder metadata JSON")
return if (has(key) && !isNull(key)) getString(key) else null
}
fun JSONObject.optIntNull(key: String): Int? {
val value = optInt(key, -1)
return if (value == -1) null else value
}
return FolderBookMetadata(
bookId = json.getString("bookId"),
title = null,
author = null,
displayName = json.optString("displayName", "Unknown"),
type = json.optString("type", "PDF"),
lastChapterIndex = json.optIntNull("lastChapterIndex"),
lastPage = json.optIntNull("lastPage"),
lastPositionCfi = json.optStringNull("lastPositionCfi"),
progressPercentage = json.optDouble("progressPercentage", 0.0).toFloat(),
isRecent = json.optBoolean("isRecent", true),
lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L),
bookmarksJson = json.optStringNull("bookmarksJson"),
locatorBlockIndex = json.optIntNull("locatorBlockIndex"),
locatorCharOffset = json.optIntNull("locatorCharOffset"),
customName = json.optStringNull("customName"),
highlightsJson = json.optStringNull("highlightsJson"),
seriesName = null,
seriesIndex = null,
description = null,
originalTitle = null,
originalAuthor = null,
originalSeriesName = null,
originalSeriesIndex = null,
originalDescription = null
)
} }
} }
} }
fun FolderBookMetadata.toSharedFolderBookMetadata(): SharedFolderBookMetadata {
return SharedFolderBookMetadata(
bookId = bookId,
title = title,
author = author,
displayName = displayName,
type = type,
lastChapterIndex = lastChapterIndex,
lastPage = lastPage,
lastPositionCfi = lastPositionCfi,
progressPercentage = progressPercentage,
isRecent = isRecent,
lastModifiedTimestamp = lastModifiedTimestamp,
bookmarksJson = bookmarksJson,
locatorBlockIndex = locatorBlockIndex,
locatorCharOffset = locatorCharOffset,
customName = customName,
highlightsJson = highlightsJson,
seriesName = seriesName,
seriesIndex = seriesIndex,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription
)
}
fun SharedFolderBookMetadata.toFolderBookMetadata(): FolderBookMetadata {
return FolderBookMetadata(
bookId = bookId,
title = title,
author = author,
displayName = displayName,
type = type,
lastChapterIndex = lastChapterIndex,
lastPage = lastPage,
lastPositionCfi = lastPositionCfi,
progressPercentage = progressPercentage,
isRecent = isRecent,
lastModifiedTimestamp = lastModifiedTimestamp,
bookmarksJson = bookmarksJson,
locatorBlockIndex = locatorBlockIndex,
locatorCharOffset = locatorCharOffset,
customName = customName,
highlightsJson = highlightsJson,
seriesName = seriesName,
seriesIndex = seriesIndex,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription
)
}
fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, sourceFolderUri: String?): RecentFileItem { fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, sourceFolderUri: String?): RecentFileItem {
return RecentFileItem( return RecentFileItem(
bookId = this.bookId, bookId = this.bookId,

View file

@ -53,6 +53,10 @@ data class EpubBook(
val description: String? = null, val description: String? = null,
) )
fun epubContentFilePath(path: String): String = path.substringBefore('#').substringBefore('?')
fun EpubChapter.contentFilePath(): String = epubContentFilePath(htmlFilePath)
fun EpubBook.hasReadableExtractedContent(): Boolean { fun EpubBook.hasReadableExtractedContent(): Boolean {
if (extractionBasePath.isBlank()) return false if (extractionBasePath.isBlank()) return false
val extractionDir = File(extractionBasePath) val extractionDir = File(extractionBasePath)
@ -60,6 +64,6 @@ fun EpubBook.hasReadableExtractedContent(): Boolean {
if (chapters.isEmpty()) return extractionDir.list()?.isNotEmpty() == true if (chapters.isEmpty()) return extractionDir.list()?.isNotEmpty() == true
return chapters.all { chapter -> return chapters.all { chapter ->
File(extractionDir, chapter.htmlFilePath).isFile File(extractionDir, chapter.contentFilePath()).isFile
} }
} }

View file

@ -52,6 +52,11 @@ class SingleFileImporter(private val context: Context) {
companion object { companion object {
private const val MAX_DOCX_ARCHIVE_BYTES = 64L * 1024L * 1024L private const val MAX_DOCX_ARCHIVE_BYTES = 64L * 1024L * 1024L
private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L
private const val MAX_HTML_CHAPTER_CHARS = 1_000_000
private const val MAX_HTML_BUFFERED_LINE_CHARS = 128_000
private const val MAX_HTML_HEAD_SCAN_CHARS = 256_000
private const val MAX_HTML_INLINE_CSS_CHARS = 256_000
private const val PAGE_BREAK_MARKER = "<page-break></page-break>"
} }
private val htmlSafelist = Safelist.relaxed() private val htmlSafelist = Safelist.relaxed()
@ -519,57 +524,140 @@ class SingleFileImporter(private val context: Context) {
var pageNum = 1 var pageNum = 1
val headBuilder = java.lang.StringBuilder() val headBuilder = java.lang.StringBuilder()
val currentChapterBuilder = java.lang.StringBuilder() val currentChapterBuilder = java.lang.StringBuilder()
var titleFound = false
var authorFound = false
var line: String? fun appendCss(style: String) {
while (reader.readLine().also { line = it } != null) { if (style.isBlank() || cssBuilder.length >= MAX_HTML_INLINE_CSS_CHARS) return
val trimmed = line!!.trim() val remaining = MAX_HTML_INLINE_CSS_CHARS - cssBuilder.length
cssBuilder.append(style, 0, minOf(style.length, remaining)).append('\n')
}
fun appendHeadSample(sample: String) {
if (headBuilder.length >= MAX_HTML_HEAD_SCAN_CHARS) return
val remaining = MAX_HTML_HEAD_SCAN_CHARS - headBuilder.length
headBuilder.append(sample, 0, minOf(sample.length, remaining)).append('\n')
}
fun flushChapter() {
if (currentChapterBuilder.isBlank()) {
currentChapterBuilder.clear()
return
}
chapters.add(
writeHtmlChapter(
extractionDir,
bookId,
pageNum++,
title,
cssBuilder.toString(),
currentChapterBuilder.toString()
)
)
currentChapterBuilder.clear()
}
fun appendBodySegment(segment: String, startIndex: Int = 0, endIndex: Int = segment.length, addNewline: Boolean = true) {
var start = startIndex
while (start < endIndex) {
val remainingCapacity = (MAX_HTML_CHAPTER_CHARS - currentChapterBuilder.length).coerceAtLeast(1)
val requestedEnd = minOf(endIndex, start + remainingCapacity)
val chunkEnd = findHtmlChunkEnd(segment, start, requestedEnd, endIndex)
currentChapterBuilder.append(segment, start, chunkEnd)
start = chunkEnd
if (currentChapterBuilder.length >= MAX_HTML_CHAPTER_CHARS) {
flushChapter()
}
}
if (addNewline) {
currentChapterBuilder.append('\n')
if (currentChapterBuilder.length >= MAX_HTML_CHAPTER_CHARS) {
flushChapter()
}
}
}
fun appendBodyLine(line: String) {
var start = 0
var markerIndex = line.indexOf(PAGE_BREAK_MARKER, start, ignoreCase = true)
while (markerIndex >= 0) {
appendBodySegment(line, start, markerIndex)
flushChapter()
start = markerIndex + PAGE_BREAK_MARKER.length
markerIndex = line.indexOf(PAGE_BREAK_MARKER, start, ignoreCase = true)
}
appendBodySegment(line, start, line.length)
}
reader.forEachBoundedLine(MAX_HTML_BUFFERED_LINE_CHARS) { line ->
val trimmed = line.trim()
if (inScript) { if (inScript) {
if (trimmed.contains("</script", ignoreCase = true)) { if (trimmed.contains("</script", ignoreCase = true)) {
inScript = false inScript = false
} }
continue return@forEachBoundedLine
} }
if (trimmed.startsWith("<script", ignoreCase = true)) { if (trimmed.startsWith("<script", ignoreCase = true)) {
if (!trimmed.contains("</script", ignoreCase = true)) { if (!trimmed.contains("</script", ignoreCase = true)) {
inScript = true inScript = true
} }
continue return@forEachBoundedLine
} }
if (!inBody) { if (!inBody) {
headBuilder.append(line).append('\n') appendHeadSample(line)
extractHtmlTitle(headBuilder.toString())?.let { title = it } if (!titleFound) {
extractHtmlAuthor(headBuilder.toString())?.let { author = it } (extractHtmlTitle(line) ?: extractHtmlTitle(headBuilder.toString()))?.let {
title = it
titleFound = true
}
}
if (!authorFound) {
(extractHtmlAuthor(line) ?: extractHtmlAuthor(headBuilder.toString()))?.let {
author = it
authorFound = true
}
}
if (trimmed.startsWith("<style", ignoreCase = true)) { if (trimmed.startsWith("<style", ignoreCase = true)) {
inStyle = true inStyle = true
val styleContent = line.substringAfter(">").substringBefore("</style>") val styleContent = line.substringAfter(">").substringBefore("</style>")
if (styleContent.isNotBlank()) cssBuilder.append(styleContent).append("\n") appendCss(styleContent)
if (trimmed.contains("</style>")) { if (trimmed.contains("</style>")) {
inStyle = false inStyle = false
} }
continue return@forEachBoundedLine
} }
if (inStyle) { if (inStyle) {
if (trimmed.contains("</style>")) { if (trimmed.contains("</style>")) {
cssBuilder.append(line.substringBefore("</style>")).append("\n") appendCss(line.substringBefore("</style>"))
inStyle = false inStyle = false
} else { } else {
cssBuilder.append(line).append("\n") appendCss(line)
} }
continue return@forEachBoundedLine
} }
if (trimmed.equals("<body>", ignoreCase = true)) { if (trimmed.equals("<body>", ignoreCase = true)) {
inBody = true inBody = true
continue return@forEachBoundedLine
} }
if (trimmed.startsWith("<body ", ignoreCase = true)) { if (trimmed.startsWith("<body ", ignoreCase = true)) {
inBody = true inBody = true
val afterBody = line.substringAfter(">", "") val afterBody = line.substringAfter(">", "")
if (afterBody.isNotBlank()) currentChapterBuilder.append(afterBody).append("\n") if (afterBody.isNotBlank()) appendBodyLine(afterBody)
continue return@forEachBoundedLine
}
val embeddedBodyIndex = line.indexOf("<body", ignoreCase = true)
if (embeddedBodyIndex >= 0) {
val bodyContentIndex = line.indexOf('>', startIndex = embeddedBodyIndex)
if (bodyContentIndex >= 0) {
inBody = true
val afterBody = line.substring(bodyContentIndex + 1)
if (afterBody.isNotBlank()) appendBodyLine(afterBody)
return@forEachBoundedLine
}
} }
if (trimmed.startsWith("<p") || trimmed.startsWith("<div") || if (trimmed.startsWith("<p") || trimmed.startsWith("<div") ||
@ -577,41 +665,18 @@ class SingleFileImporter(private val context: Context) {
trimmed.contains("<page-break>") || trimmed.contains("<page-break>") ||
(trimmed.isNotBlank() && !trimmed.startsWith("<") && !trimmed.startsWith("<!"))) { (trimmed.isNotBlank() && !trimmed.startsWith("<") && !trimmed.startsWith("<!"))) {
inBody = true inBody = true
currentChapterBuilder.append(line).append("\n") appendBodyLine(line)
} }
} else { } else {
if (trimmed.equals("</body>", ignoreCase = true) || trimmed.equals("</html>", ignoreCase = true)) { if (trimmed.equals("</body>", ignoreCase = true) || trimmed.equals("</html>", ignoreCase = true)) {
continue return@forEachBoundedLine
} }
if (line.contains("<page-break></page-break>")) { appendBodyLine(line)
val parts = line.split("<page-break></page-break>")
for (i in parts.indices) {
currentChapterBuilder.append(parts[i]).append("\n")
if (i < parts.size - 1) {
val chapterHtml = currentChapterBuilder.toString()
if (chapterHtml.isNotBlank()) {
chapters.add(writeHtmlChapter(extractionDir, bookId, pageNum++, title, cssBuilder.toString(), chapterHtml))
}
currentChapterBuilder.clear() // Clean memory allocation
}
}
continue
}
currentChapterBuilder.append(line).append("\n")
if (currentChapterBuilder.length > 2_000_000) {
chapters.add(writeHtmlChapter(extractionDir, bookId, pageNum++, title, cssBuilder.toString(), currentChapterBuilder.toString()))
currentChapterBuilder.clear()
}
} }
} }
val finalChapterHtml = currentChapterBuilder.toString() flushChapter()
if (finalChapterHtml.isNotBlank()) {
chapters.add(writeHtmlChapter(extractionDir, bookId, pageNum++, title, cssBuilder.toString(), finalChapterHtml))
}
} }
if (chapters.isEmpty()) { if (chapters.isEmpty()) {
@ -761,7 +826,7 @@ class SingleFileImporter(private val context: Context) {
FileOutputStream(tempFile).bufferedWriter().use { writer -> FileOutputStream(tempFile).bufferedWriter().use { writer ->
val title = originalBookNameHint.substringBeforeLast(".") val title = originalBookNameHint.substringBeforeLast(".")
writer.write("<!DOCTYPE html>\n<html>\n<head>\n<title>$title</title>\n</head>\n<body>\n") writer.write("<!DOCTYPE html>\n<html>\n<head>\n<title>$title</title>\n</head>\n<body>\n")
writer.write(htmlContent) writeHtmlBodyContentChunked(writer, htmlContent)
writer.write("\n</body>\n</html>") writer.write("\n</body>\n</html>")
} }
@ -816,12 +881,19 @@ class SingleFileImporter(private val context: Context) {
val fileName = "page_$pageNum.html" val fileName = "page_$pageNum.html"
val file = File(extractionDir, fileName) val file = File(extractionDir, fileName)
val sanitizedBodyContent = sanitizeHtmlFragment(bodyContent) val sanitizedBodyContent = sanitizeHtmlFragment(bodyContent)
val escapedTitle = title.replace("\"", "&quot;")
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.bufferedWriter().use { writer ->
writer.write("<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n<title>")
writer.write(escapedTitle)
writer.write("</title>\n<style>")
writer.write(cssStyle)
writer.write("</style>\n</head>\n<body>\n")
writer.write(sanitizedBodyContent.trim())
writer.write("\n</body>\n</html>")
}
file.writeText(fullHtml) val plainText = Jsoup.parse(sanitizedBodyContent).text()
val plainText = Jsoup.parse(fullHtml).text()
return EpubChapter( return EpubChapter(
chapterId = "${bookId}_$pageNum", chapterId = "${bookId}_$pageNum",
@ -834,4 +906,70 @@ class SingleFileImporter(private val context: Context) {
isInToc = true isInToc = true
) )
} }
private fun java.io.BufferedReader.forEachBoundedLine(
maxLineChars: Int,
onLine: (String) -> Unit
) {
val buffer = CharArray(16_384)
val currentLine = StringBuilder()
while (true) {
val read = read(buffer)
if (read == -1) break
var start = 0
var index = 0
while (index < read) {
val char = buffer[index]
val reachesLimit = currentLine.length + (index - start + 1) >= maxLineChars
if (char == '\n' || reachesLimit) {
val count = index - start + if (char == '\n') 0 else 1
if (count > 0) {
currentLine.append(buffer, start, count)
}
onLine(currentLine.toString().trimEnd('\r'))
currentLine.clear()
start = index + 1
}
index++
}
if (start < read) {
currentLine.append(buffer, start, read - start)
}
}
if (currentLine.isNotEmpty()) {
onLine(currentLine.toString().trimEnd('\r'))
}
}
private fun findHtmlChunkEnd(
source: String,
start: Int,
requestedEnd: Int,
absoluteEnd: Int
): Int {
if (requestedEnd >= absoluteEnd) return absoluteEnd
val tagStart = source.lastIndexOf('<', requestedEnd - 1)
val tagEnd = source.lastIndexOf('>', requestedEnd - 1)
if (tagStart > start && tagStart > tagEnd && requestedEnd - tagStart <= 4096) {
return tagStart
}
return requestedEnd
}
private fun writeHtmlBodyContentChunked(writer: java.io.Writer, htmlContent: String) {
var charsSinceBreak = 0
for (char in htmlContent) {
writer.write(char.code)
charsSinceBreak++
if (char == '>' || charsSinceBreak >= MAX_HTML_BUFFERED_LINE_CHARS) {
writer.write('\n'.code)
charsSinceBreak = 0
}
}
}
} }

View file

@ -468,6 +468,9 @@ fun ChapterWebView(
ttsScope: CoroutineScope, ttsScope: CoroutineScope,
tocFragments: List<String>, tocFragments: List<String>,
initialFragmentId: String? = null, initialFragmentId: String? = null,
initialImageSource: String? = null,
initialImageOriginalSource: String? = null,
initialImageOrdinal: Int = 0,
onTtsTextReady: suspend (String) -> Unit, onTtsTextReady: suspend (String) -> Unit,
isProUser: Boolean, isProUser: Boolean,
isOss: Boolean = false, isOss: Boolean = false,
@ -1011,6 +1014,14 @@ fun ChapterWebView(
} }
onChapterInitiallyScrolled() onChapterInitiallyScrolled()
scrollActionTaken = true scrollActionTaken = true
} else if (!initialImageSource.isNullOrBlank()) {
val imageJsCommand =
"javascript:window.scrollToReaderImageSource('${escapeJsString(initialImageSource)}', $initialImageOrdinal, '${escapeJsString(initialImageOriginalSource.orEmpty())}');"
Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to image source: $initialImageSource")
view?.evaluateJavascript(imageJsCommand) {
onChapterInitiallyScrolled()
scrollActionTaken = true
}
} else if (initialScrollTarget != null) { } else if (initialScrollTarget != null) {
val scrollJsCommand = when (initialScrollTarget) { val scrollJsCommand = when (initialScrollTarget) {
ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();" ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();"

View file

@ -41,6 +41,7 @@ import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.callByokTextAi import com.aryan.reader.callByokTextAi
import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.fetchRecap import com.aryan.reader.fetchRecap
import com.aryan.reader.paginatedreader.IPaginator import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.summarizationUrl import com.aryan.reader.summarizationUrl
@ -209,8 +210,7 @@ suspend fun executeRecapLogic(
val textToSummarize = paginator?.getPlainTextForChapter(i) ?: withContext(Dispatchers.IO) { val textToSummarize = paginator?.getPlainTextForChapter(i) ?: withContext(Dispatchers.IO) {
try { try {
val chapter = chapters[i] val chapter = chapters[i]
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}" val doc = Jsoup.parse(File(epubBook.extractionBasePath, chapter.contentFilePath()), "UTF-8")
val doc = Jsoup.parse(File(fullPath), "UTF-8")
doc.body().text() doc.body().text()
} catch (_: Exception) { "" } } catch (_: Exception) { "" }
} }

View file

@ -23,10 +23,13 @@ import android.content.Context
import com.aryan.reader.R import com.aryan.reader.R
import timber.log.Timber import timber.log.Timber
import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.paginatedreader.LocatorConverter import com.aryan.reader.paginatedreader.LocatorConverter
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import java.io.File import java.io.File
data class ChapterLoadingResult( data class ChapterLoadingResult(
@ -34,9 +37,44 @@ data class ChapterLoadingResult(
val chunks: List<String>, val chunks: List<String>,
val startChunkIndex: Int, val startChunkIndex: Int,
val isSuccess: Boolean, val isSuccess: Boolean,
val errorMessage: String? = null val errorMessage: String? = null,
val chunkElementStartIndices: List<Int> = emptyList(),
val chunkElementCounts: List<Int> = emptyList()
) )
internal data class ReaderHtmlChunk(
val html: String,
val elementStartIndex: Int,
val elementCount: Int
)
internal fun splitBodyNodesIntoReaderChunks(
bodyNodes: List<Node>,
chunkSize: Int = 20
): List<ReaderHtmlChunk> {
var elementStartIndex = 0
return bodyNodes.chunked(chunkSize).map { nodes ->
val elementCount = nodes.count { it is Element }
ReaderHtmlChunk(
html = nodes.joinToString(separator = "\n") { it.outerHtml() },
elementStartIndex = elementStartIndex,
elementCount = elementCount
).also {
elementStartIndex += elementCount
}
}
}
internal fun readerChunkContainerAttributes(
index: Int,
chunkElementStartIndices: List<Int>,
chunkElementCounts: List<Int>
): String {
val startIndex = chunkElementStartIndices.getOrElse(index) { index * 20 }
val elementCount = chunkElementCounts.getOrElse(index) { 20 }
return "data-chunk-index='$index' data-element-start-index='$startIndex' data-element-count='$elementCount'"
}
/** /**
* loads the chapter HTML, splits it into chunks, and calculates * loads the chapter HTML, splits it into chunks, and calculates
* the initial chunk to display based on navigation state (CFI, overrides, etc.). * the initial chunk to display based on navigation state (CFI, overrides, etc.).
@ -56,24 +94,36 @@ suspend fun loadChapterContent(
) )
try { try {
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}" val htmlFile = File(epubBook.extractionBasePath, chapter.contentFilePath())
val htmlFile = File(fullPath)
val (headContent, chunks) = if (htmlFile.exists()) { val (headContent, chunks, chunkElementStartIndices, chunkElementCounts) = if (htmlFile.exists()) {
val doc = Jsoup.parse(htmlFile, "UTF-8") val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html() val head = doc.head().html()
doc.select("script").remove() doc.select("script").remove()
val bodyNodes = doc.body().childNodes().toList() val bodyNodes = doc.body().childNodes().toList()
val chunkedList = bodyNodes.chunked(20).map { chunkOfNodes -> val htmlChunks = splitBodyNodesIntoReaderChunks(bodyNodes)
chunkOfNodes.joinToString(separator = "\n") { it.outerHtml() } if (htmlChunks.isEmpty()) {
} ChapterHtmlPayload(
if (chunkedList.isEmpty()) { head = head,
head to listOf("<body><p>${context.getString(R.string.chapter_empty)}</p></body>") chunks = listOf("<body><p>${context.getString(R.string.chapter_empty)}</p></body>"),
chunkElementStartIndices = listOf(0),
chunkElementCounts = listOf(1)
)
} else { } else {
head to chunkedList ChapterHtmlPayload(
head = head,
chunks = htmlChunks.map { it.html },
chunkElementStartIndices = htmlChunks.map { it.elementStartIndex },
chunkElementCounts = htmlChunks.map { it.elementCount }
)
} }
} else { } else {
"" to listOf("<h1>${context.getString(R.string.chapter_not_found)}</h1>") ChapterHtmlPayload(
head = "",
chunks = listOf("<h1>${context.getString(R.string.chapter_not_found)}</h1>"),
chunkElementStartIndices = listOf(0),
chunkElementCounts = listOf(1)
)
} }
var targetChunk = 0 var targetChunk = 0
@ -101,7 +151,9 @@ suspend fun loadChapterContent(
head = headContent, head = headContent,
chunks = chunks, chunks = chunks,
startChunkIndex = targetChunk, startChunkIndex = targetChunk,
isSuccess = true isSuccess = true,
chunkElementStartIndices = chunkElementStartIndices,
chunkElementCounts = chunkElementCounts
) )
} catch (e: Exception) { } catch (e: Exception) {
@ -115,3 +167,10 @@ suspend fun loadChapterContent(
) )
} }
} }
private data class ChapterHtmlPayload(
val head: String,
val chunks: List<String>,
val chunkElementStartIndices: List<Int>,
val chunkElementCounts: List<Int>
)

View file

@ -19,9 +19,11 @@
*/ */
package com.aryan.reader.epubreader package com.aryan.reader.epubreader
import android.graphics.BitmapFactory
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
@ -32,6 +34,7 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@ -55,6 +58,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@ -66,12 +70,13 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -83,7 +88,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -94,8 +101,10 @@ import com.aryan.reader.R
import com.aryan.reader.RenderMode import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.epub.EpubTocEntry import com.aryan.reader.epub.EpubTocEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
@Composable @Composable
@ -207,6 +216,7 @@ fun EpubReaderDrawerSheet(
chapters: List<EpubChapter>, chapters: List<EpubChapter>,
tableOfContents: List<EpubTocEntry>, tableOfContents: List<EpubTocEntry>,
activeFragmentId: String?, activeFragmentId: String?,
readerImages: List<EpubReaderImageReference>,
bookmarks: Set<Bookmark>, bookmarks: Set<Bookmark>,
userHighlights: List<UserHighlight>, userHighlights: List<UserHighlight>,
currentChapterIndex: Int, currentChapterIndex: Int,
@ -214,6 +224,8 @@ fun EpubReaderDrawerSheet(
renderMode: RenderMode, renderMode: RenderMode,
onNavigateToChapter: (Int) -> Unit, onNavigateToChapter: (Int) -> Unit,
onNavigateToTocEntry: (EpubTocEntry) -> Unit, onNavigateToTocEntry: (EpubTocEntry) -> Unit,
onNavigateToImage: (EpubReaderImageReference) -> Unit,
onDownloadImage: (EpubReaderImageReference) -> Unit,
onNavigateToBookmark: (Bookmark) -> Unit, onNavigateToBookmark: (Bookmark) -> Unit,
onNavigateToHighlight: (UserHighlight) -> Unit, onNavigateToHighlight: (UserHighlight) -> Unit,
onDeleteBookmark: (Bookmark) -> Unit, onDeleteBookmark: (Bookmark) -> Unit,
@ -227,11 +239,15 @@ fun EpubReaderDrawerSheet(
ModalDrawerSheet( ModalDrawerSheet(
modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars) modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)
) { ) {
val drawerPagerState = rememberPagerState(pageCount = { 3 }) val drawerPagerState = rememberPagerState(pageCount = { 4 })
val drawerScope = rememberCoroutineScope() val drawerScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = drawerPagerState.currentPage) { ScrollableTabRow(
selectedTabIndex = drawerPagerState.currentPage,
edgePadding = 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Tab( Tab(
selected = drawerPagerState.currentPage == 0, selected = drawerPagerState.currentPage == 0,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(0) } }, onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(0) } },
@ -247,6 +263,11 @@ fun EpubReaderDrawerSheet(
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(2) } }, onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(2) } },
text = { Text(stringResource(R.string.tab_annotations)) } text = { Text(stringResource(R.string.tab_annotations)) }
) )
Tab(
selected = drawerPagerState.currentPage == 3,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(3) } },
text = { Text(stringResource(R.string.tab_images)) }
)
} }
HorizontalPager( HorizontalPager(
@ -282,6 +303,11 @@ fun EpubReaderDrawerSheet(
onOpenPaletteManager = onOpenPaletteManager, onOpenPaletteManager = onOpenPaletteManager,
onHighlightColorChange = onHighlightColorChange onHighlightColorChange = onHighlightColorChange
) )
3 -> ImagesList(
readerImages = readerImages,
onNavigateToImage = onNavigateToImage,
onDownloadImage = onDownloadImage
)
} }
} }
} }
@ -710,6 +736,145 @@ private fun BookmarksList(
} }
} }
@Composable
private fun ImagesList(
readerImages: List<EpubReaderImageReference>,
onNavigateToImage: (EpubReaderImageReference) -> Unit,
onDownloadImage: (EpubReaderImageReference) -> Unit
) {
if (readerImages.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.no_images_found),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
return
}
val listState = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(end = 4.dp),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(
items = readerImages,
key = { it.id }
) { image ->
ListItem(
leadingContent = {
EpubReaderImageThumbnail(
image = image,
modifier = Modifier.size(width = 72.dp, height = 56.dp)
)
},
headlineContent = {
Text(
text = image.displayTitle,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
supportingContent = {
Column {
Text(
text = image.chapterTitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val metadata = listOfNotNull(image.dimensionLabel, image.sourceName()).joinToString(" - ")
if (metadata.isNotBlank()) {
Text(
text = metadata,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
},
trailingContent = {
IconButton(onClick = { onDownloadImage(image) }) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = stringResource(R.string.content_desc_download_image)
)
}
},
modifier = Modifier.clickable { onNavigateToImage(image) }
)
HorizontalDivider()
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
@Composable
private fun EpubReaderImageThumbnail(
image: EpubReaderImageReference,
modifier: Modifier = Modifier
) {
var bitmap by remember(image.sourcePath) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(image.sourcePath) {
bitmap = withContext(Dispatchers.IO) {
if (image.sourcePath.startsWith("data:", ignoreCase = true)) {
val bytes = image.readDownloadBytes()
bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
} else {
BitmapFactory.decodeFile(image.sourcePath)
}
}
}
Surface(
modifier = modifier,
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
) {
val currentBitmap = bitmap
if (currentBitmap != null) {
Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize()
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = (image.index + 1).toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable @Composable
private fun HighlightsList( private fun HighlightsList(

View file

@ -0,0 +1,243 @@
package com.aryan.reader.epubreader
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.AndroidHtmlResourceResolver
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.io.File
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.Base64
data class EpubReaderImageReference(
val id: String,
val index: Int,
val sourcePath: String,
val originalSource: String,
val altText: String?,
val chapterIndex: Int,
val chapterTitle: String,
val elementId: String?,
val ordinalInChapter: Int,
val chunkIndex: Int?,
val intrinsicWidth: Int?,
val intrinsicHeight: Int?
) {
val displayTitle: String
get() = altText?.trim()?.takeIf { it.isNotBlank() }
?: sourceName()?.substringBeforeLast('.')?.takeIf { it.isNotBlank() }
?: "Image ${index + 1}"
val dimensionLabel: String?
get() {
val width = intrinsicWidth?.takeIf { it > 0 }
val height = intrinsicHeight?.takeIf { it > 0 }
return if (width != null && height != null) "${width}x$height" else null
}
fun sourceName(): String? {
val source = originalSource.takeIf { it.isNotBlank() } ?: sourcePath
if (source.startsWith("data:", ignoreCase = true)) return null
return source
.substringBefore('#')
.substringBefore('?')
.replace('\\', '/')
.substringAfterLast('/')
.takeIf { it.isNotBlank() }
}
fun suggestedDownloadFileName(): String {
val extension = sourcePath.readerImageExtension()
?: originalSource.readerImageExtension()
?: "png"
val base = altText?.trim()?.takeIf { it.isNotBlank() }
?: sourceName()?.substringBeforeLast('.')?.takeIf { it.isNotBlank() }
?: "image-${index + 1}"
val safeBase = base.sanitizedReaderImageFileBase().ifBlank { "image-${index + 1}" }
return "$safeBase.$extension"
}
fun mimeType(): String {
val dataMime = readerDataUriMimeType(sourcePath)
if (dataMime != null) return dataMime
return when (sourcePath.readerImageExtension() ?: originalSource.readerImageExtension()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"webp" -> "image/webp"
"bmp" -> "image/bmp"
"svg" -> "image/svg+xml"
else -> "image/*"
}
}
}
fun EpubBook.readerImageReferencesForDrawer(): List<EpubReaderImageReference> {
val references = mutableListOf<EpubReaderImageReference>()
chapters.forEachIndexed { chapterIndex, chapter ->
val html = chapter.readerImageHtml(extractionBasePath).takeIf { it.isNotBlank() }
?: return@forEachIndexed
val sourceOrdinalByKey = mutableMapOf<String, Int>()
val document = Jsoup.parse(html, chapter.absPath)
document.select("img, image").forEach { element ->
val originalSource = element.readerImageSource() ?: return@forEach
val sourcePath = resolveReaderImageSource(chapter, extractionBasePath, originalSource)
val sourceKey = sourcePath.readerImageLookupKey()
val ordinal = sourceOrdinalByKey.getOrDefault(sourceKey, 0)
sourceOrdinalByKey[sourceKey] = ordinal + 1
val index = references.size
references += EpubReaderImageReference(
id = "android-epub-image:$chapterIndex:$ordinal:${sourcePath.hashCode()}:$index",
index = index,
sourcePath = sourcePath,
originalSource = originalSource,
altText = element.attr("alt").ifBlank { element.attr("title") }.ifBlank { null },
chapterIndex = chapterIndex,
chapterTitle = chapter.title.ifBlank { "Chapter ${chapterIndex + 1}" },
elementId = element.id().ifBlank { null },
ordinalInChapter = ordinal,
chunkIndex = element.readerTopLevelBodyChildIndex()?.let { it / 20 },
intrinsicWidth = element.readerImageDimension("width"),
intrinsicHeight = element.readerImageDimension("height")
)
}
}
return references
}
fun EpubReaderImageReference.readDownloadBytes(): ByteArray? {
if (sourcePath.startsWith("data:", ignoreCase = true)) {
return sourcePath.readerDataUriBytes()
}
return runCatching {
File(sourcePath).takeIf { it.isFile }?.readBytes()
}.getOrNull()
}
private fun EpubChapter.readerImageHtml(extractionBasePath: String): String {
if (htmlContent.isNotBlank()) return htmlContent
return runCatching {
File(extractionBasePath, htmlFilePath).takeIf { it.isFile }?.readText().orEmpty()
}.getOrDefault("")
}
private fun Element.readerImageSource(): String? {
return listOf("src", "href", "xlink:href", "data-src")
.firstNotNullOfOrNull { attrName ->
attr(attrName).trim().takeIf { it.isNotBlank() }
}
}
private fun Element.readerImageDimension(attribute: String): Int? {
val raw = attr(attribute).trim().takeIf { it.isNotBlank() } ?: return null
return Regex("""\d+""").find(raw)?.value?.toIntOrNull()?.takeIf { it > 0 }
}
private fun Element.readerTopLevelBodyChildIndex(): Int? {
val body = ownerDocument()?.body() ?: return null
var topLevel: Element = this
while (topLevel.parent() != null && topLevel.parent() != body) {
topLevel = topLevel.parent() ?: break
}
if (topLevel.parent() != body) return null
return body.childNodes().indexOf(topLevel).takeIf { it >= 0 }
}
private fun resolveReaderImageSource(
chapter: EpubChapter,
extractionBasePath: String,
source: String
): String {
val withoutFragment = source.substringBefore('#').substringBefore('?')
if (withoutFragment.startsWith("data:", ignoreCase = true)) return source
val fileUriPath = withoutFragment.readerFileUriPath()
fileUriPath?.let { path ->
val file = File(path)
if (file.isFile) {
return runCatching { file.canonicalFile.absolutePath }.getOrDefault(file.absolutePath)
}
}
val sourceForResolve = fileUriPath ?: withoutFragment
AndroidHtmlResourceResolver.resolvePath(chapter.absPath, extractionBasePath, sourceForResolve)?.let {
return it
}
val fallbackCandidates = listOf(
File(extractionBasePath, sourceForResolve),
File(extractionBasePath, sourceForResolve.trimStart('/', '\\'))
)
return fallbackCandidates
.firstOrNull { it.isFile }
?.let { runCatching { it.canonicalFile.absolutePath }.getOrDefault(it.absolutePath) }
?: source
}
private fun String.readerFileUriPath(): String? {
if (!startsWith("file:", ignoreCase = true)) return null
return runCatching {
URI(this).path?.let { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) }
}.getOrNull()
}
private fun String.readerImageLookupKey(): String {
return substringBefore('#')
.substringBefore('?')
.replace('\\', '/')
.lowercase()
}
private fun String.readerImageExtension(): String? {
readerDataUriMimeType(this)?.let { mime ->
return when (mime.lowercase()) {
"image/jpeg" -> "jpg"
"image/png" -> "png"
"image/gif" -> "gif"
"image/webp" -> "webp"
"image/bmp" -> "bmp"
"image/svg+xml" -> "svg"
else -> null
}
}
return substringBefore('#')
.substringBefore('?')
.substringAfterLast('.', "")
.lowercase()
.takeIf { it in setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "svg") }
}
private fun readerDataUriMimeType(source: String): String? {
if (!source.startsWith("data:", ignoreCase = true)) return null
return source
.drop(5)
.substringBefore(';')
.substringBefore(',')
.takeIf { it.startsWith("image/", ignoreCase = true) }
}
private fun String.readerDataUriBytes(): ByteArray? {
val commaIndex = indexOf(',')
if (!startsWith("data:", ignoreCase = true) || commaIndex == -1) return null
val metadata = substring(0, commaIndex)
val data = substring(commaIndex + 1)
return runCatching {
if (metadata.contains(";base64", ignoreCase = true)) {
Base64.getDecoder().decode(data)
} else {
URLDecoder.decode(data, StandardCharsets.UTF_8.name()).toByteArray(StandardCharsets.UTF_8)
}
}.getOrNull()
}
private fun String.sanitizedReaderImageFileBase(): String {
return replace(Regex("""[\\/:*?"<>|]+"""), "_")
.replace(Regex("""\s+"""), " ")
.trim()
.trim('.')
.take(80)
}

View file

@ -158,6 +158,9 @@ import com.aryan.reader.BuildConfig
import com.aryan.reader.BuiltInThemes import com.aryan.reader.BuiltInThemes
import com.aryan.reader.MainViewModel import com.aryan.reader.MainViewModel
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.ReaderBrightnessEffect
import com.aryan.reader.ReaderFileInfoDialogs
import com.aryan.reader.ReaderBrightnessSheet
import com.aryan.reader.ReaderScreenOrientationEffect import com.aryan.reader.ReaderScreenOrientationEffect
import com.aryan.reader.ReaderScreenOrientationSheet import com.aryan.reader.ReaderScreenOrientationSheet
import com.aryan.reader.ReaderThemePanel import com.aryan.reader.ReaderThemePanel
@ -176,13 +179,17 @@ import com.aryan.reader.epub.hasReadableExtractedContent
import com.aryan.reader.fetchAiDefinition import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadReaderBrightnessSettings
import com.aryan.reader.loadReaderScreenOrientationMode import com.aryan.reader.loadReaderScreenOrientationMode
import com.aryan.reader.loadEpubRightToLeftPagination import com.aryan.reader.loadEpubRightToLeftPagination
import com.aryan.reader.loadReaderThemeId import com.aryan.reader.loadReaderThemeId
import com.aryan.reader.loadReaderSliderToggled
import com.aryan.reader.loadReaderTextureBitmap import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.loadTtsReplacementPreferences import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.readerSliderBookmarkPosition
import com.aryan.reader.readerSliderChromeColors
import com.aryan.reader.readerSliderToggleState
import com.aryan.reader.paginatedreader.BookPaginator import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.CfiUtils
import com.aryan.reader.paginatedreader.HeaderBlock import com.aryan.reader.paginatedreader.HeaderBlock
import com.aryan.reader.paginatedreader.IPaginator import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.paginatedreader.ListItemBlock import com.aryan.reader.paginatedreader.ListItemBlock
@ -198,10 +205,13 @@ import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveReaderBrightnessSettings
import com.aryan.reader.saveReaderScreenOrientationMode import com.aryan.reader.saveReaderScreenOrientationMode
import com.aryan.reader.saveEpubRightToLeftPagination import com.aryan.reader.saveEpubRightToLeftPagination
import com.aryan.reader.saveReaderThemeId import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.saveReaderSliderToggled
import com.aryan.reader.saveTtsReplacementPreferences import com.aryan.reader.saveTtsReplacementPreferences
import com.aryan.reader.shouldRenderReaderSlider
import com.aryan.reader.shared.ReaderTtsReplacementPreferences import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.SpeakerSamplePlayer
@ -210,6 +220,7 @@ import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.splitTextIntoChunks import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements import com.aryan.reader.withTtsReplacements
import com.aryan.reader.shared.reader.ReaderJumpHistory import com.aryan.reader.shared.reader.ReaderJumpHistory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -217,6 +228,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
@ -246,7 +258,7 @@ private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools"
private const val TOOL_ORDER_KEY = "reader_tool_order" private const val TOOL_ORDER_KEY = "reader_tool_order"
private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools" private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools"
private const val HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "reader_hidden_tools_defaults_version" private const val HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "reader_hidden_tools_defaults_version"
private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 1 private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 2
private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore" 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_LIFECYCLE_RESUME = "lifecycle_resume"
private const val TTS_LOCATE_REASON_OVERLAY = "overlay" private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
@ -264,6 +276,25 @@ private fun epubHighlightDiagSnippet(text: String, maxLength: Int = 80): String
.take(maxLength) .take(maxLength)
} }
private fun List<TtsChunk>.withInitialChunkOverride(
startChunkIndex: Int,
initialChunk: TtsChunk?
): List<TtsChunk> {
if (initialChunk == null || startChunkIndex !in indices) return this
val existing = this[startChunkIndex]
if (
existing.text == initialChunk.text &&
existing.sourceCfi == initialChunk.sourceCfi &&
existing.startOffsetInSource == initialChunk.startOffsetInSource
) {
return this
}
return toMutableList().also { chunks ->
chunks[startChunkIndex] = initialChunk
}
}
private fun View.bottomRoundedCornerRadiusPx(): Int { private fun View.bottomRoundedCornerRadiusPx(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
@ -313,7 +344,7 @@ private fun loadHiddenTools(context: Context): Set<String> {
val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty() val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0) val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) { if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) {
val migratedHiddenTools = savedHiddenTools + defaultReaderHiddenTools() val migratedHiddenTools = savedHiddenTools + readerHiddenToolsIntroducedAfter(defaultsVersion)
prefs.edit { prefs.edit {
putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools) putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools)
putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION) putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION)
@ -323,6 +354,13 @@ private fun loadHiddenTools(context: Context): Set<String> {
return savedHiddenTools return savedHiddenTools
} }
private fun readerHiddenToolsIntroducedAfter(defaultsVersion: Int): Set<String> {
return buildSet {
if (defaultsVersion < 1) add(ReaderTool.SCREEN_ORIENTATION.name)
if (defaultsVersion < 2) add(ReaderTool.BRIGHTNESS.name)
}
}
private fun saveToolOrder(context: Context, toolOrder: List<ReaderTool>) { private fun saveToolOrder(context: Context, toolOrder: List<ReaderTool>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) } prefs.edit { putString(TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
@ -644,9 +682,19 @@ fun EpubReaderHost(
) { ) {
val view = LocalView.current val view = LocalView.current
val context = LocalContext.current val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
val window = (view.context as? Activity)?.window val window = (view.context as? Activity)?.window
val activity = context as? Activity val activity = context as? Activity
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var readerBrightnessSettings by remember { mutableStateOf(loadReaderBrightnessSettings(context)) }
var showBrightnessSheet by remember { mutableStateOf(false) }
ReaderBrightnessEffect(window, readerBrightnessSettings)
val updateReaderBrightness: (com.aryan.reader.ReaderBrightnessSettings) -> Unit = { settings ->
readerBrightnessSettings = settings
saveReaderBrightnessSettings(context, settings)
}
fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) { fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) {
viewModel.showBanner(message, isError, isPersistent) viewModel.showBanner(message, isError, isPersistent)
} }
@ -663,8 +711,8 @@ fun EpubReaderHost(
var isNavigatingToPosition by remember { mutableStateOf(false) } var isNavigatingToPosition by remember { mutableStateOf(false) }
var isSeamlessTransitioning by remember { mutableStateOf(false) } var isSeamlessTransitioning by remember { mutableStateOf(false) }
var showInsufficientCreditsDialog by remember { mutableStateOf(false) } var showInsufficientCreditsDialog by remember { mutableStateOf(false) }
var showFileInfoDialog by remember { mutableStateOf(false) }
var isPageSliderVisible by remember { mutableStateOf(false) }
var sliderCurrentPage by remember { mutableFloatStateOf(0f) } var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
var isFastScrubbing by remember { mutableStateOf(false) } var isFastScrubbing by remember { mutableStateOf(false) }
val scrubDebounceJob = remember { mutableStateOf<Job?>(null) } val scrubDebounceJob = remember { mutableStateOf<Job?>(null) }
@ -717,6 +765,11 @@ fun EpubReaderHost(
val readerCacheBookId = remember(stableBookId, epubBook.title, epubBook.fileName) { val readerCacheBookId = remember(stableBookId, epubBook.title, epubBook.fileName) {
stableBookId ?: if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title) stableBookId ?: if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
} }
val bookId = readerCacheBookId
var isPageSliderVisible by remember(bookId) {
mutableStateOf(loadReaderSliderToggled(context, bookId))
}
val locatorConverter = remember(context, readerCacheBookId) { val locatorConverter = remember(context, readerCacheBookId) {
LocatorConverter( LocatorConverter(
@ -749,7 +802,6 @@ fun EpubReaderHost(
var isAutoScrollCollapsed by remember { mutableStateOf(false) } var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isTtsCollapsed by remember { mutableStateOf(false) } var isTtsCollapsed by remember { mutableStateOf(false) }
val bookId = readerCacheBookId
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) } var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
val initialSettings = remember(isAutoScrollLocal) { val initialSettings = remember(isAutoScrollLocal) {
@ -1017,6 +1069,13 @@ fun EpubReaderHost(
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var showBars by remember { mutableStateOf(false) } var showBars by remember { mutableStateOf(false) }
val chapters = remember(epubBook.chapters) { epubBook.chapters } val chapters = remember(epubBook.chapters) { epubBook.chapters }
var readerImages by remember(epubBook) { mutableStateOf<List<EpubReaderImageReference>>(emptyList()) }
LaunchedEffect(epubBook) {
readerImages = withContext(Dispatchers.IO) {
epubBook.readerImageReferencesForDrawer()
}
}
var currentChapterIndex by rememberSaveable(epubBook.title) { var currentChapterIndex by rememberSaveable(epubBook.title) {
mutableIntStateOf( mutableIntStateOf(
@ -1048,11 +1107,14 @@ fun EpubReaderHost(
var loadUpToChunkIndex by remember(currentChapterIndex) { mutableIntStateOf(0) } var loadUpToChunkIndex by remember(currentChapterIndex) { mutableIntStateOf(0) }
var chapterChunks by remember(currentChapterIndex) { mutableStateOf<List<String>>(emptyList()) } var chapterChunks by remember(currentChapterIndex) { mutableStateOf<List<String>>(emptyList()) }
var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf<List<Int>>(emptyList()) }
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") } var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) } var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
var cfiToLoad by remember { mutableStateOf(initialCfi) } var cfiToLoad by remember { mutableStateOf(initialCfi) }
var fragmentToLoad by remember { mutableStateOf<String?>(null) } var fragmentToLoad by remember { mutableStateOf<String?>(null) }
var imageToLoad by remember { mutableStateOf<EpubReaderImageReference?>(null) }
var isInitialCfiLoad by remember(initialLocator) { mutableStateOf(initialLocator != null) } var isInitialCfiLoad by remember(initialLocator) { mutableStateOf(initialLocator != null) }
var bookmarkPageMap by remember { mutableStateOf<Map<String, Int>>(emptyMap()) } var bookmarkPageMap by remember { mutableStateOf<Map<String, Int>>(emptyMap()) }
@ -1170,13 +1232,6 @@ fun EpubReaderHost(
} }
} }
LaunchedEffect(isPageSliderVisible) {
if (!isPageSliderVisible) {
startPageThumbnail?.recycle()
startPageThumbnail = null
}
}
LaunchedEffect(ttsState.errorMessage) { LaunchedEffect(ttsState.errorMessage) {
ttsState.errorMessage?.let { message -> ttsState.errorMessage?.let { message ->
if (message == "INSUFFICIENT_CREDITS") { if (message == "INSUFFICIENT_CREDITS") {
@ -1189,6 +1244,12 @@ fun EpubReaderHost(
} }
val searchState = rememberSearchState(scope = scope, searcher = epubSearcher) val searchState = rememberSearchState(scope = scope, searcher = epubSearcher)
val isEpubSliderReady = currentRenderMode == RenderMode.VERTICAL_SCROLL || paginatedPagerState.pageCount > 0
val epubSliderChromeVisible = shouldRenderReaderSlider(
isToggledOn = isPageSliderVisible,
isBottomChromeVisible = showBars,
isSearchActive = searchState.isSearchActive
) && isEpubSliderReady
val speakerPlayer = remember(context, scope) { val speakerPlayer = remember(context, scope) {
SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() }) SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() })
} }
@ -1282,6 +1343,11 @@ fun EpubReaderHost(
if (systemIsDark) Color(0xFFE0E0E0) else Color(0xFF000000) if (systemIsDark) Color(0xFFE0E0E0) else Color(0xFF000000)
} else activeTheme.textColor } else activeTheme.textColor
} }
val epubReaderSliderColors = readerSliderChromeColors(
pageBackground = effectiveBg,
pageText = effectiveText,
themePrimary = MaterialTheme.colorScheme.primary
)
val activeTextureId = activeTheme.textureId val activeTextureId = activeTheme.textureId
val activeTextureAlpha = 1f - globalTextureTransparency val activeTextureAlpha = 1f - globalTextureTransparency
val activeTextureBitmap = remember(activeTextureId) { val activeTextureBitmap = remember(activeTextureId) {
@ -1326,7 +1392,8 @@ fun EpubReaderHost(
!ttsState.currentWordSourceCfi.isNullOrBlank() || !ttsState.currentWordSourceCfi.isNullOrBlank() ||
!ttsState.sourceCfi.isNullOrBlank() || !ttsState.sourceCfi.isNullOrBlank() ||
!ttsState.currentText.isNullOrBlank() !ttsState.currentText.isNullOrBlank()
val isSameBook = ttsState.bookTitle == null || ttsState.bookTitle == epubBook.title val isSameBook = ttsState.bookId?.let { it == bookId }
?: (ttsState.bookTitle == null || ttsState.bookTitle == epubBook.title)
return isReaderSession && hasReaderSessionState && isSameBook return isReaderSession && hasReaderSessionState && isSameBook
} }
@ -1372,6 +1439,7 @@ fun EpubReaderHost(
chunkTargetOverride = null chunkTargetOverride = null
cfiToLoad = null cfiToLoad = null
fragmentToLoad = null fragmentToLoad = null
imageToLoad = null
isNavigatingToPosition = false isNavigatingToPosition = false
suppressNextVerticalTtsDetach = false suppressNextVerticalTtsDetach = false
} }
@ -1570,22 +1638,31 @@ fun EpubReaderHost(
val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0 val chapterStartPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0
val pageInChapter = currentPage - chapterStartPage val pageInChapter = currentPage - chapterStartPage
val ttsChunks = bookPaginator.getTtsChunksForChapter( val allTtsChunks = bookPaginator.getTtsChunksForChapter(chapterIndex)
chapterIndex = chapterIndex, val firstChunkOnPage = if (pageInChapter > 0) {
startingFromPageInChapter = pageInChapter bookPaginator.getTtsChunksForChapter(
) chapterIndex = chapterIndex,
startingFromPageInChapter = pageInChapter
)?.firstOrNull()
} else {
allTtsChunks?.firstOrNull()
}
val startChunkIndex = findTtsChunkStartIndex(allTtsChunks.orEmpty(), firstChunkOnPage) ?: 0
if (!ttsChunks.isNullOrEmpty()) { if (!allTtsChunks.isNullOrEmpty() && firstChunkOnPage != null) {
val chapterTitle = chapters.getOrNull(chapterIndex)?.title val chapterTitle = chapters.getOrNull(chapterIndex)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsChapterIndex = chapterIndex ttsChapterIndex = chapterIndex
ttsController.start( ttsController.start(
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId), chunks = allTtsChunks.withInitialChunkOverride(startChunkIndex, firstChunkOnPage)
.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title, bookTitle = epubBook.title,
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
bookId = bookId,
chapterIndex = chapterIndex, chapterIndex = chapterIndex,
totalChapters = chapters.size, totalChapters = chapters.size,
startChunkIndex = startChunkIndex,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token
@ -1597,6 +1674,33 @@ fun EpubReaderHost(
) )
} }
var pendingImageDownload by remember { mutableStateOf<EpubReaderImageReference?>(null) }
val imageSaveLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("image/*"),
onResult = { uri ->
val image = pendingImageDownload
pendingImageDownload = null
if (uri != null && image != null) {
scope.launch {
val saved = withContext(Dispatchers.IO) {
runCatching {
val bytes = image.readDownloadBytes() ?: error("Image bytes are unavailable")
context.contentResolver.openOutputStream(uri)?.use { output ->
output.write(bytes)
} ?: error("Could not open image destination")
}.isSuccess
}
val message = if (saved) {
context.getString(R.string.saved_image_message, image.suggestedDownloadFileName())
} else {
context.getString(R.string.error_save_image)
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
}
)
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
onResult = { _ -> onResult = { _ ->
@ -1616,17 +1720,14 @@ fun EpubReaderHost(
val bookPaginator = paginator as? BookPaginator val bookPaginator = paginator as? BookPaginator
val chapterIndex = currentChapterInPaginatedMode ?: return@launch val chapterIndex = currentChapterInPaginatedMode ?: return@launch
val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch
val foundIdx = findTtsChunkStartIndex(
var foundIdx = -1 chunks = chunks,
for (i in chunks.indices) { target = TtsChunk(
val c = chunks[i] text = "",
val cPath = CfiUtils.getPath(c.sourceCfi) sourceCfi = baseCfi,
val bPath = CfiUtils.getPath(baseCfi) startOffsetInSource = startOffset
if (cPath == bPath && startOffset >= c.startOffsetInSource && startOffset < c.startOffsetInSource + c.text.length) { )
foundIdx = i ) ?: -1
break
}
}
if (foundIdx != -1) { if (foundIdx != -1) {
val target = chunks[foundIdx] val target = chunks[foundIdx]
@ -1639,21 +1740,24 @@ fun EpubReaderHost(
spokenText = slicedText, spokenText = slicedText,
) )
val remainingChunks = mutableListOf(newChunk) val sessionChunks = chunks.toMutableList().also {
remainingChunks.addAll(chunks.subList(foundIdx + 1, chunks.size)) it[foundIdx] = newChunk
}
if (remainingChunks.isNotEmpty()) { if (sessionChunks.isNotEmpty()) {
ttsShouldStartOnChapterLoad = false ttsShouldStartOnChapterLoad = false
ttsChapterIndex = chapterIndex ttsChapterIndex = chapterIndex
val chapterTitle = chapters.getOrNull(chapterIndex)?.title val chapterTitle = chapters.getOrNull(chapterIndex)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsController.start( ttsController.start(
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, bookId), chunks = sessionChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title, bookTitle = epubBook.title,
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
bookId = bookId,
chapterIndex = chapterIndex, chapterIndex = chapterIndex,
totalChapters = chapters.size, totalChapters = chapters.size,
startChunkIndex = foundIdx,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token
@ -1770,6 +1874,63 @@ fun EpubReaderHost(
} }
} }
fun currentEpubSliderPage(): Int {
return when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> currentPageInChapter
RenderMode.PAGINATED -> (paginatedPagerState.currentPage + 1).coerceAtLeast(1)
}
}
fun resetEpubSliderBookmark() {
val position = readerSliderBookmarkPosition(currentEpubSliderPage())
sliderStartPage = position.startPage
sliderCurrentPage = position.currentPage
}
LaunchedEffect(bookId, isPageSliderVisible) {
saveReaderSliderToggled(context, bookId, isPageSliderVisible)
if (isPageSliderVisible) {
resetEpubSliderBookmark()
}
}
fun toggleEpubPageSlider() {
if (!isPageSliderVisible && currentRenderMode == RenderMode.PAGINATED && paginatedPagerState.pageCount <= 0) {
showBanner("Book is not paginated yet.")
return
}
val nextState = readerSliderToggleState(
isCurrentlyToggledOn = isPageSliderVisible,
currentPage = currentEpubSliderPage()
)
sliderStartPage = nextState.bookmarkPosition.startPage
sliderCurrentPage = nextState.bookmarkPosition.currentPage
isPageSliderVisible = nextState.isToggledOn
showBars = true
if (nextState.isToggledOn) {
showFormatAdjustmentBars = false
}
}
LaunchedEffect(isPageSliderVisible, epubSliderChromeVisible, currentRenderMode, currentPageInChapter, paginatedPagerState.currentPage) {
if (isPageSliderVisible && !epubSliderChromeVisible) {
resetEpubSliderBookmark()
}
}
LaunchedEffect(epubSliderChromeVisible, currentRenderMode, sliderStartPage, webViewRefForTts) {
if (epubSliderChromeVisible && currentRenderMode == RenderMode.VERTICAL_SCROLL) {
startPageThumbnail?.recycle()
startPageThumbnail = webViewRefForTts?.let { webView ->
captureWebViewVisibleArea(webView)
}
} else if (!epubSliderChromeVisible || currentRenderMode == RenderMode.PAGINATED) {
startPageThumbnail?.recycle()
startPageThumbnail = null
}
}
val latestChapterIndex by rememberUpdatedState(currentChapterIndex) val latestChapterIndex by rememberUpdatedState(currentChapterIndex)
LaunchedEffect(ttsState.bookTitle, ttsState.chapterIndex, ttsState.sourceCfi, ttsState.playbackSource) { LaunchedEffect(ttsState.bookTitle, ttsState.chapterIndex, ttsState.sourceCfi, ttsState.playbackSource) {
@ -1952,6 +2113,8 @@ fun EpubReaderHost(
webViewRefForTts = null webViewRefForTts = null
chapterHead = "" chapterHead = ""
chapterChunks = emptyList() chapterChunks = emptyList()
chapterChunkElementStartIndices = emptyList()
chapterChunkElementCounts = emptyList()
startPageThumbnail?.recycle() startPageThumbnail?.recycle()
startPageThumbnail = null startPageThumbnail = null
autoScrollResumeJob.value?.cancel() autoScrollResumeJob.value?.cancel()
@ -2029,6 +2192,8 @@ fun EpubReaderHost(
chapterHead = result.head chapterHead = result.head
chapterChunks = result.chunks chapterChunks = result.chunks
chapterChunkElementStartIndices = result.chunkElementStartIndices
chapterChunkElementCounts = result.chunkElementCounts
isChapterParsing = false isChapterParsing = false
if (initialScrollTargetForChapter == ChapterScrollPosition.END) { if (initialScrollTargetForChapter == ChapterScrollPosition.END) {
@ -2505,6 +2670,42 @@ fun EpubReaderHost(
} }
} }
fun scrollCurrentVerticalChapterToImage(image: EpubReaderImageReference) {
val targetChunk = image.chunkIndex
if (targetChunk != null && targetChunk >= 0) {
injectVerticalChunksThrough(targetChunk)
}
val escapedSource = escapeJsString(image.sourcePath)
val escapedOriginalSource = escapeJsString(image.originalSource)
webViewRefForTts?.evaluateJavascript(
"javascript:window.scrollToReaderImageSource('$escapedSource', ${image.ordinalInChapter}, '$escapedOriginalSource');",
null
)
}
fun navigateVerticalToImage(image: EpubReaderImageReference) {
scope.launch {
recordEpubJump(chapterStartJumpLocator(image.chapterIndex))
clearPendingTtsRelocationState("sidebar_image_vertical")
imageToLoad = image
cfiToLoad = null
fragmentToLoad = null
initialScrollTargetForChapter = null
if (image.chapterIndex != currentChapterIndex) {
chunkTargetOverride = image.chunkIndex?.coerceAtLeast(0)
Timber.tag(TAG_LINK_NAV)
.d("[CHAPTER-NAV] source=SIDEBAR_IMAGE, from=$currentChapterIndex, to=${image.chapterIndex}, image='${image.sourceName()}'")
currentScrollYPosition = 0
currentScrollHeightValue = 0
currentChapterIndex = image.chapterIndex
} else {
chunkTargetOverride = null
scrollCurrentVerticalChapterToImage(image)
imageToLoad = null
}
}
}
fun navigateVerticalToCfi(chapterIndex: Int, cfi: String) { fun navigateVerticalToCfi(chapterIndex: Int, cfi: String) {
scope.launch { scope.launch {
val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi) val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfi)
@ -2766,10 +2967,7 @@ fun EpubReaderHost(
} }
BackHandler(enabled = true) { BackHandler(enabled = true) {
if (isPageSliderVisible) { if (drawerState.isOpen) {
isPageSliderVisible = false
showBars = true
} else if (drawerState.isOpen) {
scope.launch { scope.launch {
Timber.d("Back pressed: Closing drawer") Timber.d("Back pressed: Closing drawer")
drawerState.close() drawerState.close()
@ -2795,6 +2993,7 @@ fun EpubReaderHost(
chapters = chapters, chapters = chapters,
tableOfContents = epubBook.tableOfContents, tableOfContents = epubBook.tableOfContents,
activeFragmentId = activeFragmentId, activeFragmentId = activeFragmentId,
readerImages = readerImages,
bookmarks = bookmarks, bookmarks = bookmarks,
userHighlights = userHighlights, userHighlights = userHighlights,
currentChapterIndex = currentChapterIndex, currentChapterIndex = currentChapterIndex,
@ -2803,6 +3002,56 @@ fun EpubReaderHost(
activeHighlightPalette = currentHighlightPalette, activeHighlightPalette = currentHighlightPalette,
onOpenPaletteManager = { showPaletteManager = true }, onOpenPaletteManager = { showPaletteManager = true },
onHighlightColorChange = onHighlightColorChange, onHighlightColorChange = onHighlightColorChange,
onNavigateToImage = { image ->
scope.launch {
drawerState.close()
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
navigateVerticalToImage(image)
}
RenderMode.PAGINATED -> {
val bookPaginator = paginator as? BookPaginator
if (bookPaginator != null) {
isNavigatingByToc = true
try {
val imagePage = bookPaginator.findStablePageForImageSource(
chapterIndex = image.chapterIndex,
sourcePath = image.sourcePath,
elementId = image.elementId,
ordinalInChapter = image.ordinalInChapter
)
if (imagePage != null) {
val (pageIndex, locator) = imagePage
paginatedJumpLocatorForPage(
pageIndex = pageIndex,
targetLocator = locator,
allowPageFallback = true
)?.let { recordEpubJump(it) }
scrollPaginatedToJumpPage(pageIndex, locator)
} else {
val fallbackPage = bookPaginator.findStableChapterStartPage(image.chapterIndex)
if (fallbackPage != null) {
recordEpubJump(chapterStartJumpLocator(image.chapterIndex).copy(pageIndex = fallbackPage))
scrollPaginatedToJumpPage(
fallbackPage,
Locator(image.chapterIndex, 0, 0),
fallbackToChapterStart = true
)
}
}
} finally {
isNavigatingByToc = false
}
}
}
}
if (showBars) showBars = false
}
},
onDownloadImage = { image ->
pendingImageDownload = image
imageSaveLauncher.launch(image.suggestedDownloadFileName())
},
onNavigateToTocEntry = { entry -> onNavigateToTocEntry = { entry ->
scope.launch { scope.launch {
drawerState.close() drawerState.close()
@ -3434,6 +3683,10 @@ fun EpubReaderHost(
currentTopPadding currentTopPadding
} }
val epubJumpBackLabel = epubJumpHistory.backLocator?.epubJumpLabel()
val epubJumpForwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel()
val isEpubJumpHistoryVisible = showBars && !searchState.isSearchActive && (epubJumpBackLabel != null || epubJumpForwardLabel != null)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -3545,16 +3798,26 @@ fun EpubReaderHost(
} else if (chapterChunks.isNotEmpty()) { } else if (chapterChunks.isNotEmpty()) {
var hasRequestedExtractionForThisChapter by remember(targetChapterIndex) { mutableStateOf(false) } var hasRequestedExtractionForThisChapter by remember(targetChapterIndex) { mutableStateOf(false) }
val initialContentToLoad = remember(loadUpToChunkIndex, chapterChunks) { val initialContentToLoad = remember(
loadUpToChunkIndex,
chapterChunks,
chapterChunkElementStartIndices,
chapterChunkElementCounts
) {
val targetIdx = loadUpToChunkIndex val targetIdx = loadUpToChunkIndex
val startIdx = 0 val startIdx = 0
val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1) val endIdx = minOf(chapterChunks.lastIndex, targetIdx + 1)
chapterChunks.indices.joinToString(separator = "\n") { index -> chapterChunks.indices.joinToString(separator = "\n") { index ->
val attributes = readerChunkContainerAttributes(
index,
chapterChunkElementStartIndices,
chapterChunkElementCounts
)
if (index in startIdx..endIdx) { if (index in startIdx..endIdx) {
"<div class='chunk-container' data-chunk-index='$index'>${chapterChunks[index]}</div>" "<div class='chunk-container' $attributes>${chapterChunks[index]}</div>"
} else { } else {
"<div class='chunk-container' data-chunk-index='$index'></div>" "<div class='chunk-container' $attributes></div>"
} }
} }
} }
@ -3651,6 +3914,9 @@ fun EpubReaderHost(
initialPageScrollY = currentScrollYPosition, initialPageScrollY = currentScrollYPosition,
initialCfi = cfiToLoad, initialCfi = cfiToLoad,
initialFragmentId = fragmentToLoad.also { }, initialFragmentId = fragmentToLoad.also { },
initialImageSource = imageToLoad?.sourcePath,
initialImageOriginalSource = imageToLoad?.originalSource,
initialImageOrdinal = imageToLoad?.ordinalInChapter ?: 0,
userHighlights = userHighlights.filter { it.chapterIndex == targetChapterIndex }, userHighlights = userHighlights.filter { it.chapterIndex == targetChapterIndex },
activeHighlightPalette = currentHighlightPalette, activeHighlightPalette = currentHighlightPalette,
onUpdatePalette = onUpdateHighlightPalette, onUpdatePalette = onUpdateHighlightPalette,
@ -3693,12 +3959,14 @@ fun EpubReaderHost(
) )
} else { } else {
val wasCfiScroll = cfiToLoad != null val wasCfiScroll = cfiToLoad != null
Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll") val wasImageScroll = imageToLoad != null
logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll") Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll, Was image scroll: $wasImageScroll")
logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll wasImageScroll=$wasImageScroll")
initialScrollTargetForChapter = null initialScrollTargetForChapter = null
cfiToLoad = null cfiToLoad = null
fragmentToLoad = null fragmentToLoad = null
Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll") imageToLoad = null
Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll, Was image scroll: $wasImageScroll")
isWebViewReady = true isWebViewReady = true
if (wasCfiScroll) { if (wasCfiScroll) {
@ -4154,14 +4422,27 @@ fun EpubReaderHost(
Uri.fromFile(File(it)).toString() Uri.fromFile(File(it)).toString()
} }
ttsChapterIndex = targetChapterIndex ttsChapterIndex = targetChapterIndex
val nativeChapterChunks = locatorConverter
.getTtsChunksForChapter(epubBook, targetChapterIndex, bookId)
.orEmpty()
val extractedStartChunk = ttsChunks.firstOrNull()
val nativeStartChunkIndex = findTtsChunkStartIndex(nativeChapterChunks, extractedStartChunk)
val sessionChunks = if (nativeChapterChunks.isNotEmpty() && nativeStartChunkIndex != null) {
nativeChapterChunks.withInitialChunkOverride(nativeStartChunkIndex, extractedStartChunk)
} else {
ttsChunks
}
val startChunkIndex = nativeStartChunkIndex ?: 0
ttsController.start( ttsController.start(
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId), chunks = sessionChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title, bookTitle = epubBook.title,
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
bookId = bookId,
chapterIndex = targetChapterIndex, chapterIndex = targetChapterIndex,
totalChapters = chapters.size, totalChapters = chapters.size,
startChunkIndex = startChunkIndex,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token
@ -5216,6 +5497,7 @@ fun EpubReaderHost(
currentRenderMode = currentRenderMode, currentRenderMode = currentRenderMode,
isBookmarked = isBookmarked, isBookmarked = isBookmarked,
isTtsActive = isTtsSessionActive, isTtsActive = isTtsSessionActive,
isSliderActive = isPageSliderVisible,
tapToNavigateEnabled = tapToNavigateEnabled, tapToNavigateEnabled = tapToNavigateEnabled,
volumeScrollEnabled = volumeScrollEnabled, volumeScrollEnabled = volumeScrollEnabled,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
@ -5307,35 +5589,11 @@ fun EpubReaderHost(
onOpenTtsReplacements = { showTtsReplacementsSheet = true }, onOpenTtsReplacements = { showTtsReplacementsSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true }, onOpenThemeSettings = { showThemePanel = true },
onOpenBrightness = { showBrightnessSheet = true },
onOpenVisualOptions = { showVisualOptionsSheet = true }, onOpenVisualOptions = { showVisualOptionsSheet = true },
onOpenScreenOrientation = { showScreenOrientationSheet = true }, onOpenScreenOrientation = { showScreenOrientationSheet = true },
onOpenAiHub = { showAiHubSheet = true }, onOpenAiHub = { showAiHubSheet = true },
onOpenSlider = { onOpenSlider = ::toggleEpubPageSlider,
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 {
showBanner("Book is not paginated yet.")
}
}
}
},
onOpenDrawer = { onOpenDrawer = {
scope.launch { drawerState.open() } scope.launch { drawerState.open() }
}, },
@ -5343,6 +5601,7 @@ fun EpubReaderHost(
showFormatAdjustmentBars = !showFormatAdjustmentBars showFormatAdjustmentBars = !showFormatAdjustmentBars
if (showFormatAdjustmentBars) { if (showFormatAdjustmentBars) {
searchState.showSearchResultsPanel = false searchState.showSearchResultsPanel = false
resetEpubSliderBookmark()
isPageSliderVisible = false isPageSliderVisible = false
} }
}, },
@ -5374,6 +5633,7 @@ fun EpubReaderHost(
} }
} }
}, },
onOpenFileInfo = { showFileInfoDialog = true },
onToggleReflow = if (onToggleReflow != null) { onToggleReflow = if (onToggleReflow != null) {
{ {
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) { val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
@ -5537,8 +5797,8 @@ fun EpubReaderHost(
.padding(bottom = bottomPadding + 45.dp), .padding(bottom = bottomPadding + 45.dp),
showStandardBars = showBars, showStandardBars = showBars,
searchStateActive = searchState.isSearchActive, searchStateActive = searchState.isSearchActive,
backLabel = epubJumpHistory.backLocator?.epubJumpLabel(), backLabel = epubJumpBackLabel,
forwardLabel = epubJumpHistory.forwardLocator?.epubJumpLabel(), forwardLabel = epubJumpForwardLabel,
onBack = ::goBackInEpubJumpHistory, onBack = ::goBackInEpubJumpHistory,
onForward = ::goForwardInEpubJumpHistory, onForward = ::goForwardInEpubJumpHistory,
onClear = { epubJumpHistory = epubJumpHistory.clear() } onClear = { epubJumpHistory = epubJumpHistory.clear() }
@ -5555,35 +5815,12 @@ fun EpubReaderHost(
toolOrder = toolOrder, toolOrder = toolOrder,
bottomTools = bottomTools, bottomTools = bottomTools,
currentTtsMode = currentTtsMode, currentTtsMode = currentTtsMode,
isSliderActive = isPageSliderVisible,
onOpenAiHub = { showAiHubSheet = true }, onOpenAiHub = { showAiHubSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true }, onOpenThemeSettings = { showThemePanel = true },
onOpenSlider = { onOpenBrightness = { showBrightnessSheet = true },
when (currentRenderMode) { onOpenSlider = ::toggleEpubPageSlider,
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 {
showBanner("Book is not paginated yet.")
}
}
}
},
onOpenDrawer = { onOpenDrawer = {
scope.launch { drawerState.open() } scope.launch { drawerState.open() }
}, },
@ -5592,6 +5829,7 @@ fun EpubReaderHost(
showFormatAdjustmentBars = !showFormatAdjustmentBars showFormatAdjustmentBars = !showFormatAdjustmentBars
if (showFormatAdjustmentBars) { if (showFormatAdjustmentBars) {
searchState.showSearchResultsPanel = false searchState.showSearchResultsPanel = false
resetEpubSliderBookmark()
isPageSliderVisible = false isPageSliderVisible = false
} }
}, },
@ -5880,69 +6118,62 @@ fun EpubReaderHost(
onDismiss = { activeFootnoteHtml = null } onDismiss = { activeFootnoteHtml = null }
) )
} }
}
}
EpubReaderPageSlider( EpubReaderPageSlider(
isVisible = isPageSliderVisible, isVisible = epubSliderChromeVisible,
currentRenderMode = currentRenderMode, currentRenderMode = currentRenderMode,
totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount, totalPages = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount,
sliderCurrentPage = sliderCurrentPage, sliderCurrentPage = sliderCurrentPage,
sliderStartPage = sliderStartPage, sliderStartPage = sliderStartPage,
startPageThumbnail = startPageThumbnail, startPageThumbnail = startPageThumbnail,
paginator = paginator, paginator = paginator,
chapters = chapters, chapters = chapters,
onClose = { onScrub = { newValue ->
isPageSliderVisible = false sliderCurrentPage = newValue
showBars = true isFastScrubbing = true
}, scrubDebounceJob.value?.cancel()
onScrub = { newValue -> scrubDebounceJob.value = scope.launch {
sliderCurrentPage = newValue delay(200)
isFastScrubbing = true if (isActive) {
scrubDebounceJob.value?.cancel() val targetPage = newValue.roundToInt()
scrubDebounceJob.value = scope.launch { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
delay(200) val scrollY = (targetPage - 1) * currentClientHeightValue
if (isActive) { webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null)
val targetPage = newValue.roundToInt() } else {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { paginatedPagerState.scrollToPage(targetPage - 1)
val scrollY = (targetPage - 1) * currentClientHeightValue }
webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) isFastScrubbing = false
} else { }
paginatedPagerState.scrollToPage(targetPage - 1)
} }
isFastScrubbing = false },
} onJumpToPage = { page ->
} scope.launch {
}, if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
onJumpToPage = { page -> sliderCurrentPage = page.toFloat()
scope.launch { val scrollY = (page - 1) * currentClientHeightValue
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null)
sliderCurrentPage = page.toFloat() } else {
val scrollY = (page - 1) * currentClientHeightValue sliderCurrentPage = page.toFloat()
recordEpubJump( val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(page - 1)
SharedReaderLocator( scrollPaginatedToJumpPage(page - 1, targetLocator)
chapterIndex = currentChapterIndex, }
cfi = "android-scroll:$scrollY" }
) },
) modifier = Modifier
webViewRefForTts?.evaluateJavascript("window.scrollTo(0, $scrollY);", null) .align(Alignment.BottomCenter)
} else { .padding(bottom = bottomPadding + 45.dp + if (isEpubJumpHistoryVisible) 40.dp else 0.dp),
sliderCurrentPage = page.toFloat() activeColor = epubReaderSliderColors.activeTrackColor,
val targetLocator = (paginator as? BookPaginator)?.getLocatorForPage(page - 1) inactiveColor = epubReaderSliderColors.inactiveTrackColor,
paginatedJumpLocatorForPage( contentColor = epubReaderSliderColors.contentColor,
pageIndex = page - 1, thumbnailSurfaceColor = epubReaderSliderColors.thumbnailSurfaceColor,
targetLocator = targetLocator, thumbnailContentColor = epubReaderSliderColors.thumbnailContentColor
allowPageFallback = true )
)?.let { recordEpubJump(it) }
scrollPaginatedToJumpPage(page - 1, targetLocator) if (epubSliderChromeVisible && isFastScrubbing) {
} val total = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount
PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total)
} }
} }
)
if (isPageSliderVisible && isFastScrubbing) {
val total = if (currentRenderMode == RenderMode.VERTICAL_SCROLL) totalPagesInCurrentChapter else paginatedPagerState.pageCount
PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total)
} }
if (showTtsSettingsSheet) { if (showTtsSettingsSheet) {
@ -5974,6 +6205,15 @@ fun EpubReaderHost(
onDismiss = { showTtsReplacementsSheet = false }, onDismiss = { showTtsReplacementsSheet = false },
) )
ReaderFileInfoDialogs(
isFileInfoVisible = showFileInfoDialog,
onFileInfoVisibleChange = { showFileInfoDialog = it },
uiState = uiState,
primaryBookId = uiState.selectedBookId ?: stableBookId,
uriString = uiState.selectedEpubUri?.toString(),
viewModel = viewModel
)
if (showCustomizeToolsSheet) { if (showCustomizeToolsSheet) {
CustomizeToolsSheet( CustomizeToolsSheet(
hiddenTools = hiddenTools, hiddenTools = hiddenTools,
@ -5995,6 +6235,14 @@ fun EpubReaderHost(
) )
} }
if (showBrightnessSheet) {
ReaderBrightnessSheet(
settings = readerBrightnessSettings,
onSettingsChange = updateReaderBrightness,
onDismiss = { showBrightnessSheet = false }
)
}
if (showDictionarySettingsSheet) { if (showDictionarySettingsSheet) {
DictionarySettingsDialog( DictionarySettingsDialog(
isVisible = true, isVisible = true,

View file

@ -45,6 +45,7 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SearchResultsPanel import com.aryan.reader.SearchResultsPanel
import com.aryan.reader.SearchState import com.aryan.reader.SearchState
import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.paginatedreader.IPaginator import com.aryan.reader.paginatedreader.IPaginator
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -64,8 +65,7 @@ fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List<SearchResul
val results = mutableListOf<SearchResult>() val results = mutableListOf<SearchResult>()
epubBook.chapters.forEachIndexed { chapterIndex, chapter -> epubBook.chapters.forEachIndexed { chapterIndex, chapter ->
try { try {
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}" val htmlFile = File(epubBook.extractionBasePath, chapter.contentFilePath())
val htmlFile = File(fullPath)
if (!htmlFile.exists()) return@forEachIndexed if (!htmlFile.exists()) return@forEachIndexed
val doc = Jsoup.parse(htmlFile, "UTF-8") val doc = Jsoup.parse(htmlFile, "UTF-8")

View file

@ -109,6 +109,13 @@ import com.aryan.reader.data.CustomFontEntity
import java.io.File import java.io.File
import kotlin.math.roundToInt import kotlin.math.roundToInt
typealias ReaderFont = com.aryan.reader.shared.ReaderFont
typealias ReaderTextAlign = com.aryan.reader.shared.ReaderTextAlign
typealias SystemUiMode = com.aryan.reader.shared.SystemUiMode
typealias PageInfoMode = com.aryan.reader.shared.PageInfoMode
typealias PageInfoPosition = com.aryan.reader.shared.PageInfoPosition
typealias FormatSettings = com.aryan.reader.shared.FormatSettings
const val SETTINGS_PREFS_NAME = "epub_reader_settings" const val SETTINGS_PREFS_NAME = "epub_reader_settings"
private const val TEXT_ALIGN_KEY = "reader_text_align" private const val TEXT_ALIGN_KEY = "reader_text_align"
private const val FONT_SIZE_KEY = "reader_font_size" private const val FONT_SIZE_KEY = "reader_font_size"
@ -153,50 +160,45 @@ fun loadTtsPitch(context: Context): Float {
return prefs.getFloat(TTS_PITCH_KEY, 1.0f) return prefs.getFloat(TTS_PITCH_KEY, 1.0f)
} }
enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) { val ReaderTextAlign.iconResId: Int
ORIGINAL("original", "Original", "Original"), get() = when (this) {
MERRIWEATHER("merriweather", "Merriweather", "Merriweather"), ReaderTextAlign.DEFAULT,
LATO("lato", "Lato", "Lato"), ReaderTextAlign.LEFT -> R.drawable.format_align_left
LORA("lora", "Lora", "Lora"), ReaderTextAlign.RIGHT -> R.drawable.format_align_right
ROBOTO_MONO("roboto_mono", "Roboto Mono", "Roboto Mono"), ReaderTextAlign.JUSTIFY -> R.drawable.format_align_justify
LEXEND("lexend", "Lexend", "Lexend") }
}
enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, @StringRes val displayNameRes: Int) { @get:StringRes
DEFAULT("default", "", R.drawable.format_align_left, R.string.label_default), val ReaderTextAlign.displayNameRes: Int
LEFT("left", "left", R.drawable.format_align_left, R.string.label_left), get() = when (this) {
RIGHT("right", "right", R.drawable.format_align_right, R.string.label_right), ReaderTextAlign.DEFAULT -> R.string.label_default
JUSTIFY("justify", "justify", R.drawable.format_align_justify, R.string.label_justify) ReaderTextAlign.LEFT -> R.string.label_left
} ReaderTextAlign.RIGHT -> R.string.label_right
ReaderTextAlign.JUSTIFY -> R.string.label_justify
}
enum class SystemUiMode(val id: Int, @StringRes val titleRes: Int) { @get:StringRes
DEFAULT(0, R.string.label_always_show), val SystemUiMode.titleRes: Int
SYNC(1, R.string.label_sync_with_menus), get() = when (this) {
HIDDEN(2, R.string.label_always_hide) SystemUiMode.DEFAULT -> R.string.label_always_show
} SystemUiMode.SYNC -> R.string.label_sync_with_menus
SystemUiMode.HIDDEN -> R.string.label_always_hide
}
enum class PageInfoMode(val id: Int, @StringRes val titleRes: Int) { @get:StringRes
DEFAULT(0, R.string.label_always_show), val PageInfoMode.titleRes: Int
SYNC(1, R.string.label_sync_with_menus), get() = when (this) {
HIDDEN(2, R.string.label_always_hide) PageInfoMode.DEFAULT -> R.string.label_always_show
} PageInfoMode.SYNC -> R.string.label_sync_with_menus
PageInfoMode.HIDDEN -> R.string.label_always_hide
}
enum class PageInfoPosition(val id: Int, @StringRes val titleRes: Int) { @get:StringRes
BOTTOM(0, R.string.label_bottom), val PageInfoPosition.titleRes: Int
TOP(1, R.string.label_top) get() = when (this) {
} PageInfoPosition.BOTTOM -> R.string.label_bottom
PageInfoPosition.TOP -> R.string.label_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
)
private const val FORMAT_IS_LOCAL_PREFIX = "format_is_local_" private const val FORMAT_IS_LOCAL_PREFIX = "format_is_local_"
private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_" private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_"

View file

@ -327,20 +327,27 @@ private fun handleVerticalAutoAdvance(
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Loading remaining text of current chapter natively.") Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Vertical: Loading remaining text of current chapter natively.")
val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex) val nativeChunks = locatorConverter.getTtsChunksForChapter(epubBook, currentTtsChapterIndex)
if (!nativeChunks.isNullOrEmpty() && lastReadCfi != null) { if (!nativeChunks.isNullOrEmpty()) {
val lastCfiPath = lastReadCfi.split(":")[0] val resumeIdx = findTtsChunkResumeIndex(
val resumeIdx = nativeChunks.indexOfLast { it.sourceCfi.split(":")[0] == lastCfiPath } chunks = nativeChunks,
sourceCfi = lastReadCfi,
startOffsetInSource = currentState.startOffsetInSource,
currentText = currentState.currentText,
currentChunkIndexFallback = currentState.currentChunkIndex
)
if (resumeIdx != -1 && resumeIdx + 1 < nativeChunks.size) { if (resumeIdx != null && resumeIdx + 1 < nativeChunks.size) {
val remainingChunks = nativeChunks.subList(resumeIdx + 1, nativeChunks.size) val startChunkIndex = resumeIdx + 1
val token = getAuthToken() val token = getAuthToken()
ttsController.start( ttsController.start(
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId), chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
bookTitle = epubBookTitle, bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title, chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() }, coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
bookId = ttsReplacementBookId,
chapterIndex = currentTtsChapterIndex, chapterIndex = currentTtsChapterIndex,
totalChapters = chapters.size, totalChapters = chapters.size,
startChunkIndex = startChunkIndex,
continueSession = true, continueSession = true,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER", playbackSource = "READER",
@ -371,6 +378,7 @@ private fun handleVerticalAutoAdvance(
bookTitle = epubBookTitle, bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(nextIdx)?.title, chapterTitle = chapters.getOrNull(nextIdx)?.title,
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() }, coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
bookId = ttsReplacementBookId,
chapterIndex = nextIdx, chapterIndex = nextIdx,
totalChapters = chapters.size, totalChapters = chapters.size,
continueSession = true, continueSession = true,
@ -450,6 +458,7 @@ private fun handlePaginatedAutoAdvance(
bookTitle = epubBookTitle, bookTitle = epubBookTitle,
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
bookId = ttsReplacementBookId,
chapterIndex = chapterToTry, chapterIndex = chapterToTry,
totalChapters = chapters.size, totalChapters = chapters.size,
continueSession = true, continueSession = true,

View file

@ -0,0 +1,105 @@
package com.aryan.reader.epubreader
import com.aryan.reader.paginatedreader.CfiUtils
import com.aryan.reader.paginatedreader.TtsChunk
import kotlin.math.abs
private val TTS_WHITESPACE = Regex("\\s+")
internal fun sameTtsChunkSource(first: String, second: String): Boolean {
if (first.isBlank() || second.isBlank()) return first == second
val firstPath = CfiUtils.getPath(first)
val secondPath = CfiUtils.getPath(second)
return firstPath == secondPath || cfiPathContains(firstPath, secondPath) || cfiPathContains(secondPath, firstPath)
}
internal fun findTtsChunkStartIndex(
chunks: List<TtsChunk>,
target: TtsChunk?
): Int? {
if (target == null) return null
val exactIndex = chunks.indexOfFirst {
sameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
it.startOffsetInSource == target.startOffsetInSource &&
normalizedTtsText(it.text) == normalizedTtsText(target.text)
}
if (exactIndex >= 0) return exactIndex
val sourceAndOffsetIndex = chunks.indexOfFirst {
sameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
target.startOffsetInSource >= it.startOffsetInSource &&
target.startOffsetInSource < it.startOffsetInSource + it.text.length
}
if (sourceAndOffsetIndex >= 0) return sourceAndOffsetIndex
val sourceAndTextIndex = chunks.indexOfFirst {
sameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
ttsTextMatches(it.text, target.text)
}
if (sourceAndTextIndex >= 0) return sourceAndTextIndex
val sourceNearestOffsetIndex = chunks
.mapIndexedNotNull { index, chunk ->
if (sameTtsChunkSource(chunk.sourceCfi, target.sourceCfi)) {
index to abs(chunk.startOffsetInSource - target.startOffsetInSource)
} else {
null
}
}
.minByOrNull { it.second }
?.first
if (sourceNearestOffsetIndex != null) return sourceNearestOffsetIndex
return findUniqueTextMatch(chunks, target.text)
}
internal fun findTtsChunkResumeIndex(
chunks: List<TtsChunk>,
sourceCfi: String?,
startOffsetInSource: Int,
currentText: String?,
currentChunkIndexFallback: Int
): Int? {
val target = sourceCfi
?.takeIf { it.isNotBlank() }
?.let {
TtsChunk(
text = currentText.orEmpty(),
sourceCfi = it,
startOffsetInSource = startOffsetInSource.coerceAtLeast(0)
)
}
val matchedIndex = findTtsChunkStartIndex(chunks, target)
?: currentText?.let { findUniqueTextMatch(chunks, it) }
if (matchedIndex != null) return matchedIndex
return currentChunkIndexFallback.takeIf { it in chunks.indices }
}
private fun cfiPathContains(parentPath: String, childPath: String): Boolean {
if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false
val parentParts = parentPath.split('/').filter { it.isNotEmpty() }
val childParts = childPath.split('/').filter { it.isNotEmpty() }
return parentParts.size < childParts.size && childParts.take(parentParts.size) == parentParts
}
private fun normalizedTtsText(text: String): String =
text.replace(TTS_WHITESPACE, " ").trim()
private fun ttsTextMatches(first: String, second: String): Boolean {
val firstNormalized = normalizedTtsText(first)
val secondNormalized = normalizedTtsText(second)
if (firstNormalized.isBlank() || secondNormalized.isBlank()) return false
return firstNormalized == secondNormalized ||
firstNormalized.startsWith(secondNormalized) ||
secondNormalized.startsWith(firstNormalized)
}
private fun findUniqueTextMatch(chunks: List<TtsChunk>, text: String): Int? {
val matches = chunks.mapIndexedNotNull { index, chunk ->
index.takeIf { ttsTextMatches(chunk.text, text) }
}
return matches.singleOrNull()
}

View file

@ -38,6 +38,20 @@ import org.json.JSONObject
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM } enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
internal fun readWebViewHitTestTypeOrNull(hitTestTypeProvider: () -> Int?): Int? {
return try {
hitTestTypeProvider()
} catch (e: NullPointerException) {
Timber.w(e, "WebView hit test state was unavailable for tap.")
null
}
}
internal fun isWebViewAnchorHitTestType(type: Int?): Boolean {
return type == WebView.HitTestResult.SRC_ANCHOR_TYPE ||
type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE
}
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
class InteractiveWebView( class InteractiveWebView(
context: Context, context: Context,
@ -376,10 +390,11 @@ class InteractiveWebView(
override fun onSingleTapConfirmed(e: MotionEvent): Boolean { override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
Timber.d("onSingleTapConfirmed") Timber.d("onSingleTapConfirmed")
val hitTestResult = this@InteractiveWebView.hitTestResult val type = readWebViewHitTestTypeOrNull {
val type = hitTestResult.type this@InteractiveWebView.hitTestResult?.type
}
if (type == HitTestResult.SRC_ANCHOR_TYPE || type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE) { if (isWebViewAnchorHitTestType(type)) {
Timber.d("Tap was on a link. Consuming tap, not toggling app bars.") Timber.d("Tap was on a link. Consuming tap, not toggling app bars.")
return true return true
} }

View file

@ -6,4 +6,5 @@ typealias OpdsFeed = com.aryan.reader.shared.opds.OpdsFeed
typealias OpdsAuthor = com.aryan.reader.shared.opds.OpdsAuthor typealias OpdsAuthor = com.aryan.reader.shared.opds.OpdsAuthor
typealias OpdsAcquisition = com.aryan.reader.shared.opds.OpdsAcquisition typealias OpdsAcquisition = com.aryan.reader.shared.opds.OpdsAcquisition
typealias OpdsEntry = com.aryan.reader.shared.opds.OpdsEntry typealias OpdsEntry = com.aryan.reader.shared.opds.OpdsEntry
typealias OpdsDownloadState = com.aryan.reader.shared.opds.SharedOpdsDownloadState
typealias OpdsScreenState = com.aryan.reader.shared.opds.SharedOpdsScreenState typealias OpdsScreenState = com.aryan.reader.shared.opds.SharedOpdsScreenState

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import com.aryan.reader.shared.opds.SharedOpdsCatalogs import com.aryan.reader.shared.opds.SharedOpdsCatalogs
import com.aryan.reader.shared.opds.SharedOpdsRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -12,7 +13,7 @@ import timber.log.Timber
import java.security.MessageDigest import java.security.MessageDigest
import java.util.UUID import java.util.UUID
class OpdsRepository(context: Context) { class OpdsRepository(context: Context) : SharedOpdsRepository {
private val prefs: SharedPreferences = context.getSharedPreferences("reader_opds_prefs", Context.MODE_PRIVATE) private val prefs: SharedPreferences = context.getSharedPreferences("reader_opds_prefs", Context.MODE_PRIVATE)
private val parser = OpdsParser() private val parser = OpdsParser()
@ -34,7 +35,7 @@ class OpdsRepository(context: Context) {
private val httpClient = sharedHttpClient private val httpClient = sharedHttpClient
fun getCatalogs(): List<OpdsCatalog> { override fun loadCatalogs(): List<OpdsCatalog> {
val jsonString = prefs.getString(KEY_CATALOGS_JSON, null) val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString) val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString)
val catalogs = decodedCatalogs.ifEmpty { val catalogs = decodedCatalogs.ifEmpty {
@ -46,10 +47,12 @@ class OpdsRepository(context: Context) {
return catalogs return catalogs
} }
suspend fun getSearchTemplate( fun getCatalogs(): List<OpdsCatalog> = loadCatalogs()
override suspend fun getSearchTemplate(
openSearchUrl: String, openSearchUrl: String,
username: String? = null, username: String?,
password: String? = null password: String?
): String? = withContext(Dispatchers.IO) { ): String? = withContext(Dispatchers.IO) {
try { try {
val request = Request.Builder().url(openSearchUrl).build() val request = Request.Builder().url(openSearchUrl).build()
@ -65,7 +68,7 @@ class OpdsRepository(context: Context) {
fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) { fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
saveCatalogs( saveCatalogs(
SharedOpdsCatalogs.addCatalog( SharedOpdsCatalogs.addCatalog(
catalogs = getCatalogs(), catalogs = loadCatalogs(),
title = title, title = title,
url = url, url = url,
username = username, username = username,
@ -76,14 +79,14 @@ class OpdsRepository(context: Context) {
} }
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) { fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
saveCatalogs(SharedOpdsCatalogs.updateCatalog(getCatalogs(), id, title, url, username, password)) saveCatalogs(SharedOpdsCatalogs.updateCatalog(loadCatalogs(), id, title, url, username, password))
} }
fun removeCatalog(id: String) { fun removeCatalog(id: String) {
saveCatalogs(SharedOpdsCatalogs.removeCatalog(getCatalogs(), id)) saveCatalogs(SharedOpdsCatalogs.removeCatalog(loadCatalogs(), id))
} }
private fun saveCatalogs(catalogs: List<OpdsCatalog>) { override fun saveCatalogs(catalogs: List<OpdsCatalog>) {
prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) } prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) }
} }
@ -166,7 +169,7 @@ class OpdsRepository(context: Context) {
} }
suspend fun fetchFeed(url: String, username: String? = null, password: String? = null): Result<OpdsFeed> = withContext(Dispatchers.IO) { override suspend fun fetchFeed(url: String, username: String?, password: String?): Result<OpdsFeed> = withContext(Dispatchers.IO) {
Timber.tag("OpdsDebug").d("Starting fetch for URL: $url") Timber.tag("OpdsDebug").d("Starting fetch for URL: $url")
try { try {
val client = getAuthenticatedClient(username, password) val client = getAuthenticatedClient(username, password)

View file

@ -6,135 +6,106 @@ import android.net.Uri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.shared.opds.SharedOpdsController
import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer
import com.aryan.reader.shared.opds.SharedOpdsSearch
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Response
import okhttp3.Request import okhttp3.Request
import okhttp3.Response
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.UUID
class OpdsViewModel(application: Application) : AndroidViewModel(application) { class OpdsViewModel(application: Application) : AndroidViewModel(application) {
private val repository = OpdsRepository(application) private val repository = OpdsRepository(application)
private val controller = SharedOpdsController(
repository = repository,
feedLoadErrorMessage = { error ->
application.getString(R.string.opds_error_load_feed, error.message.orEmpty())
},
idFactory = { UUID.randomUUID().toString() }
)
private val _uiState = MutableStateFlow(OpdsScreenState()) private val _uiState = MutableStateFlow(controller.state)
val uiState: StateFlow<OpdsScreenState> = _uiState.asStateFlow() val uiState: StateFlow<OpdsScreenState> = _uiState.asStateFlow()
private val urlStack = mutableListOf<String>()
private val _downloadingEntries = MutableStateFlow<Set<String>>(emptySet())
val downloadingEntries: StateFlow<Set<String>> = _downloadingEntries.asStateFlow()
private fun fetchUrl(url: String, isPagination: Boolean = false) {
viewModelScope.launch {
val catalog = _uiState.value.currentCatalog
_uiState.update { it.copy(isLoading = true, errorMessage = null, isViewingCatalog = true) }
val result = repository.fetchFeed(url, catalog?.username, catalog?.password)
result.onSuccess { newFeed ->
val template = newFeed.searchUrl ?: _uiState.value.searchUrlTemplate
if (!isPagination) {
if (urlStack.isEmpty() || urlStack.last() != url) {
urlStack.add(url)
}
_uiState.update { it.copy(isLoading = false, currentFeed = newFeed, searchUrlTemplate = template) }
} else {
_uiState.update { state ->
val currentEntries = state.currentFeed?.entries ?: emptyList()
state.copy(
isLoading = false,
currentFeed = newFeed.copy(entries = currentEntries + newFeed.entries),
searchUrlTemplate = template
)
}
}
}.onFailure { e ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = getApplication<Application>().getString(R.string.opds_error_load_feed, e.message.orEmpty())
)
}
}
}
}
fun loadNextPage() { fun loadNextPage() {
val nextUrl = _uiState.value.currentFeed?.nextUrl viewModelScope.launch {
if (nextUrl != null && !_uiState.value.isLoading) { controller.loadNextPage(::emitState)
fetchUrl(nextUrl, isPagination = true)
} }
} }
data class DownloadState(val isDownloading: Boolean, val progress: Float? = null)
private val _downloadingState = MutableStateFlow<Map<String, DownloadState>>(emptyMap())
val downloadingState: StateFlow<Map<String, DownloadState>> = _downloadingState.asStateFlow()
fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) { fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) {
val downloadUrl = acquisition.url val downloadUrl = acquisition.url
val catalog = _uiState.value.currentCatalog val catalog = _uiState.value.currentCatalog
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch {
_downloadingState.update { it + (entry.id to DownloadState(true, 0f)) } updateDownloadState(entry.id, OpdsDownloadState(isDownloading = true, progress = 0f))
try { try {
val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password) val tempFile = withContext(Dispatchers.IO) {
val request = Request.Builder().url(downloadUrl).build() val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password)
val request = Request.Builder().url(downloadUrl).build()
val response = client.newCall(request).execute() client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw OpdsDownloadFailedException(
context.getString(R.string.opds_error_download_failed, response.message)
)
}
if (response.isSuccessful) { val body = response.body
val body = response.body ?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response))
?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response)) val contentLength = body.contentLength()
val contentLength = body.contentLength() val ext = resolveOpdsDownloadExtension(acquisition, response)
val safeTitle = SharedOpdsDownloadNamer.safeFileStem(entry.title).take(50)
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
val ext = resolveOpdsDownloadExtension(acquisition, response) body.byteStream().use { input ->
tempFile.outputStream().use { output ->
val buffer = ByteArray(8 * 1024)
var totalRead = 0L
var lastProgressUpdate = System.currentTimeMillis()
val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50) while (true) {
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext") val bytesRead = input.read(buffer)
if (bytesRead == -1) break
output.write(buffer, 0, bytesRead)
totalRead += bytesRead
val input = body.byteStream() if (contentLength > 0) {
val output = tempFile.outputStream() val now = System.currentTimeMillis()
val buffer = ByteArray(8 * 1024) if (now - lastProgressUpdate > 200) {
var bytesRead: Int val progress = (totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f)
var totalRead = 0L withContext(Dispatchers.Main) {
var lastProgressUpdate = System.currentTimeMillis() updateDownloadState(
entry.id,
input.use { inp -> OpdsDownloadState(isDownloading = true, progress = progress)
output.use { out -> )
while (inp.read(buffer).also { bytesRead = it } != -1) { }
out.write(buffer, 0, bytesRead) lastProgressUpdate = now
totalRead += bytesRead }
if (contentLength > 0) {
val now = System.currentTimeMillis()
// Throttle UI updates to 4-5 fps
if (now - lastProgressUpdate > 200) {
val progress = totalRead.toFloat() / contentLength.toFloat()
_downloadingState.update { it + (entry.id to DownloadState(true, progress)) }
lastProgressUpdate = now
} }
} }
} }
} }
tempFile
} }
withContext(Dispatchers.Main) {
onDownloaded(Uri.fromFile(tempFile))
}
} else {
Timber.e("Download failed: ${response.code}")
_uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_failed, response.message)) }
} }
onDownloaded(Uri.fromFile(tempFile))
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Download error") Timber.e(e, "Download error")
_uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_error, e.message.orEmpty())) } val message = if (e is OpdsDownloadFailedException) {
e.message.orEmpty()
} else {
context.getString(R.string.opds_error_download_error, e.message.orEmpty())
}
emitState(controller.setErrorMessage(message))
} finally { } finally {
_downloadingState.update { it - entry.id } updateDownloadState(entry.id, null)
} }
} }
} }
@ -147,69 +118,55 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
) )
} }
init {
loadCatalogs()
}
private fun loadCatalogs() {
_uiState.update { it.copy(catalogs = repository.getCatalogs()) }
}
fun addCatalog(title: String, url: String, username: String?, password: String?) { fun addCatalog(title: String, url: String, username: String?, password: String?) {
repository.addCatalog(title, url, username, password) emitState(controller.addCatalog(title, url, username, password))
loadCatalogs()
} }
fun removeCatalog(id: String) { fun removeCatalog(id: String) {
repository.removeCatalog(id) emitState(controller.removeCatalog(id))
loadCatalogs()
} }
fun openCatalog(catalog: OpdsCatalog) { fun openCatalog(catalog: OpdsCatalog) {
urlStack.clear() viewModelScope.launch {
_uiState.update { it.copy(searchUrlTemplate = null, currentCatalog = catalog) } controller.openCatalog(catalog, ::emitState)
fetchUrl(catalog.url)
}
fun openFeedUrl(url: String) {
fetchUrl(url)
}
fun navigateBack(): Boolean {
if (urlStack.size > 1) {
urlStack.removeAt(urlStack.lastIndex)
val previousUrl = urlStack.last()
urlStack.removeAt(urlStack.lastIndex)
fetchUrl(previousUrl)
return true
} else {
urlStack.clear()
_uiState.update { it.copy(isViewingCatalog = false, currentFeed = null, searchUrlTemplate = null, currentCatalog = null) }
return false
} }
} }
fun openFeedUrl(url: String) {
viewModelScope.launch {
controller.openFeedUrl(url, ::emitState)
}
}
fun navigateBack(): Boolean {
val returnsToPreviousFeed = controller.hasFeedHistory()
viewModelScope.launch {
controller.navigateBack(::emitState)
}
return returnsToPreviousFeed
}
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) { fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
repository.updateCatalog(id, title, url, username, password) emitState(controller.updateCatalog(id, title, url, username, password))
loadCatalogs()
} }
fun search(query: String) { fun search(query: String) {
val searchLink = _uiState.value.searchUrlTemplate ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } controller.search(query, ::emitState)
val finalUrl = SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl ->
val catalog = _uiState.value.currentCatalog
repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password)
}
openFeedUrl(finalUrl)
} }
} }
fun clearError() { fun clearError() {
_uiState.update { it.copy(errorMessage = null) } emitState(controller.clearError())
} }
private fun updateDownloadState(entryId: String, downloadState: OpdsDownloadState?) {
emitState(controller.updateDownloadState(entryId, downloadState))
}
private fun emitState(state: OpdsScreenState) {
_uiState.value = state
}
private class OpdsDownloadFailedException(message: String) : Exception(message)
} }

View file

@ -38,6 +38,7 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.epub.EpubChapter import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.paginatedreader.data.BookCacheDao import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.BookProcessingInput import com.aryan.reader.paginatedreader.data.BookProcessingInput
import com.aryan.reader.paginatedreader.data.BookProcessingWorker import com.aryan.reader.paginatedreader.data.BookProcessingWorker
@ -103,6 +104,14 @@ private data class PaginationRequest(val chapterIndex: Int, val priority: Int) :
private const val PAGE_INDEX_ANCHOR_SEPARATOR = "\u001F" private const val PAGE_INDEX_ANCHOR_SEPARATOR = "\u001F"
private fun String.normalizedImageSourceForNavigation(): String {
return substringBefore('#')
.substringBefore('?')
.replace('\\', '/')
.removePrefix("file://")
.lowercase()
}
private data class TextRangeIndex( private data class TextRangeIndex(
val pageInChapter: Int, val pageInChapter: Int,
val blockIndex: Int, val blockIndex: Int,
@ -394,7 +403,7 @@ class BookPaginator(
} }
private fun chapterContentVersion(chapter: EpubChapter): Int { private fun chapterContentVersion(chapter: EpubChapter): Int {
val backingFile = java.io.File(extractionBasePath, chapter.htmlFilePath) val backingFile = java.io.File(extractionBasePath, chapter.contentFilePath())
return buildString { return buildString {
append(chapter.absPath) append(chapter.absPath)
append('|') append('|')
@ -656,6 +665,43 @@ class BookPaginator(
finalPageIndex finalPageIndex
} }
suspend fun findStablePageForImageSource(
chapterIndex: Int,
sourcePath: String,
elementId: String?,
ordinalInChapter: Int
): Pair<Int, Locator>? = withContext(Dispatchers.IO) {
val chapter = chapters.getOrNull(chapterIndex) ?: return@withContext null
val imageBlocks = getAllBlocks(getBlocksForChapter(chapter, chapterIndex))
.filterIsInstance<ImageBlock>()
if (imageBlocks.isEmpty()) return@withContext null
val normalizedSource = sourcePath.normalizedImageSourceForNavigation()
val normalizedFileName = normalizedSource.substringAfterLast('/')
val matchingBySource = imageBlocks.filter { block ->
val normalizedBlockPath = block.path.normalizedImageSourceForNavigation()
normalizedBlockPath == normalizedSource ||
normalizedBlockPath.endsWith("/$normalizedFileName") ||
normalizedSource.endsWith("/${normalizedBlockPath.substringAfterLast('/')}")
}
val targetBlock = elementId
?.takeIf { it.isNotBlank() }
?.let { id -> imageBlocks.firstOrNull { it.elementId == id } }
?: matchingBySource.getOrNull(ordinalInChapter.coerceAtLeast(0))
?: matchingBySource.firstOrNull()
?: return@withContext null
val locator = Locator(
chapterIndex = chapterIndex,
blockIndex = targetBlock.blockIndex,
charOffset = 0
)
val targetPage = findStablePageForLocator(locator)
?: findStableChapterStartPage(chapterIndex)
?: return@withContext null
targetPage to locator
}
suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? { suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? {
val pages = ensureChapterPaginated(chapterIndex) val pages = ensureChapterPaginated(chapterIndex)
if (pages.isNullOrEmpty()) { if (pages.isNullOrEmpty()) {
@ -766,7 +812,7 @@ class BookPaginator(
var shouldIgnoreCache = false var shouldIgnoreCache = false
if (isCacheEmpty && isLazyChapter) { if (isCacheEmpty && isLazyChapter) {
val file = java.io.File(extractionBasePath, chapter.htmlFilePath) val file = java.io.File(extractionBasePath, chapter.contentFilePath())
if (file.exists() && file.length() > 0) { if (file.exists() && file.length() > 0) {
Timber.tag("ReflowPaginationDiag").w("getBlocksForChapter: Cache HIT but empty for lazy chapter $chapterIndex. Backing file exists (${file.length()} bytes). Ignoring cache.") Timber.tag("ReflowPaginationDiag").w("getBlocksForChapter: Cache HIT but empty for lazy chapter $chapterIndex. Backing file exists (${file.length()} bytes). Ignoring cache.")
shouldIgnoreCache = true shouldIgnoreCache = true
@ -789,7 +835,7 @@ class BookPaginator(
var htmlToParse = chapter.htmlContent var htmlToParse = chapter.htmlContent
if (htmlToParse.isEmpty()) { if (htmlToParse.isEmpty()) {
val file = java.io.File(extractionBasePath, chapter.htmlFilePath) val file = java.io.File(extractionBasePath, chapter.contentFilePath())
if (file.exists()) { if (file.exists()) {
Timber.tag("ReflowPaginationDiag").d("getBlocksForChapter: Lazy loading content from disk for chapter $chapterIndex: ${file.name} (${file.length()} bytes)") Timber.tag("ReflowPaginationDiag").d("getBlocksForChapter: Lazy loading content from disk for chapter $chapterIndex: ${file.name} (${file.length()} bytes)")
try { try {
@ -1187,7 +1233,7 @@ class BookPaginator(
Timber.tag("POS_DIAG").d("getPlainTextForChapter: chapterIndex=$chapterIndex, chapterTitle='${chapter.title}', hasInMemoryContent=${chapter.htmlContent.isNotEmpty()}") Timber.tag("POS_DIAG").d("getPlainTextForChapter: chapterIndex=$chapterIndex, chapterTitle='${chapter.title}', hasInMemoryContent=${chapter.htmlContent.isNotEmpty()}")
val htmlToParse = chapter.htmlContent.ifEmpty { val htmlToParse = chapter.htmlContent.ifEmpty {
try { try {
val file = java.io.File(extractionBasePath, chapter.htmlFilePath) val file = java.io.File(extractionBasePath, chapter.contentFilePath())
if (file.exists()) file.readText() else "" if (file.exists()) file.readText() else ""
} catch (_: Exception) { } catch (_: Exception) {
"" ""

View file

@ -25,6 +25,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.paginatedreader.data.BookCacheDao import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.ProcessedChapter import com.aryan.reader.paginatedreader.data.ProcessedChapter
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -67,7 +68,7 @@ class LocatorConverter(
val htmlToParse = chapter.htmlContent.ifBlank { val htmlToParse = chapter.htmlContent.ifBlank {
try { try {
val file = File(book.extractionBasePath, chapter.htmlFilePath) val file = File(book.extractionBasePath, chapter.contentFilePath())
if (file.exists()) { if (file.exists()) {
val content = file.readText() val content = file.readText()
content content

View file

@ -34,6 +34,7 @@ import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.aryan.reader.epub.epubContentFilePath
import com.aryan.reader.paginatedreader.CssParser import com.aryan.reader.paginatedreader.CssParser
import com.aryan.reader.paginatedreader.FontFaceInfo import com.aryan.reader.paginatedreader.FontFaceInfo
import com.aryan.reader.paginatedreader.MathMLRenderer import com.aryan.reader.paginatedreader.MathMLRenderer
@ -246,7 +247,7 @@ class BookProcessingWorker(
if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) { if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) {
Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}") Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}")
val htmlToParse = chapter.htmlContent.ifBlank { val htmlToParse = chapter.htmlContent.ifBlank {
val backingFile = File(extractionBasePath, chapter.htmlFilePath) val backingFile = File(extractionBasePath, epubContentFilePath(chapter.htmlFilePath))
if (backingFile.exists()) { if (backingFile.exists()) {
backingFile.readText() backingFile.readText()
} else { } else {

View file

@ -42,6 +42,8 @@ object NativePdfiumBridge {
inkPointOffsets: IntArray, inkPointOffsets: IntArray,
inkPointCounts: IntArray, inkPointCounts: IntArray,
inkPoints: FloatArray, inkPoints: FloatArray,
inkNames: Array<String>,
inkContents: Array<String>,
textPageIndices: IntArray, textPageIndices: IntArray,
textBounds: FloatArray, textBounds: FloatArray,
textColors: IntArray, textColors: IntArray,
@ -62,7 +64,16 @@ object NativePdfiumBridge {
highlightRectOffsets: IntArray, highlightRectOffsets: IntArray,
highlightRectCounts: IntArray, highlightRectCounts: IntArray,
highlightRects: FloatArray, highlightRects: FloatArray,
highlightContents: Array<String> highlightNames: Array<String>,
highlightContents: Array<String>,
highlightCommentOffsets: IntArray,
highlightCommentCounts: IntArray,
highlightCommentParentIndices: IntArray,
highlightCommentNames: Array<String>,
highlightCommentAuthors: Array<String>,
highlightCommentContents: Array<String>,
highlightCommentCreatedDates: Array<String>,
highlightCommentModifiedDates: Array<String>
): Boolean ): Boolean
const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT

View file

@ -0,0 +1,11 @@
package com.aryan.reader.pdf
enum class AnnotationType {
INK, TEXT
}
enum class InkType {
PEN, HIGHLIGHTER, HIGHLIGHTER_ROUND, ERASER, FOUNTAIN_PEN, PENCIL, TEXT
}
data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)

View file

@ -0,0 +1,111 @@
package com.aryan.reader.pdf
import android.graphics.Bitmap
import android.graphics.RectF
import androidx.core.graphics.createBitmap
import com.aryan.reader.ml.SpeechBubble
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sqrt
import android.graphics.Color as AndroidColor
internal data class ExpandedBubbleRender(
val bitmap: Bitmap,
val zoomFactor: Float
)
internal fun computeDynamicBubbleZoomFactor(
bubbleBounds: RectF,
viewportWidth: Float,
viewportHeight: Float
): Float {
if (bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) return 1.5f
val targetWidth = viewportWidth * 0.6f
val targetHeight = viewportHeight * 0.32f
return min(targetWidth / bubbleBounds.width(), targetHeight / bubbleBounds.height())
.coerceIn(1.35f, 4.25f)
}
internal fun isTapInsideBubble(
bubble: SpeechBubble,
tapX: Float,
tapY: Float,
hitSlopPx: Float
): Boolean {
val expandedBounds = RectF(bubble.bounds)
expandedBounds.inset(-hitSlopPx, -hitSlopPx)
if (!expandedBounds.contains(tapX, tapY)) return false
val mask = bubble.maskBitmap ?: return true
if (!bubble.bounds.contains(tapX, tapY)) return true
val normalizedX = ((tapX - bubble.bounds.left) / bubble.bounds.width()).coerceIn(0f, 0.999f)
val normalizedY = ((tapY - bubble.bounds.top) / bubble.bounds.height()).coerceIn(0f, 0.999f)
val maskX = (normalizedX * mask.width).toInt().coerceIn(0, mask.width - 1)
val maskY = (normalizedY * mask.height).toInt().coerceIn(0, mask.height - 1)
return AndroidColor.alpha(mask.getPixel(maskX, maskY)) > 24
}
internal suspend fun renderExpandedBubbleBitmap(
document: ReaderDocument,
pageIndex: Int,
bubbleBounds: RectF,
pageWidth: Int,
pageHeight: Int,
renderScale: Float
): Bitmap? = withContext(Dispatchers.IO) {
if (pageWidth <= 0 || pageHeight <= 0 || bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) {
return@withContext null
}
document.openPage(pageIndex)?.use { page ->
val safeRenderScale = safePdfBitmapRenderScale(
contentWidth = bubbleBounds.width(),
contentHeight = bubbleBounds.height(),
requestedScale = renderScale
)
val cropWidth = (bubbleBounds.width() * safeRenderScale).roundToInt().coerceAtLeast(1)
val cropHeight = (bubbleBounds.height() * safeRenderScale).roundToInt().coerceAtLeast(1)
val bitmap = createBitmap(cropWidth, cropHeight)
try {
page.renderPageBitmap(
bitmap = bitmap,
startX = (-bubbleBounds.left * safeRenderScale).roundToInt(),
startY = (-bubbleBounds.top * safeRenderScale).roundToInt(),
drawSizeX = (pageWidth * safeRenderScale).roundToInt().coerceAtLeast(cropWidth),
drawSizeY = (pageHeight * safeRenderScale).roundToInt().coerceAtLeast(cropHeight),
renderAnnot = true
)
bitmap
} catch (t: Throwable) {
bitmap.recycle()
Timber.tag("BubbleZoom").w(t, "Failed to render expanded bubble bitmap for page $pageIndex")
null
}
}
}
internal fun safePdfBitmapRenderScale(
contentWidth: Float,
contentHeight: Float,
requestedScale: Float
): Float {
if (contentWidth <= 0f || contentHeight <= 0f || requestedScale <= 0f) return 1f
val requestedWidth = contentWidth * requestedScale
val requestedHeight = contentHeight * requestedScale
val requestedBytes = requestedWidth.toDouble() * requestedHeight.toDouble() * 4.0
val byteScale = sqrt(PDF_MAX_DRAW_BITMAP_BYTES.toDouble() / requestedBytes.coerceAtLeast(1.0))
val dimensionScale = PDF_MAX_DRAW_BITMAP_DIMENSION_PX.toDouble() /
max(requestedWidth, requestedHeight).toDouble().coerceAtLeast(1.0)
val limiter = min(1.0, min(byteScale, dimensionScale)).coerceAtLeast(0.01)
return (requestedScale.toDouble() * limiter).coerceAtLeast(0.01).toFloat()
}
internal const val PDF_MAX_DRAW_BITMAP_BYTES = 64L * 1024L * 1024L
internal const val PDF_MAX_DRAW_BITMAP_DIMENSION_PX = 4096

View file

@ -24,7 +24,6 @@ import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import timber.log.Timber import timber.log.Timber
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -32,7 +31,6 @@ private const val TAG = "PdfCoverGenerator"
class PdfCoverGenerator(context: Context) { class PdfCoverGenerator(context: Context) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val pdfiumCore = PdfiumCoreKt(Dispatchers.IO)
/** /**
* Generates a Bitmap cover for the first page of a PDF. * Generates a Bitmap cover for the first page of a PDF.
@ -48,37 +46,40 @@ class PdfCoverGenerator(context: Context) {
appContext.contentResolver.openFileDescriptor(pdfUri, "r").use { pfd -> appContext.contentResolver.openFileDescriptor(pdfUri, "r").use { pfd ->
if (pfd == null) { if (pfd == null) {
Timber.e("Failed to open ParcelFileDescriptor for URI: $pdfUri") Timber.e("Failed to open ParcelFileDescriptor for URI: $pdfUri")
return@withContext null null
} } else {
pdfiumCore.newDocument(pfd).use { doc -> PdfiumEngineProvider.withPdfium {
if (doc.getPageCount() == 0) { PdfiumCoreProvider.core.newDocument(pfd).use { doc ->
Timber.w("PDF has no pages, cannot generate cover: $pdfUri") if (doc.getPageCount() == 0) {
return@withContext null Timber.w("PDF has no pages, cannot generate cover: $pdfUri")
} return@withPdfium null
doc.openPage(0)?.use { page -> }
val originalWidth = page.getPageWidthPoint() doc.openPage(0)?.use { page ->
val originalHeight = page.getPageHeightPoint() val originalWidth = page.getPageWidthPoint()
if (originalWidth <= 0 || originalHeight <= 0) { val originalHeight = page.getPageHeightPoint()
Timber.e("Invalid page dimensions for cover: $pdfUri") if (originalWidth <= 0 || originalHeight <= 0) {
return@withContext null Timber.e("Invalid page dimensions for cover: $pdfUri")
return@withPdfium null
}
val aspectRatio = originalWidth.toFloat() / originalHeight.toFloat()
val targetWidth = (targetHeight * aspectRatio).toInt()
if (targetWidth <= 0) {
Timber.e("Calculated invalid bitmap width for cover: $targetWidth")
return@withPdfium null
}
val bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(
bitmap = bitmap,
startX = 0, startY = 0,
drawSizeX = targetWidth, drawSizeY = targetHeight,
renderAnnot = false
)
bitmap
}
} }
val aspectRatio = originalWidth.toFloat() / originalHeight.toFloat()
val targetWidth = (targetHeight * aspectRatio).toInt()
if (targetWidth <= 0) {
Timber.e("Calculated invalid bitmap width for cover: $targetWidth")
return@withContext null
}
val bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(
bitmap = bitmap,
startX = 0, startY = 0,
drawSizeX = targetWidth, drawSizeY = targetHeight,
renderAnnot = false
)
bitmap
} }
} }
} }

View file

@ -158,21 +158,40 @@ internal fun shouldShowPdfAnnotationExportChoice(
internal fun getFastFileId(context: Context, uri: Uri): String { internal fun getFastFileId(context: Context, uri: Uri): String {
var result = uri.toString() var result = uri.toString()
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"id.fast.start uri=$uri scheme=${uri.scheme}"
)
try { try {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> if (uri.scheme == "file") {
if (cursor.moveToFirst()) { uri.path?.let {
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) val file = java.io.File(it)
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) result = "${file.name}_${file.length()}"
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"id.fast.file uri=$uri path=${file.absolutePath} exists=${file.exists()} " +
"name=${file.name} size=${file.length()} mtime=${file.lastModified()} result=$result"
)
}
} else {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L val size = if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown" val name = if (nameIndex != -1) cursor.getString(nameIndex) else "unknown"
result = "${name}_${size}" result = "${name}_${size}"
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"id.fast.content uri=$uri name=$name size=$size result=$result"
)
}
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e(e, "id.fast.failed uri=$uri fallback=$result")
Timber.e(e, "Failed to generate fast file ID") Timber.e(e, "Failed to generate fast file ID")
} }
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i("id.fast.done uri=$uri result=$result")
return result return result
} }

View file

@ -48,6 +48,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -81,6 +82,7 @@ import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import timber.log.Timber import timber.log.Timber
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import com.aryan.reader.cardTitle
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.data.VirtualPage
@ -316,9 +318,12 @@ private fun PdfTabsDrawerPage(
activeTabBookId: String?, activeTabBookId: String?,
currentPage: Int, currentPage: Int,
totalPages: Int, totalPages: Int,
isTopTabStripVisible: Boolean,
onTabSelected: (String) -> Unit, onTabSelected: (String) -> Unit,
onTabClosed: (String) -> Unit, onTabClosed: (String) -> Unit,
onNewTabClick: () -> Unit onNewTabClick: () -> Unit,
onTopTabStripVisibilityChange: (Boolean) -> Unit,
usePdfFileNameAsDisplayName: Boolean
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Row( Row(
@ -357,6 +362,28 @@ private fun PdfTabsDrawerPage(
HorizontalDivider() HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onTopTabStripVisibilityChange(!isTopTabStripVisible) }
.padding(start = 16.dp, end = 12.dp, top = 10.dp, bottom = 10.dp)
.testTag("PdfTopTabStripVisibilityToggle"),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.pdf_tabs_show_top_app_bar_tabs),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Switch(
checked = isTopTabStripVisible,
onCheckedChange = null
)
}
HorizontalDivider()
if (openTabs.isEmpty()) { if (openTabs.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp),
@ -382,7 +409,8 @@ private fun PdfTabsDrawerPage(
currentPage = currentPage, currentPage = currentPage,
totalPages = totalPages, totalPages = totalPages,
onTabSelected = onTabSelected, onTabSelected = onTabSelected,
onTabClosed = onTabClosed onTabClosed = onTabClosed,
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
) )
} }
} }
@ -397,7 +425,8 @@ private fun PdfDrawerTabItem(
currentPage: Int, currentPage: Int,
totalPages: Int, totalPages: Int,
onTabSelected: (String) -> Unit, onTabSelected: (String) -> Unit,
onTabClosed: (String) -> Unit onTabClosed: (String) -> Unit,
usePdfFileNameAsDisplayName: Boolean
) { ) {
val shape = RoundedCornerShape(8.dp) val shape = RoundedCornerShape(8.dp)
val containerColor by animateColorAsState( val containerColor by animateColorAsState(
@ -485,7 +514,7 @@ private fun PdfDrawerTabItem(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = tab.customName ?: tab.title ?: tab.displayName, text = tab.cardTitle(usePdfFileNameAsDisplayName),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
color = contentColor, color = contentColor,
@ -542,11 +571,14 @@ internal fun PdfNavigationDrawerContent(
isTabsEnabled: Boolean = false, isTabsEnabled: Boolean = false,
openTabs: List<RecentFileItem> = emptyList(), openTabs: List<RecentFileItem> = emptyList(),
activeTabBookId: String? = null, activeTabBookId: String? = null,
usePdfFileNameAsDisplayName: Boolean = false,
isTopTabStripVisible: Boolean = true,
customHighlightColors: Map<PdfHighlightColor, Color>, customHighlightColors: Map<PdfHighlightColor, Color>,
onPageSelected: (Int) -> Unit, onPageSelected: (Int) -> Unit,
onTabSelected: (String) -> Unit = {}, onTabSelected: (String) -> Unit = {},
onTabClosed: (String) -> Unit = {}, onTabClosed: (String) -> Unit = {},
onNewTabClick: () -> Unit = {}, onNewTabClick: () -> Unit = {},
onTopTabStripVisibilityChange: (Boolean) -> Unit = {},
onRenameBookmark: (PdfBookmark, String) -> Unit, onRenameBookmark: (PdfBookmark, String) -> Unit,
onDeleteBookmark: (PdfBookmark) -> Unit, onDeleteBookmark: (PdfBookmark) -> Unit,
onDeleteHighlight: (PdfUserHighlight) -> Unit, onDeleteHighlight: (PdfUserHighlight) -> Unit,
@ -601,6 +633,7 @@ internal fun PdfNavigationDrawerContent(
activeTabBookId = activeTabBookId, activeTabBookId = activeTabBookId,
currentPage = currentPage, currentPage = currentPage,
totalPages = totalPages, totalPages = totalPages,
isTopTabStripVisible = isTopTabStripVisible,
onTabSelected = { bookId -> onTabSelected = { bookId ->
if (bookId == activeTabBookId) { if (bookId == activeTabBookId) {
onCloseDrawer() onCloseDrawer()
@ -610,7 +643,9 @@ internal fun PdfNavigationDrawerContent(
} }
}, },
onTabClosed = onTabClosed, onTabClosed = onTabClosed,
onNewTabClick = onNewTabClick onNewTabClick = onNewTabClick,
onTopTabStripVisibilityChange = onTopTabStripVisibilityChange,
usePdfFileNameAsDisplayName = usePdfFileNameAsDisplayName
) )
PdfDrawerSection.CHAPTERS -> { // Chapters Page PdfDrawerSection.CHAPTERS -> { // Chapters Page

View file

@ -0,0 +1,64 @@
package com.aryan.reader.pdf
import android.graphics.RectF
import timber.log.Timber
data class EmbeddedAnnotation(
val index: Int,
val subtype: Int,
val rect: RectF,
val contents: String?,
val author: String?,
val name: String?,
val inReplyTo: String?,
val replies: MutableList<EmbeddedAnnotation> = mutableListOf()
)
internal fun groupEmbeddedAnnotationsForDisplay(
annotations: List<EmbeddedAnnotation>
): List<EmbeddedAnnotation> {
if (annotations.isEmpty()) return emptyList()
val annotMap = annotations
.filter { !it.name.isNullOrBlank() }
.associateBy { it.name }
val orphans = mutableListOf<EmbeddedAnnotation>()
annotations.forEach { annot ->
if (!annot.inReplyTo.isNullOrBlank() && annotMap.containsKey(annot.inReplyTo)) {
Timber.tag("PdfCommentDebug").i("Linking: ${annot.name} is a reply to ${annot.inReplyTo}")
annotMap[annot.inReplyTo]?.replies?.add(annot)
} else {
orphans.add(annot)
}
}
Timber.tag("PdfCommentDebug").d("After ID linking: Orphans count = ${orphans.size}")
val groupedRoots = mutableListOf<MutableList<EmbeddedAnnotation>>()
orphans.forEach { annot ->
val match = groupedRoots.find { group ->
val root = group.first()
val inflatedRoot = RectF(root.rect).apply { inset(-10f, -10f) }
RectF.intersects(inflatedRoot, annot.rect)
}
if (match != null) {
Timber.tag("PdfCommentDebug").w(
"Geometric grouping triggered for ${annot.name} with ${match.first().name}. This might flatten nested replies!"
)
match.add(annot)
} else {
groupedRoots.add(mutableListOf(annot))
}
}
return groupedRoots.map { group ->
val root = group.first()
if (group.size > 1) {
root.replies.addAll(group.drop(1))
}
root
}.filter {
!it.contents.isNullOrBlank() || it.replies.any { reply -> !reply.contents.isNullOrBlank() }
}
}

View file

@ -96,7 +96,10 @@ import com.aryan.reader.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrLine import com.aryan.reader.pdf.ocr.OcrLine
import com.aryan.reader.pdf.ocr.OcrResult import com.aryan.reader.pdf.ocr.OcrResult
import com.aryan.reader.pdf.ocr.OcrSymbol import com.aryan.reader.pdf.ocr.OcrSymbol
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
import timber.log.Timber import timber.log.Timber
import java.text.DateFormat
import java.util.Date
import java.util.UUID import java.util.UUID
enum class OcrLanguage(@StringRes val displayNameRes: Int) { enum class OcrLanguage(@StringRes val displayNameRes: Int) {
@ -135,7 +138,8 @@ data class PdfUserHighlight(
val color: PdfHighlightColor, val color: PdfHighlightColor,
val text: String, val text: String,
val range: Pair<Int, Int>, val range: Pair<Int, Int>,
val note: String? = null val note: String? = null,
val comments: List<SharedPdfAnnotationComment> = emptyList()
) )
internal data class CustomPdfMenuState( internal data class CustomPdfMenuState(
@ -699,6 +703,13 @@ fun PdfHighlightColorRow(
} }
} }
private enum class PdfAnnotationSheetSection {
NOTE,
COMMENTS
}
private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader"
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PdfAnnotationBottomSheet( fun PdfAnnotationBottomSheet(
@ -709,7 +720,8 @@ fun PdfAnnotationBottomSheet(
onPaletteClick: (() -> Unit)? = null, onPaletteClick: (() -> Unit)? = null,
onColorChange: (PdfHighlightColor) -> Unit, onColorChange: (PdfHighlightColor) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (String) -> Unit, onSave: (String, List<SharedPdfAnnotationComment>) -> Unit,
onUpdate: (String, List<SharedPdfAnnotationComment>) -> Unit = { _, _ -> },
onDelete: () -> Unit, onDelete: () -> Unit,
onCopy: () -> Unit, onCopy: () -> Unit,
onDictionary: () -> Unit, onDictionary: () -> Unit,
@ -718,6 +730,24 @@ fun PdfAnnotationBottomSheet(
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var noteText by remember { mutableStateOf(highlight.note ?: "") } var noteText by remember { mutableStateOf(highlight.note ?: "") }
var comments by remember(highlight.id) { mutableStateOf(highlight.comments) }
var selectedSection by remember(highlight.id) { mutableStateOf(PdfAnnotationSheetSection.NOTE) }
var commentText by remember(highlight.id) { mutableStateOf("") }
var replyTargetId by remember(highlight.id) { mutableStateOf<String?>(null) }
var editingCommentId by remember(highlight.id) { mutableStateOf<String?>(null) }
var commentAuthor by remember(highlight.id) {
mutableStateOf(
highlight.comments
.lastOrNull { it.author.isNotBlank() }
?.author
?: DEFAULT_PDF_COMMENT_AUTHOR
)
}
fun persistComments(nextComments: List<SharedPdfAnnotationComment>) {
comments = nextComments
onUpdate(noteText, nextComments)
}
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@ -775,23 +805,98 @@ fun PdfAnnotationBottomSheet(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
OutlinedTextField( PdfAnnotationSheetTabs(
value = noteText, selectedSection = selectedSection,
onValueChange = { noteText = it }, commentCount = comments.count { it.contents.isNotBlank() },
placeholder = { Text(stringResource(R.string.placeholder_add_note), color = effectiveText.copy(alpha = 0.5f)) }, effectiveText = effectiveText,
modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp), onSectionChange = { selectedSection = it }
maxLines = 5,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = effectiveText.copy(alpha = 0.3f),
focusedTextColor = effectiveText,
unfocusedTextColor = effectiveText
),
shape = RoundedCornerShape(12.dp)
) )
Spacer(Modifier.height(12.dp))
if (selectedSection == PdfAnnotationSheetSection.NOTE) {
OutlinedTextField(
value = noteText,
onValueChange = { noteText = it },
placeholder = { Text(stringResource(R.string.placeholder_add_note), color = effectiveText.copy(alpha = 0.5f)) },
modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp),
maxLines = 5,
colors = pdfAnnotationTextFieldColors(effectiveText),
shape = RoundedCornerShape(12.dp)
)
} else {
PdfHighlightCommentsEditor(
comments = comments,
commentText = commentText,
commentAuthor = commentAuthor,
replyTargetId = replyTargetId,
editingCommentId = editingCommentId,
effectiveText = effectiveText,
onCommentTextChange = { commentText = it },
onCommentAuthorChange = { commentAuthor = it },
onReply = {
editingCommentId = null
replyTargetId = it.id
commentText = ""
},
onCancelReply = { replyTargetId = null },
onEdit = { comment ->
editingCommentId = comment.id
replyTargetId = null
commentText = comment.contents
commentAuthor = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
},
onCancelEdit = {
editingCommentId = null
commentText = ""
},
onDelete = { comment ->
val nextComments = comments.withoutCommentThread(comment.id)
persistComments(nextComments)
if (replyTargetId != null && (replyTargetId == comment.id || nextComments.none { it.id == replyTargetId })) {
replyTargetId = null
}
if (editingCommentId != null && (editingCommentId == comment.id || nextComments.none { it.id == editingCommentId })) {
editingCommentId = null
commentText = ""
}
},
onAddComment = {
val contents = commentText.trim()
if (contents.isNotBlank()) {
val now = System.currentTimeMillis()
val author = commentAuthor.trim().ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
val nextComments = if (editingCommentId != null) {
comments.map { comment ->
if (comment.id == editingCommentId) {
comment.copy(
author = author,
contents = contents,
modifiedAt = now
)
} else {
comment
}
}
} else {
comments + SharedPdfAnnotationComment(
id = UUID.randomUUID().toString(),
parentId = replyTargetId,
author = author,
contents = contents,
createdAt = now,
modifiedAt = now
)
}
persistComments(nextComments)
commentText = ""
replyTargetId = null
editingCommentId = null
}
}
)
}
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Row( Row(
@ -811,19 +916,326 @@ fun PdfAnnotationBottomSheet(
Text(stringResource(R.string.action_delete)) Text(stringResource(R.string.action_delete))
} }
Button( Button(
onClick = { onSave(noteText) }, onClick = { onSave(noteText, comments) },
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary contentColor = MaterialTheme.colorScheme.onPrimary
) )
) { ) {
Text(stringResource(R.string.action_save_note)) Text(stringResource(R.string.action_done))
} }
} }
} }
} }
} }
@Composable
private fun PdfAnnotationSheetTabs(
selectedSection: PdfAnnotationSheetSection,
commentCount: Int,
effectiveText: Color,
onSectionChange: (PdfAnnotationSheetSection) -> Unit
) {
Surface(
color = effectiveText.copy(alpha = 0.06f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.padding(4.dp)) {
PdfAnnotationSheetTab(
label = stringResource(R.string.label_note),
selected = selectedSection == PdfAnnotationSheetSection.NOTE,
effectiveText = effectiveText,
modifier = Modifier.weight(1f),
onClick = { onSectionChange(PdfAnnotationSheetSection.NOTE) }
)
PdfAnnotationSheetTab(
label = "${stringResource(R.string.label_comments)} ($commentCount)",
selected = selectedSection == PdfAnnotationSheetSection.COMMENTS,
effectiveText = effectiveText,
modifier = Modifier.weight(1f),
onClick = { onSectionChange(PdfAnnotationSheetSection.COMMENTS) }
)
}
}
}
@Composable
private fun PdfAnnotationSheetTab(
label: String,
selected: Boolean,
effectiveText: Color,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Surface(
color = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else effectiveText,
shape = RoundedCornerShape(6.dp),
modifier = modifier
.height(40.dp)
.clip(RoundedCornerShape(6.dp))
.clickable(onClick = onClick)
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun PdfHighlightCommentsEditor(
comments: List<SharedPdfAnnotationComment>,
commentText: String,
commentAuthor: String,
replyTargetId: String?,
editingCommentId: String?,
effectiveText: Color,
onCommentTextChange: (String) -> Unit,
onCommentAuthorChange: (String) -> Unit,
onReply: (SharedPdfAnnotationComment) -> Unit,
onCancelReply: () -> Unit,
onEdit: (SharedPdfAnnotationComment) -> Unit,
onCancelEdit: () -> Unit,
onDelete: (SharedPdfAnnotationComment) -> Unit,
onAddComment: () -> Unit
) {
val commentIds = comments.filter { it.contents.isNotBlank() }.map { it.id }.toSet()
val visibleComments = comments
.filter { it.contents.isNotBlank() }
.map { comment ->
if (comment.parentId != null && comment.parentId !in commentIds) {
comment.copy(parentId = null)
} else {
comment
}
}
val replyTarget = visibleComments.firstOrNull { it.id == replyTargetId }
val editingComment = visibleComments.firstOrNull { it.id == editingCommentId }
Column {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 220.dp)
.verticalScroll(rememberScrollState())
) {
PdfHighlightCommentThread(
comments = visibleComments,
parentId = null,
depth = 0,
visitedIds = emptySet(),
effectiveText = effectiveText,
onReply = onReply,
onEdit = onEdit,
onDelete = onDelete
)
}
if (editingComment != null || replyTarget != null) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (editingComment != null) {
stringResource(R.string.label_editing_comment)
} else {
stringResource(
R.string.label_replying_to,
replyTarget?.author?.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }.orEmpty()
)
},
style = MaterialTheme.typography.labelMedium,
color = effectiveText.copy(alpha = 0.7f),
modifier = Modifier.weight(1f)
)
TextButton(onClick = if (editingComment != null) onCancelEdit else onCancelReply) {
Text(stringResource(R.string.action_cancel))
}
}
}
OutlinedTextField(
value = commentAuthor,
onValueChange = onCommentAuthorChange,
label = { Text(stringResource(R.string.author)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
colors = pdfAnnotationTextFieldColors(effectiveText),
shape = RoundedCornerShape(12.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = commentText,
onValueChange = onCommentTextChange,
placeholder = {
Text(
stringResource(R.string.placeholder_add_comment),
color = effectiveText.copy(alpha = 0.5f)
)
},
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
maxLines = 4,
colors = pdfAnnotationTextFieldColors(effectiveText),
shape = RoundedCornerShape(12.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onAddComment, enabled = commentText.isNotBlank()) {
Text(
stringResource(
if (editingComment != null) R.string.action_save_comment else R.string.action_add_comment
)
)
}
}
}
}
@Composable
private fun PdfHighlightCommentThread(
comments: List<SharedPdfAnnotationComment>,
parentId: String?,
depth: Int,
visitedIds: Set<String>,
effectiveText: Color,
onReply: (SharedPdfAnnotationComment) -> Unit,
onEdit: (SharedPdfAnnotationComment) -> Unit,
onDelete: (SharedPdfAnnotationComment) -> Unit
) {
comments
.filter { it.parentId == parentId }
.sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id }))
.forEach { comment ->
if (comment.id in visitedIds) return@forEach
PdfHighlightCommentItem(
comment = comment,
depth = depth,
effectiveText = effectiveText,
onReply = { onReply(comment) },
onEdit = { onEdit(comment) },
onDelete = { onDelete(comment) }
)
PdfHighlightCommentThread(
comments = comments,
parentId = comment.id,
depth = depth + 1,
visitedIds = visitedIds + comment.id,
effectiveText = effectiveText,
onReply = onReply,
onEdit = onEdit,
onDelete = onDelete
)
}
}
@Composable
private fun PdfHighlightCommentItem(
comment: SharedPdfAnnotationComment,
depth: Int,
effectiveText: Color,
onReply: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
val indentSize = (depth * 16).dp
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = indentSize, top = 6.dp, bottom = 6.dp)
) {
if (depth > 0) {
Box(
modifier = Modifier
.width(2.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outlineVariant)
)
Spacer(modifier = Modifier.width(12.dp))
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR },
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
val timestamp = comment.createdAt.formatPdfCommentTimestamp()
if (timestamp.isNotBlank()) {
Text(
text = timestamp,
style = MaterialTheme.typography.labelSmall,
color = effectiveText.copy(alpha = 0.55f)
)
}
}
Spacer(Modifier.height(2.dp))
Text(
text = comment.contents,
style = MaterialTheme.typography.bodyMedium,
color = effectiveText
)
Row {
TextButton(onClick = onReply) {
Text(stringResource(R.string.action_reply))
}
TextButton(onClick = onEdit) {
Text(stringResource(R.string.label_edit))
}
TextButton(onClick = onDelete) {
Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error)
}
}
}
}
}
@Composable
private fun pdfAnnotationTextFieldColors(effectiveText: Color) =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = effectiveText.copy(alpha = 0.3f),
focusedTextColor = effectiveText,
unfocusedTextColor = effectiveText
)
private fun List<SharedPdfAnnotationComment>.withoutCommentThread(commentId: String): List<SharedPdfAnnotationComment> {
val childrenByParentId = groupBy { it.parentId }
val idsToRemove = mutableSetOf<String>()
fun collect(id: String) {
if (!idsToRemove.add(id)) return
childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) }
}
collect(commentId)
return filterNot { it.id in idsToRemove }
}
private fun Long.formatPdfCommentTimestamp(): String {
if (this <= 0L) return ""
return runCatching {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(this))
}.getOrDefault("")
}
@Composable @Composable
private fun PdfBottomSheetToolButton( private fun PdfBottomSheetToolButton(
icon: Int, icon: Int,

View file

@ -7,26 +7,18 @@ import androidx.media3.common.util.UnstableApi
import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.TtsPlaybackManager
internal enum class SaveMode { internal typealias SaveMode = com.aryan.reader.shared.SaveMode
ORIGINAL, ANNOTATED
}
enum class SearchHighlightMode { typealias SearchHighlightMode = com.aryan.reader.shared.SearchHighlightMode
FOCUSED, ALL
}
internal sealed interface HistoryAction { internal sealed interface HistoryAction {
data class Add(val pageIndex: Int, val annotation: PdfAnnotation) : HistoryAction data class Add(val pageIndex: Int, val annotation: PdfAnnotation) : HistoryAction
data class Remove(val items: Map<Int, List<PdfAnnotation>>) : HistoryAction data class Remove(val items: Map<Int, List<PdfAnnotation>>) : HistoryAction
} }
internal enum class DockLocation { internal typealias DockLocation = com.aryan.reader.shared.DockLocation
TOP, BOTTOM, FLOATING
}
internal enum class DisplayMode { internal typealias DisplayMode = com.aryan.reader.shared.PdfDisplayMode
PAGINATION, VERTICAL_SCROLL
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Suppress("unused") @Suppress("unused")

View file

@ -154,6 +154,15 @@ fun VerticalScrollbar(
@Composable @Composable
internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) { internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
PageScrubbingAnimation(
pageLabel = "Page $currentPage of $totalPages"
)
}
@Composable
internal fun PageScrubbingAnimation(
pageLabel: String
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -178,7 +187,7 @@ internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
text = "Page $currentPage of $totalPages", text = pageLabel,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
@ -188,9 +197,16 @@ internal fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
@Composable @Composable
internal fun ThumbnailWithIndicator( internal fun ThumbnailWithIndicator(
thumbnail: Bitmap, modifier: Modifier = Modifier, onClick: () -> Unit thumbnail: Bitmap,
modifier: Modifier = Modifier,
borderColor: Color = Color.Unspecified,
onClick: () -> Unit
) { ) {
val borderColor = MaterialTheme.colorScheme.primary val effectiveBorderColor = if (borderColor == Color.Unspecified) {
MaterialTheme.colorScheme.primary
} else {
borderColor
}
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Surface( Surface(
modifier = Modifier modifier = Modifier
@ -198,7 +214,7 @@ internal fun ThumbnailWithIndicator(
.height(64.dp) .height(64.dp)
.clickable(onClick = onClick), .clickable(onClick = onClick),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
border = BorderStroke(2.dp, borderColor) border = BorderStroke(2.dp, effectiveBorderColor)
) { ) {
Image( Image(
bitmap = thumbnail.asImageBitmap(), bitmap = thumbnail.asImageBitmap(),
@ -211,7 +227,7 @@ internal fun ThumbnailWithIndicator(
.offset(y = (-4).dp) .offset(y = (-4).dp)
.size(8.dp) .size(8.dp)
.rotate(45f) .rotate(45f)
.background(borderColor)) .background(effectiveBorderColor))
} }
} }

View file

@ -0,0 +1,357 @@
package com.aryan.reader.pdf
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.platform.ViewConfiguration
import kotlinx.coroutines.withTimeout
import timber.log.Timber
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.pow
internal const val PDF_ONE_HAND_ZOOM_TRACE_TAG = "PdfOneHandZoomTrace"
internal const val PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS = 90L
internal const val PDF_ONE_HAND_ZOOM_DRAG_DISTANCE_FOR_DOUBLE_DP = 240f
internal enum class PdfSecondTapZoomAction {
QUICK_DOUBLE_TAP,
ONE_HAND_ZOOM,
HELD_NO_MOVEMENT
}
internal fun classifyPdfSecondTapZoomAction(
pressDurationMillis: Long,
totalDragY: Float,
movementSlopPx: Float,
holdTimeoutMillis: Long = PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS
): PdfSecondTapZoomAction {
return when {
pressDurationMillis < holdTimeoutMillis -> PdfSecondTapZoomAction.QUICK_DOUBLE_TAP
abs(totalDragY) >= movementSlopPx -> PdfSecondTapZoomAction.ONE_HAND_ZOOM
else -> PdfSecondTapZoomAction.HELD_NO_MOVEMENT
}
}
internal fun pdfOneHandZoomScale(
startScale: Float,
totalDragY: Float,
dragDistanceForDoublePx: Float,
minScale: Float,
maxScale: Float
): Float {
val safeStart = startScale.takeIf { it.isFinite() && it > 0f } ?: minScale
val safeDistance = dragDistanceForDoublePx.takeIf { it.isFinite() && it > 0f } ?: 1f
val scaleMultiplier = 2f.pow(totalDragY / safeDistance)
return (safeStart * scaleMultiplier).coerceIn(minScale, maxScale)
}
internal fun clampCenteredPdfCameraOffset(
scale: Float,
offset: Offset,
viewportSize: Size,
contentSize: Size
): Offset {
if (viewportSize.width <= 0f || viewportSize.height <= 0f || scale <= 1f) {
return Offset.Zero
}
val maxOffsetX = ((contentSize.width * scale) - viewportSize.width).coerceAtLeast(0f) / 2f
val maxOffsetY = ((contentSize.height * scale) - viewportSize.height).coerceAtLeast(0f) / 2f
return Offset(
x = offset.x.coerceIn(-maxOffsetX, maxOffsetX),
y = offset.y.coerceIn(-maxOffsetY, maxOffsetY)
)
}
internal fun centeredPdfCameraOffsetForScaleChange(
previousScale: Float,
nextScale: Float,
previousOffset: Offset,
pivot: Offset,
viewportSize: Size,
contentSize: Size
): Offset {
val safePreviousScale = previousScale.takeIf { it.isFinite() && it > 0f } ?: 1f
val ratio = nextScale / safePreviousScale
val viewportCenter = Offset(viewportSize.width / 2f, viewportSize.height / 2f)
val targetOffset = previousOffset * ratio + (pivot - viewportCenter) * (1f - ratio)
return clampCenteredPdfCameraOffset(
scale = nextScale,
offset = targetOffset,
viewportSize = viewportSize,
contentSize = contentSize
)
}
internal fun topLeftPdfPanForScaleChange(
previousScale: Float,
nextScale: Float,
previousPan: Offset,
pivot: Offset
): Offset {
val safePreviousScale = previousScale.takeIf { it.isFinite() && it > 0f } ?: 1f
val contentPivot = (pivot - previousPan) / safePreviousScale
return pivot - (contentPivot * nextScale)
}
private fun Offset.traceString(): String = "(${x.toInt()},${y.toInt()})"
private fun PointerInputChange.traceString(): String {
return "id=$id pos=${position.traceString()} prev=${previousPosition.traceString()} pressed=$pressed consumed=$isConsumed"
}
private fun traceOneHandZoom(message: String) {
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(message)
}
internal suspend fun PointerInputScope.detectPdfTapAndOneHandZoomGestures(
viewConfiguration: ViewConfiguration,
canStartOneHandZoom: () -> Boolean,
canHandleQuickDoubleTap: () -> Boolean = { true },
consumeSingleTap: Boolean = true,
onTap: (Offset) -> Unit,
onQuickDoubleTap: (Offset) -> Unit,
onOneHandZoomHoldStart: (Offset) -> Unit,
onOneHandZoom: (pivot: Offset, totalDragY: Float) -> Unit,
onOneHandZoomEnd: (started: Boolean) -> Unit
) {
awaitEachGesture {
val firstDown = awaitFirstDown(requireUnconsumed = false)
traceOneHandZoom(
"detector.firstDown ${firstDown.traceString()} consumeSingleTap=$consumeSingleTap " +
"doubleTapTimeout=${viewConfiguration.doubleTapTimeoutMillis} holdTimeout=$PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS"
)
val firstUp = waitForUpOrCancellation()
if (firstUp == null) {
traceOneHandZoom("detector.firstUpCanceled first=${firstDown.traceString()}")
return@awaitEachGesture
}
traceOneHandZoom("detector.firstUp ${firstUp.traceString()}")
val secondDown = awaitPdfSecondDown(
firstPointerId = firstDown.id,
timeoutMillis = viewConfiguration.doubleTapTimeoutMillis
)
if (secondDown == null) {
traceOneHandZoom(
"detector.singleTap noSecondDown consume=$consumeSingleTap firstUpConsumedBefore=${firstUp.isConsumed} " +
"tap=${firstDown.position.traceString()}"
)
if (consumeSingleTap) firstUp.consume()
onTap(firstDown.position)
return@awaitEachGesture
}
val pivot = secondDown.position
var latestPosition = pivot
var quickDoubleTapUp: PointerInputChange? = null
var canceled = false
val oneHandAllowed = canStartOneHandZoom()
val movementSlopPx = max(2f, viewConfiguration.touchSlop * 0.35f)
var shouldStartOneHandZoom = false
traceOneHandZoom(
"detector.secondDown ${secondDown.traceString()} oneHandAllowed=$oneHandAllowed " +
"quickAllowed=${canHandleQuickDoubleTap()} movementSlop=$movementSlopPx touchSlop=${viewConfiguration.touchSlop}"
)
try {
withTimeout(PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS) {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == secondDown.id }
if (change == null) {
canceled = true
traceOneHandZoom(
"detector.preHoldCancel missingSecondPointer changes=${event.changes.joinToString { it.traceString() }}"
)
return@withTimeout
}
latestPosition = change.position
if (change.changedToUp()) {
quickDoubleTapUp = change
traceOneHandZoom("detector.preHoldQuickUp ${change.traceString()}")
return@withTimeout
}
if (change.isConsumed) {
canceled = true
traceOneHandZoom(
"detector.preHoldCancel consumedByOther ${change.traceString()} " +
"allChanges=${event.changes.joinToString { it.traceString() }}"
)
return@withTimeout
}
if (oneHandAllowed) {
val delta = change.position - pivot
val isVerticalZoomDrag = abs(delta.y) >= movementSlopPx &&
abs(delta.y) >= abs(delta.x) * 1.1f
if (isVerticalZoomDrag) {
shouldStartOneHandZoom = true
traceOneHandZoom(
"detector.preHoldStart verticalDrag delta=${delta.traceString()} " +
"slop=$movementSlopPx change=${change.traceString()}"
)
change.consume()
return@withTimeout
} else if (delta.getDistance() >= viewConfiguration.touchSlop) {
canceled = true
traceOneHandZoom(
"detector.preHoldCancel nonZoomMove delta=${delta.traceString()} " +
"distance=${delta.getDistance()} touchSlop=${viewConfiguration.touchSlop}"
)
return@withTimeout
}
}
}
}
} catch (_: PointerEventTimeoutCancellationException) {
// The second tap is being held, so the quick double-tap action is suppressed.
shouldStartOneHandZoom = true
traceOneHandZoom(
"detector.holdTimeout pivot=${pivot.traceString()} latest=${latestPosition.traceString()} " +
"oneHandAllowed=$oneHandAllowed"
)
}
if (canceled) {
traceOneHandZoom("detector.end canceledBeforeAction latest=${latestPosition.traceString()}")
return@awaitEachGesture
}
if (quickDoubleTapUp != null) {
val quickAllowed = canHandleQuickDoubleTap()
traceOneHandZoom(
"detector.quickDoubleTap fire quickAllowed=$quickAllowed upConsumedBefore=${quickDoubleTapUp?.isConsumed} " +
"pivot=${pivot.traceString()}"
)
if (quickAllowed) {
quickDoubleTapUp?.consume()
}
onQuickDoubleTap(pivot)
return@awaitEachGesture
}
if (!oneHandAllowed) {
traceOneHandZoom("detector.oneHandBlocked waitingForUp pivot=${pivot.traceString()}")
waitForUpOrCancellation()
return@awaitEachGesture
}
if (!shouldStartOneHandZoom) {
traceOneHandZoom("detector.end noAction shouldStart=false latest=${latestPosition.traceString()}")
return@awaitEachGesture
}
var zoomStarted = false
fun updateZoom(position: Offset) {
val totalDragY = position.y - pivot.y
val action = classifyPdfSecondTapZoomAction(
pressDurationMillis = PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS,
totalDragY = totalDragY,
movementSlopPx = movementSlopPx
)
if (action == PdfSecondTapZoomAction.ONE_HAND_ZOOM) {
if (!zoomStarted) {
traceOneHandZoom(
"detector.oneHandZoomStart dragY=$totalDragY pivot=${pivot.traceString()} " +
"position=${position.traceString()} slop=$movementSlopPx"
)
}
zoomStarted = true
}
if (zoomStarted) {
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v(
"detector.oneHandZoomUpdate dragY=$totalDragY pivot=${pivot.traceString()} position=${position.traceString()}"
)
onOneHandZoom(pivot, totalDragY)
}
}
traceOneHandZoom(
"detector.oneHandHoldStart pivot=${pivot.traceString()} latest=${latestPosition.traceString()}"
)
onOneHandZoomHoldStart(pivot)
try {
updateZoom(latestPosition)
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == secondDown.id }
if (change == null) {
traceOneHandZoom(
"detector.postHoldEnd missingSecondPointer changes=${event.changes.joinToString { it.traceString() }}"
)
break
}
latestPosition = change.position
if (change.isConsumed) {
traceOneHandZoom(
"detector.postHoldEnd consumedByOther ${change.traceString()} zoomStarted=$zoomStarted"
)
break
}
val isPositionChanged = change.positionChanged()
val isUp = change.changedToUp()
updateZoom(latestPosition)
if (isPositionChanged || zoomStarted || isUp) {
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v(
"detector.consumePostHold changed=$isPositionChanged zoomStarted=$zoomStarted up=$isUp " +
change.traceString()
)
change.consume()
}
if (isUp) {
traceOneHandZoom(
"detector.oneHandPointerUp zoomStarted=$zoomStarted latest=${latestPosition.traceString()}"
)
break
}
}
} finally {
traceOneHandZoom(
"detector.oneHandEnd zoomStarted=$zoomStarted latest=${latestPosition.traceString()}"
)
onOneHandZoomEnd(zoomStarted)
}
}
}
private suspend fun androidx.compose.ui.input.pointer.AwaitPointerEventScope.awaitPdfSecondDown(
firstPointerId: PointerId,
timeoutMillis: Long
): PointerInputChange? {
return try {
withTimeout(timeoutMillis) {
while (true) {
val event = awaitPointerEvent()
val secondDown = event.changes.firstOrNull {
it.id != firstPointerId && it.changedToDown()
} ?: event.changes.firstOrNull {
it.id == firstPointerId && it.changedToDown()
}
if (secondDown != null) return@withTimeout secondDown
}
null
}
} catch (_: PointerEventTimeoutCancellationException) {
null
}
}

View file

@ -0,0 +1,196 @@
package com.aryan.reader.pdf
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
import org.json.JSONArray
import org.json.JSONObject
internal fun remapPdfAnnotationsForLayoutChange(
currentLayout: List<VirtualPage>,
updatedLayout: List<VirtualPage>,
annotations: Map<Int, List<PdfAnnotation>>
): Map<Int, List<PdfAnnotation>> {
if (annotations.isEmpty()) return emptyMap()
val mapping = buildPdfPageIndexMapping(
currentLayout = currentLayout,
updatedLayout = updatedLayout,
sourcePageIndices = annotations.keys
)
val remapped = linkedMapOf<Int, MutableList<PdfAnnotation>>()
annotations.toSortedMap().forEach { (sourcePageIndex, pageAnnotations) ->
val targetPageIndex = mapping[sourcePageIndex] ?: return@forEach
pageAnnotations.forEach { annotation ->
remapped.getOrPut(targetPageIndex) { mutableListOf() }
.add(annotation.copy(pageIndex = targetPageIndex))
}
}
return remapped.mapValues { (_, pageAnnotations) -> pageAnnotations.toList() }
}
internal fun remapPdfTextBoxesForLayoutChange(
currentLayout: List<VirtualPage>,
updatedLayout: List<VirtualPage>,
textBoxes: List<PdfTextBox>
): List<PdfTextBox> {
if (textBoxes.isEmpty()) return emptyList()
val mapping = buildPdfPageIndexMapping(
currentLayout = currentLayout,
updatedLayout = updatedLayout,
sourcePageIndices = textBoxes.map { it.pageIndex }
)
return textBoxes.mapNotNull { box ->
mapping[box.pageIndex]?.let { targetPageIndex ->
box.copy(pageIndex = targetPageIndex)
}
}
}
internal fun remapPdfUserHighlightsForLayoutChange(
currentLayout: List<VirtualPage>,
updatedLayout: List<VirtualPage>,
highlights: List<PdfUserHighlight>
): List<PdfUserHighlight> {
if (highlights.isEmpty()) return emptyList()
val mapping = buildPdfPageIndexMapping(
currentLayout = currentLayout,
updatedLayout = updatedLayout,
sourcePageIndices = highlights.map { it.pageIndex }
)
return highlights.mapNotNull { highlight ->
mapping[highlight.pageIndex]?.let { targetPageIndex ->
highlight.copy(pageIndex = targetPageIndex)
}
}
}
internal fun remapPdfHistoryActionsForLayoutChange(
currentLayout: List<VirtualPage>,
updatedLayout: List<VirtualPage>,
actions: List<HistoryAction>
): List<HistoryAction> {
if (actions.isEmpty()) return emptyList()
return actions.mapNotNull { action ->
when (action) {
is HistoryAction.Add -> {
val mapping = buildPdfPageIndexMapping(
currentLayout = currentLayout,
updatedLayout = updatedLayout,
sourcePageIndices = listOf(action.pageIndex)
)
val targetPageIndex = mapping[action.pageIndex] ?: return@mapNotNull null
action.copy(
pageIndex = targetPageIndex,
annotation = action.annotation.copy(pageIndex = targetPageIndex)
)
}
is HistoryAction.Remove -> {
val remappedItems = remapPdfAnnotationsForLayoutChange(
currentLayout = currentLayout,
updatedLayout = updatedLayout,
annotations = action.items
)
remappedItems.takeIf { it.isNotEmpty() }?.let(HistoryAction::Remove)
}
}
}
}
internal fun remapPdfBookmarksJsonForLayoutChange(
currentLayout: List<VirtualPage>,
updatedLayout: List<VirtualPage>,
currentBookmarksJson: String
): String {
if (currentBookmarksJson.isBlank()) return "[]"
val jsonArray = JSONArray(currentBookmarksJson)
val sourcePageIndices = buildList {
for (i in 0 until jsonArray.length()) {
val pageIndex = jsonArray.optJSONObject(i)?.optInt("pageIndex", Int.MIN_VALUE)
if (pageIndex != null && pageIndex != Int.MIN_VALUE) add(pageIndex)
}
}
val mapping = buildPdfPageIndexMapping(
currentLayout = currentLayout,
updatedLayout = updatedLayout,
sourcePageIndices = sourcePageIndices
)
val newArray = JSONArray()
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val sourcePageIndex = obj.optInt("pageIndex", Int.MIN_VALUE)
val targetPageIndex = mapping[sourcePageIndex] ?: continue
val newObj = JSONObject(obj.toString())
newObj.put("pageIndex", targetPageIndex)
newObj.put("totalPages", updatedLayout.size)
newArray.put(newObj)
}
return newArray.toString()
}
internal fun buildPdfPageIndexMapping(
currentLayout: List<VirtualPage>,
updatedLayout: List<VirtualPage>,
sourcePageIndices: Iterable<Int>
): Map<Int, Int> {
val distinctSourcePageIndices = sourcePageIndices.toSet()
if (distinctSourcePageIndices.isEmpty()) return emptyMap()
val minimumCurrentPageCount = maxOf(
currentLayout.size,
distinctSourcePageIndices.maxOrNull()?.plus(1) ?: 0
)
val effectiveCurrentLayout = currentLayout.withDefaultPdfPagesUntil(minimumCurrentPageCount)
val currentTokens = effectiveCurrentLayout.toOccurrenceTokens()
val updatedTokenIndices = updatedLayout.toOccurrenceTokens()
.mapIndexed { index, token -> token to index }
.toMap()
return distinctSourcePageIndices.mapNotNull { sourcePageIndex ->
val token = currentTokens.getOrNull(sourcePageIndex) ?: return@mapNotNull null
val targetPageIndex = updatedTokenIndices[token] ?: return@mapNotNull null
sourcePageIndex to targetPageIndex
}.toMap()
}
private fun List<VirtualPage>.withDefaultPdfPagesUntil(pageCount: Int): List<VirtualPage> {
if (size >= pageCount) return this
return this + (size until pageCount).map { VirtualPage.PdfPage(it) }
}
private fun List<VirtualPage>.toOccurrenceTokens(): List<VirtualPageOccurrenceToken> {
val seen = mutableMapOf<VirtualPageKey, Int>()
return map { page ->
val key = page.toVirtualPageKey()
val occurrence = seen.getOrDefault(key, 0)
seen[key] = occurrence + 1
VirtualPageOccurrenceToken(key, occurrence)
}
}
private fun VirtualPage.toVirtualPageKey(): VirtualPageKey {
return when (this) {
is VirtualPage.PdfPage -> VirtualPageKey.Pdf(pdfIndex)
is VirtualPage.BlankPage -> VirtualPageKey.Blank(id)
}
}
private data class VirtualPageOccurrenceToken(
val key: VirtualPageKey,
val occurrence: Int
)
private sealed interface VirtualPageKey {
data class Pdf(val pdfIndex: Int) : VirtualPageKey
data class Blank(val id: String) : VirtualPageKey
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
package com.aryan.reader.pdf
import com.aryan.reader.pdf.data.VirtualPage
internal const val PDF_BLANK_PAGE_PERSISTENCE_TAG = "PdfBlankPagePersist"
internal fun List<VirtualPage>.pdfLayoutDebugSummary(maxPages: Int = 16): String {
val blankPages = filterIsInstance<VirtualPage.BlankPage>()
val sample = take(maxPages).mapIndexed { displayIndex, page ->
when (page) {
is VirtualPage.PdfPage -> "$displayIndex:P${page.pdfIndex}"
is VirtualPage.BlankPage ->
"$displayIndex:B(${page.id.take(8)},${page.width}x${page.height},manual=${page.wasManuallyAdded})"
}
}.let { pages ->
if (size > maxPages) pages + "...(+${size - maxPages})" else pages
}
return "size=$size pdf=${count { it is VirtualPage.PdfPage }} " +
"blank=${blankPages.size} manualBlank=${blankPages.count { it.wasManuallyAdded }} " +
"pages=${sample.joinToString(prefix = "[", postfix = "]")}"
}

View file

@ -0,0 +1,25 @@
package com.aryan.reader.pdf
import android.graphics.Rect
import com.aryan.reader.pdf.data.VirtualPage
enum class LinkSource {
ANNOTATION, TEXT_CONTENT
}
data class PageLink(
val highlightBounds: Rect,
val tapBounds: Rect,
val url: String?,
val destPageIdx: Int?,
val source: LinkSource
)
internal fun pdfRenderPageId(documentKey: String, pageIndex: Int, virtualPage: VirtualPage?): String {
val sourcePageId = when (virtualPage) {
is VirtualPage.BlankPage -> "BLANK_${virtualPage.id}"
is VirtualPage.PdfPage -> "PDF_${virtualPage.pdfIndex}"
null -> "PDF_$pageIndex"
}
return "$documentKey:$sourcePageId"
}

View file

@ -0,0 +1,422 @@
package com.aryan.reader.pdf
import android.graphics.Bitmap
import android.graphics.Rect
import android.util.LruCache
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import com.aryan.reader.pdf.data.PdfAnnotation
import timber.log.Timber
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
import android.graphics.Color as AndroidColor
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
object PdfInkGeometry {
fun calculateFountainPenPoints(
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
): Pair<List<Offset>, List<Offset>> {
if (points.size < 2) return Pair(emptyList(), emptyList())
if (points.size % 50 == 0) {
Timber.tag("FountainPenDebug").d(
"Calculate Points: PWidth=$pageWidth, PHeight=$pageHeight, BaseW=$baseWidth, Pts=${points.size}"
)
}
val leftSide = mutableListOf<Offset>()
val rightSide = mutableListOf<Offset>()
val computedWidths = FloatArray(points.size)
computedWidths[0] = baseWidth
val velocityFactor = 300f
for (i in 1 until points.size) {
val p1 = points[i - 1]
val p2 = points[i]
val dxNorm = p2.x - p1.x
val dyNorm = p2.y - p1.y
val aspect = if (pageWidth > 0 && pageHeight > 0) pageHeight / pageWidth else 1f
val distNorm = sqrt(dxNorm * dxNorm + (dyNorm * aspect) * (dyNorm * aspect))
val timeDelta = (p2.timestamp - p1.timestamp).coerceAtLeast(1)
val velocityNorm = distNorm / timeDelta
val targetWidth = (baseWidth * (1f / (1f + velocityNorm * velocityFactor))).coerceIn(
baseWidth * 0.2f, baseWidth * 1.4f
)
computedWidths[i] = computedWidths[i - 1] * 0.6f + targetWidth * 0.4f
if (i < 5) {
Timber.tag("FountainPenDebug").v(
"Pt[$i]: dt=$timeDelta, velNorm=$velocityNorm, width=${computedWidths[i]} (base=$baseWidth)"
)
}
}
for (i in 0 until points.size - 1) {
val pCurrent = points[i]
val pNext = points[i + 1]
val curX = pCurrent.x * pageWidth
val curY = pCurrent.y * pageHeight
val nextX = pNext.x * pageWidth
val nextY = pNext.y * pageHeight
val angle = atan2(nextY - curY, nextX - curX)
val normalAngle = angle - (PI / 2f).toFloat()
val w = computedWidths[i] / 2f
leftSide.add(Offset((curX + cos(normalAngle) * w), (curY + sin(normalAngle) * w)))
rightSide.add(Offset((curX - cos(normalAngle) * w), (curY - sin(normalAngle) * w)))
}
val lastIdx = points.lastIndex
val lastP = points[lastIdx]
val prevP = points[lastIdx - 1]
val lastX = lastP.x * pageWidth
val lastY = lastP.y * pageHeight
val prevX = prevP.x * pageWidth
val prevY = prevP.y * pageHeight
val lastAngle = atan2(lastY - prevY, lastX - prevX)
val lastNormal = lastAngle - (PI / 2f).toFloat()
val lastW = computedWidths[lastIdx] / 2f
leftSide.add(Offset((lastX + cos(lastNormal) * lastW), (lastY + sin(lastNormal) * lastW)))
rightSide.add(Offset((lastX - cos(lastNormal) * lastW), (lastY - sin(lastNormal) * lastW)))
return Pair(leftSide, rightSide)
}
}
internal object PdfBitmapPool {
private val pool = ConcurrentLinkedQueue<Bitmap>()
private const val MAX_POOL_SIZE = 4
fun get(width: Int, height: Int): Bitmap {
val iterator = pool.iterator()
while (iterator.hasNext()) {
val bitmap = iterator.next()
if (bitmap.width == width && bitmap.height == height && !bitmap.isRecycled) {
iterator.remove()
bitmap.eraseColor(AndroidColor.TRANSPARENT)
return bitmap
}
}
return createBitmap(width, height)
}
fun get(size: Int): Bitmap = get(size, size)
fun recycle(bitmap: Bitmap) {
// Overflow bitmaps are left for GC; HWUI may still reference recently drawn bitmaps.
if (!bitmap.isRecycled && pool.size < MAX_POOL_SIZE) {
pool.offer(bitmap)
}
}
fun clear() {
while (!pool.isEmpty()) {
pool.poll()
}
}
}
internal object PdfThumbnailCache {
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
private val cacheSize = maxMemory / 8
private data class CacheEntry(val bitmap: Bitmap, val sizeKb: Int)
private val memoryCache = object : LruCache<String, CacheEntry>(cacheSize) {
override fun sizeOf(key: String, entry: CacheEntry): Int {
return entry.sizeKb
}
}
fun get(pageId: String): Bitmap? {
return memoryCache.get(pageId)?.bitmap?.takeUnless { it.isRecycled }
}
fun put(pageId: String, bitmap: Bitmap) {
if (get(pageId) == null) {
val sizeKb = (bitmap.allocationByteCount / 1024).coerceAtLeast(1)
memoryCache.put(pageId, CacheEntry(bitmap, sizeKb))
}
}
fun clear() {
memoryCache.evictAll()
}
}
internal object PdfTextureGenerator {
private var noiseBitmap: Bitmap? = null
fun getNoiseTexture(): Bitmap {
if (noiseBitmap == null) {
val size = 256
val bitmap = createBitmap(size, size, Bitmap.Config.ARGB_8888)
for (x in 0 until size) {
for (y in 0 until size) {
val isGrain = Math.random() > 0.4
if (isGrain) {
val alpha = (Math.random() * 100 + 100).toInt()
bitmap[x, y] = AndroidColor.argb(alpha, 0, 0, 0)
} else {
bitmap[x, y] = AndroidColor.TRANSPARENT
}
}
}
noiseBitmap = bitmap
}
return noiseBitmap!!
}
}
internal sealed interface AnnotationRenderData {
data class Standard(
val path: Path,
val color: Color,
val strokeWidth: Float,
val cap: StrokeCap,
val blendMode: BlendMode
) : AnnotationRenderData
data class Fountain(val path: Path, val color: Color) : AnnotationRenderData
data class Pencil(
val path: android.graphics.Path,
val color: Color,
val strokeWidth: Float,
val velocityAlpha: Float
) : AnnotationRenderData
}
internal object PdfAnnotationRenderHelper {
fun createRenderData(annot: PdfAnnotation, widthPx: Int, heightPx: Int): AnnotationRenderData? {
val startTime = System.nanoTime()
if (annot.points.isEmpty()) return null
if (annot.points.size == 1) {
val point = annot.points[0]
val x = point.x * widthPx
val y = point.y * heightPx
val path = if (annot.inkType == InkType.PENCIL) android.graphics.Path() else Path()
if (path is android.graphics.Path) {
path.moveTo(x, y)
path.lineTo(x, y)
return AnnotationRenderData.Pencil(
path = path,
color = annot.color,
strokeWidth = annot.strokeWidth * widthPx,
velocityAlpha = 1.0f
)
} else if (path is Path) {
if (annot.inkType == InkType.FOUNTAIN_PEN) {
val radius = (annot.strokeWidth * widthPx) / 2f
path.addOval(
androidx.compose.ui.geometry.Rect(
center = Offset(x, y), radius = radius
)
)
return AnnotationRenderData.Fountain(path = path, color = annot.color)
}
path.moveTo(x, y)
path.lineTo(x, y)
val cap = when (annot.inkType) {
InkType.HIGHLIGHTER -> StrokeCap.Butt
InkType.HIGHLIGHTER_ROUND -> StrokeCap.Round
else -> StrokeCap.Round
}
return AnnotationRenderData.Standard(
path = path,
color = annot.color,
strokeWidth = annot.strokeWidth * widthPx,
cap = cap,
blendMode = BlendMode.SrcOver
)
}
}
val result = when (annot.inkType) {
InkType.PENCIL -> {
val path = android.graphics.Path()
val first = annot.points[0]
path.moveTo(first.x * widthPx, first.y * heightPx)
var totalDist = 0f
for (i in 1 until annot.points.size) {
val p0 = annot.points[i - 1]
val p1 = annot.points[i]
val p0x = p0.x * widthPx
val p0y = p0.y * heightPx
val p1x = p1.x * widthPx
val p1y = p1.y * heightPx
val midX = (p0x + p1x) / 2f
val midY = (p0y + p1y) / 2f
val dx = p1x - p0x
val dy = p1y - p0y
totalDist += sqrt(dx * dx + dy * dy)
if (i == 1) path.lineTo(midX, midY)
else path.quadTo(p0x, p0y, midX, midY)
}
val last = annot.points.last()
path.lineTo(last.x * widthPx, last.y * heightPx)
val duration =
(annot.points.last().timestamp - annot.points.first().timestamp).coerceAtLeast(1)
val velocity = totalDist / duration
val velocityAlphaFactor = (1f - (velocity - 0.2f) / 1.8f).coerceIn(0.4f, 1.0f)
AnnotationRenderData.Pencil(
path = path,
color = annot.color,
strokeWidth = annot.strokeWidth * widthPx,
velocityAlpha = velocityAlphaFactor
)
}
InkType.FOUNTAIN_PEN -> {
val baseStrokeWidth = annot.strokeWidth * widthPx
val path = Path()
val (leftSide, rightSide) = PdfInkGeometry.calculateFountainPenPoints(
annot.points, baseStrokeWidth, widthPx.toFloat(), heightPx.toFloat()
)
if (leftSide.isNotEmpty()) {
path.moveTo(leftSide[0].x, leftSide[0].y)
for (i in 1 until leftSide.size) {
path.lineTo(leftSide[i].x, leftSide[i].y)
}
for (i in rightSide.size - 1 downTo 0) {
path.lineTo(rightSide[i].x, rightSide[i].y)
}
path.close()
}
AnnotationRenderData.Fountain(path = path, color = annot.color)
}
else -> {
val path = Path()
val first = annot.points[0]
path.moveTo(first.x * widthPx, first.y * heightPx)
for (i in 1 until annot.points.size) {
val p0 = annot.points[i - 1]
val p1 = annot.points[i]
val p0x = p0.x * widthPx
val p0y = p0.y * heightPx
val p1x = p1.x * widthPx
val p1y = p1.y * heightPx
val midX = (p0x + p1x) / 2f
val midY = (p0y + p1y) / 2f
if (i == 1) path.lineTo(midX, midY)
else path.quadraticTo(p0x, p0y, midX, midY)
}
val last = annot.points.last()
path.lineTo(last.x * widthPx, last.y * heightPx)
val blendMode = when (annot.inkType) {
InkType.HIGHLIGHTER, InkType.HIGHLIGHTER_ROUND -> BlendMode.Multiply
else -> BlendMode.SrcOver
}
val cap = when (annot.inkType) {
InkType.HIGHLIGHTER -> StrokeCap.Butt
InkType.HIGHLIGHTER_ROUND -> StrokeCap.Round
else -> StrokeCap.Round
}
AnnotationRenderData.Standard(
path = path,
color = annot.color,
strokeWidth = annot.strokeWidth * widthPx,
cap = cap,
blendMode = blendMode
)
}
}
val duration = (System.nanoTime() - startTime) / 1_000_000f
if (duration > 1f) {
Timber.tag("PdfPerf").v("Path Gen: Type=${annot.inkType}, Pts=${annot.points.size}, Time=${duration}ms")
}
return result
}
}
@Stable
class PdfDrawingState {
var currentAnnotation by mutableStateOf<PdfAnnotation?>(null)
private set
private val currentPoints = mutableListOf<PdfPoint>()
fun onDrawStart(pageIndex: Int, point: PdfPoint, type: InkType, color: Color, width: Float) {
currentPoints.clear()
currentPoints.add(point)
currentAnnotation = PdfAnnotation(
type = AnnotationType.INK,
inkType = type,
pageIndex = pageIndex,
points = currentPoints.toList(),
color = color,
strokeWidth = width
)
}
fun onDraw(point: PdfPoint) {
currentPoints.add(point)
currentAnnotation = currentAnnotation?.copy(points = currentPoints.toList())
}
fun onDrawCancel() {
currentAnnotation = null
currentPoints.clear()
}
fun onDrawEnd(): PdfAnnotation? {
val finalAnnot = currentAnnotation
currentAnnotation = null
currentPoints.clear()
return finalAnnot
}
fun updateDrag(point: PdfPoint) {
if (currentPoints.isNotEmpty()) {
val start = currentPoints.first()
currentPoints.clear()
currentPoints.add(start)
currentPoints.add(point)
currentAnnotation = currentAnnotation?.copy(points = currentPoints.toList())
}
}
}

View file

@ -8,9 +8,9 @@ import androidx.compose.ui.graphics.toArgb
import androidx.core.content.edit import androidx.core.content.edit
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.ReaderTheme
import com.aryan.reader.ReaderTexture
import com.aryan.reader.epubreader.SystemUiMode import com.aryan.reader.epubreader.SystemUiMode
import com.aryan.reader.shared.BuiltInPdfReaderThemes
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll" internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll"
internal const val SETTINGS_PREFS_NAME = "epub_reader_settings" internal const val SETTINGS_PREFS_NAME = "epub_reader_settings"
@ -33,7 +33,6 @@ private const val PDF_AUTO_SCROLL_LOCAL_SPEED_PREFIX = "pdf_as_local_speed_"
private const val PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX = "pdf_as_local_min_" private const val PDF_AUTO_SCROLL_LOCAL_MIN_PREFIX = "pdf_as_local_min_"
private const val PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX = "pdf_as_local_max_" private const val PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX = "pdf_as_local_max_"
private const val PDF_SCROLL_LOCKED_PREFIX = "pdf_sl_local_" private const val PDF_SCROLL_LOCKED_PREFIX = "pdf_sl_local_"
internal const val PDF_FULL_SCREEN_PREFIX = "pdf_fs_local_"
private const val PDF_MUSICIAN_MODE_KEY = "pdf_musician_mode_enabled" private const val PDF_MUSICIAN_MODE_KEY = "pdf_musician_mode_enabled"
private const val PREF_USE_ONLINE_DICT = "use_online_dictionary" private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package" private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
@ -47,17 +46,21 @@ internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode" internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
internal const val PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY = "pdf_vertical_page_gap_visible" internal const val PDF_VERTICAL_PAGE_GAP_VISIBLE_KEY = "pdf_vertical_page_gap_visible"
internal const val PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY = "pdf_page_number_overlay_visible" internal const val PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY = "pdf_page_number_overlay_visible"
internal const val PDF_TOP_TAB_STRIP_VISIBLE_KEY = "pdf_top_tab_strip_visible"
internal const val PDF_PAGE_SPREAD_MODE_KEY = "pdf_page_spread_mode"
internal const val PDF_FIRST_PAGE_STANDALONE_IN_SPREAD_KEY = "pdf_first_page_standalone_in_spread"
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug" internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version" private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "pdf_hidden_tools_defaults_version"
private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 2 private const val PDF_HIDDEN_TOOLS_DEFAULTS_VERSION = 3
enum class PdfReaderTool(@StringRes val titleRes: Int, val category: String) { enum class PdfReaderTool(@StringRes val titleRes: Int, val category: String) {
DICTIONARY(R.string.tool_external_apps, "Top Bar"), DICTIONARY(R.string.tool_external_apps, "Top Bar"),
THEME(R.string.tooltip_theme_desc, "Top Bar"), THEME(R.string.tooltip_theme_desc, "Top Bar"),
BRIGHTNESS(R.string.tool_brightness, "Top Bar"),
LOCK_PANNING(R.string.tooltip_lock_pan, "Top Bar"), LOCK_PANNING(R.string.tooltip_lock_pan, "Top Bar"),
FILE_INFO(R.string.file_information, "Overflow Menu"),
VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"), VISUAL_OPTIONS(R.string.menu_visual_options, "Overflow Menu"),
TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"), TAP_TO_TURN(R.string.menu_tap_to_turn_pages, "Overflow Menu"),
FULL_SCREEN(R.string.tooltip_fullscreen, "Top Bar"),
SLIDER(R.string.tool_navigation_slider, "Bottom Bar"), SLIDER(R.string.tool_navigation_slider, "Bottom Bar"),
TOC(R.string.tool_sidebar, "Bottom Bar"), TOC(R.string.tool_sidebar, "Bottom Bar"),
SEARCH(R.string.action_search, "Bottom Bar"), SEARCH(R.string.action_search, "Bottom Bar"),
@ -83,38 +86,41 @@ enum class PdfReaderTool(@StringRes val titleRes: Int, val category: String) {
internal fun defaultPdfHiddenTools(): Set<String> { internal fun defaultPdfHiddenTools(): Set<String> {
return setOf( return setOf(
PdfReaderTool.SCREEN_ORIENTATION.name, PdfReaderTool.SCREEN_ORIENTATION.name,
PdfReaderTool.HIGHLIGHT_ALL.name PdfReaderTool.HIGHLIGHT_ALL.name,
PdfReaderTool.BRIGHTNESS.name
) )
} }
internal fun defaultPdfToolOrder(): List<PdfReaderTool> = PdfReaderTool.entries.toList() internal fun isPdfReaderToolAvailable(tool: PdfReaderTool): Boolean {
return BuildConfig.IS_PRO || tool != PdfReaderTool.OCR_LANGUAGE
internal fun defaultPdfBottomTools(): Set<String> {
return PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
} }
val PdfBuiltInThemes = listOf( internal fun defaultPdfToolOrder(): List<PdfReaderTool> = PdfReaderTool.entries.filter(::isPdfReaderToolAvailable)
ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true), internal fun defaultPdfBottomTools(): Set<String> {
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false), return defaultPdfToolOrder().filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true), }
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true), val PdfBuiltInThemes = BuiltInPdfReaderThemes
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), private fun sanitizePdfToolNameSet(
ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id), toolNames: Set<String>,
ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id), includeTool: (PdfReaderTool) -> Boolean = { true }
ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id), ): Set<String> {
ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id), return toolNames.mapNotNull { toolName ->
ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id) PdfReaderTool.entries
) .firstOrNull { it.name == toolName }
?.takeIf { isPdfReaderToolAvailable(it) && includeTool(it) }
?.name
}.toSet()
}
internal fun loadPdfHiddenTools(context: Context): Set<String> { internal fun loadPdfHiddenTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val savedHiddenTools = prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty() val savedHiddenTools = sanitizePdfToolNameSet(prefs.getStringSet(PDF_HIDDEN_TOOLS_KEY, emptySet()).orEmpty())
val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0) val defaultsVersion = prefs.getInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) { if (defaultsVersion < PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) {
val migratedHiddenTools = savedHiddenTools + defaultPdfHiddenTools() val migratedHiddenTools = sanitizePdfToolNameSet(savedHiddenTools + pdfHiddenToolsIntroducedAfter(defaultsVersion))
prefs.edit { prefs.edit {
putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools) putStringSet(PDF_HIDDEN_TOOLS_KEY, migratedHiddenTools)
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
@ -124,10 +130,20 @@ internal fun loadPdfHiddenTools(context: Context): Set<String> {
return savedHiddenTools return savedHiddenTools
} }
private fun pdfHiddenToolsIntroducedAfter(defaultsVersion: Int): Set<String> {
return buildSet {
if (defaultsVersion < 2) {
add(PdfReaderTool.SCREEN_ORIENTATION.name)
add(PdfReaderTool.HIGHLIGHT_ALL.name)
}
if (defaultsVersion < 3) add(PdfReaderTool.BRIGHTNESS.name)
}
}
internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) { internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { prefs.edit {
putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) putStringSet(PDF_HIDDEN_TOOLS_KEY, sanitizePdfToolNameSet(hiddenTools))
putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION) putInt(PDF_HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, PDF_HIDDEN_TOOLS_DEFAULTS_VERSION)
} }
} }
@ -138,24 +154,41 @@ internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
?.split(',') ?.split(',')
?.filter { it.isNotBlank() } ?.filter { it.isNotBlank() }
?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } } ?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
?.filter(::isPdfReaderToolAvailable)
.orEmpty() .orEmpty()
return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct() return (savedTools + defaultPdfToolOrder().filterNot { it in savedTools }).distinct()
} }
internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) { internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PDF_TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) } prefs.edit {
putString(
PDF_TOOL_ORDER_KEY,
toolOrder.filter(::isPdfReaderToolAvailable).joinToString(",") { it.name }
)
}
} }
internal fun loadPdfBottomTools(context: Context): Set<String> { internal fun loadPdfBottomTools(context: Context): Set<String> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val defaultBottomTools = defaultPdfBottomTools() val defaultBottomTools = defaultPdfBottomTools()
return prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools return sanitizePdfToolNameSet(
toolNames = prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools,
includeTool = { it.category == "Bottom Bar" }
)
} }
internal fun savePdfBottomTools(context: Context, bottomTools: Set<String>) { internal fun savePdfBottomTools(context: Context, bottomTools: Set<String>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putStringSet(PDF_BOTTOM_TOOLS_KEY, bottomTools) } prefs.edit {
putStringSet(
PDF_BOTTOM_TOOLS_KEY,
sanitizePdfToolNameSet(
toolNames = bottomTools,
includeTool = { it.category == "Bottom Bar" }
)
)
}
} }
internal fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> { internal fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> {
@ -217,6 +250,38 @@ internal fun loadPdfPageNumberOverlayVisible(context: Context): Boolean {
return prefs.getBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, true) return prefs.getBoolean(PDF_PAGE_NUMBER_OVERLAY_VISIBLE_KEY, true)
} }
internal fun savePdfPageSpreadMode(context: Context, mode: ReaderPageSpreadMode) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PDF_PAGE_SPREAD_MODE_KEY, mode.name) }
}
internal fun loadPdfPageSpreadMode(context: Context): ReaderPageSpreadMode {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val modeName = prefs.getString(PDF_PAGE_SPREAD_MODE_KEY, ReaderPageSpreadMode.SINGLE.name)
return runCatching { ReaderPageSpreadMode.valueOf(modeName ?: ReaderPageSpreadMode.SINGLE.name) }
.getOrDefault(ReaderPageSpreadMode.SINGLE)
}
internal fun savePdfFirstPageStandaloneInSpread(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_FIRST_PAGE_STANDALONE_IN_SPREAD_KEY, isEnabled) }
}
internal fun loadPdfFirstPageStandaloneInSpread(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_FIRST_PAGE_STANDALONE_IN_SPREAD_KEY, false)
}
internal fun savePdfTopTabStripVisible(context: Context, isVisible: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_TOP_TAB_STRIP_VISIBLE_KEY, isVisible) }
}
internal fun loadPdfTopTabStripVisible(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_TOP_TAB_STRIP_VISIBLE_KEY, true)
}
internal fun savePdfThemeId(context: Context, themeId: String) { internal fun savePdfThemeId(context: Context, themeId: String) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PDF_THEME_KEY, themeId) } prefs.edit { putString(PDF_THEME_KEY, themeId) }

View file

@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
@ -72,6 +73,8 @@ import androidx.compose.ui.window.DialogProperties
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.epubreader.OptionSegmentedControl import com.aryan.reader.epubreader.OptionSegmentedControl
import com.aryan.reader.epubreader.SystemUiMode import com.aryan.reader.epubreader.SystemUiMode
import com.aryan.reader.epubreader.titleRes
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
enum class PdfFlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL } enum class PdfFlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
@ -114,7 +117,7 @@ fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem>
} }
private val pdfReorderableToolbarTools = setOf( private val pdfReorderableToolbarTools = setOf(
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING, PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.BRIGHTNESS, PdfReaderTool.LOCK_PANNING,
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH, PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES, PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS, PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS,
@ -126,11 +129,12 @@ internal fun buildPdfToolbarItems(
toolOrder: List<PdfReaderTool>, toolOrder: List<PdfReaderTool>,
bottomTools: Set<String> bottomTools: Set<String>
): List<PdfFlatToolItem> { ): List<PdfFlatToolItem> {
val toolbarTools = toolOrder.filter { it in pdfReorderableToolbarTools } val availableToolOrder = toolOrder.filter(::isPdfReaderToolAvailable)
val toolbarTools = availableToolOrder.filter { it in pdfReorderableToolbarTools }
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) } 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 bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) } val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
val moreTools = toolOrder.filter { it !in pdfReorderableToolbarTools } val moreTools = availableToolOrder.filter { it !in pdfReorderableToolbarTools }
val list = mutableListOf<PdfFlatToolItem>() val list = mutableListOf<PdfFlatToolItem>()
@ -541,7 +545,9 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
when (tool) { when (tool) {
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp)) PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = title, modifier = Modifier.size(20.dp))
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp)) PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = title, modifier = Modifier.size(20.dp))
PdfReaderTool.BRIGHTNESS -> Icon(painterResource(id = R.drawable.contrast), contentDescription = title, modifier = Modifier.size(20.dp))
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = title, modifier = Modifier.size(20.dp)) PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = title, modifier = Modifier.size(20.dp))
PdfReaderTool.FILE_INFO -> Icon(Icons.Default.Info, contentDescription = title, modifier = Modifier.size(20.dp))
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp)) PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = title, modifier = Modifier.size(20.dp))
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp)) PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = title, modifier = Modifier.size(20.dp))
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp)) PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
@ -556,9 +562,14 @@ private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
@Composable @Composable
fun PdfVisualOptionsSheet( fun PdfVisualOptionsSheet(
displayMode: DisplayMode,
systemUiMode: SystemUiMode, systemUiMode: SystemUiMode,
pageSpreadMode: ReaderPageSpreadMode,
firstPageStandaloneInSpread: Boolean,
showVerticalPageGap: Boolean, showVerticalPageGap: Boolean,
showPageNumberOverlay: Boolean, showPageNumberOverlay: Boolean,
onPageSpreadModeChange: (ReaderPageSpreadMode) -> Unit,
onFirstPageStandaloneInSpreadChange: (Boolean) -> Unit,
onSystemUiModeChange: (SystemUiMode) -> Unit, onSystemUiModeChange: (SystemUiMode) -> Unit,
onShowVerticalPageGapChange: (Boolean) -> Unit, onShowVerticalPageGapChange: (Boolean) -> Unit,
onShowPageNumberOverlayChange: (Boolean) -> Unit, onShowPageNumberOverlayChange: (Boolean) -> Unit,
@ -606,6 +617,35 @@ fun PdfVisualOptionsSheet(
Text(stringResource(R.string.visual_options_page_layout), style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.visual_options_page_layout), style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
if (displayMode == DisplayMode.PAGINATION) {
Text(
stringResource(R.string.visual_options_pdf_page_spread),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
OptionSegmentedControl(
options = ReaderPageSpreadMode.entries,
selectedOption = pageSpreadMode,
onOptionSelected = onPageSpreadModeChange,
getLabel = {
when (it) {
ReaderPageSpreadMode.SINGLE -> stringResource(R.string.visual_options_pdf_spread_single)
ReaderPageSpreadMode.TWO_PAGE -> stringResource(R.string.visual_options_pdf_spread_two)
}
}
)
if (pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE) {
Spacer(modifier = Modifier.height(8.dp))
PdfVisualOptionSwitchRow(
title = stringResource(R.string.visual_options_pdf_first_page_alone),
description = stringResource(R.string.visual_options_pdf_first_page_alone_desc),
checked = firstPageStandaloneInSpread,
onCheckedChange = onFirstPageStandaloneInSpreadChange
)
}
Spacer(modifier = Modifier.height(12.dp))
}
PdfVisualOptionSwitchRow( PdfVisualOptionSwitchRow(
title = stringResource(R.string.visual_options_remove_page_gap), title = stringResource(R.string.visual_options_remove_page_gap),
description = stringResource(R.string.visual_options_remove_page_gap_desc), description = stringResource(R.string.visual_options_remove_page_gap_desc),

View file

@ -43,6 +43,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -55,6 +56,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@ -85,6 +87,63 @@ enum class HandlePosition {
TOP, BOTTOM, AUTO TOP, BOTTOM, AUTO
} }
private const val TEXT_BOX_DRAG_PILL_VISUAL_WIDTH_DP = 48f
private const val TEXT_BOX_DRAG_PILL_VISUAL_HEIGHT_DP = 24f
private const val TEXT_BOX_DRAG_PILL_TOUCH_WIDTH_DP = 72f
private const val TEXT_BOX_DRAG_PILL_TOUCH_HEIGHT_DP = 48f
private const val TEXT_BOX_DRAG_PILL_GAP_DP = 8f
internal data class TextBoxChromeLayout(
val containerWidthPx: Float,
val containerHeightPx: Float,
val contentWidthPx: Float,
val contentHeightPx: Float,
val contentOffsetX: Float,
val contentOffsetY: Float,
val outerTranslationX: Float,
val outerTranslationY: Float,
val dragPillLeftPx: Float,
val dragPillTopPx: Float
)
internal fun calculateTextBoxChromeLayout(
textBoundsPx: Rect,
isSelected: Boolean,
isHandleAtTop: Boolean,
handleSizePx: Float,
dragPillWidthPx: Float,
dragPillHeightPx: Float,
dragPillGapPx: Float
): TextBoxChromeLayout {
val halfHandlePx = handleSizePx / 2f
val contentWidthPx = textBoundsPx.width + handleSizePx
val contentHeightPx = textBoundsPx.height + handleSizePx
val dragPillTrackHeightPx = if (isSelected) dragPillHeightPx + dragPillGapPx else 0f
val containerWidthPx = maxOf(contentWidthPx, if (isSelected) dragPillWidthPx else contentWidthPx)
val containerHeightPx = contentHeightPx + dragPillTrackHeightPx
val contentOffsetX = (containerWidthPx - contentWidthPx) / 2f
val contentOffsetY = if (isSelected && isHandleAtTop) dragPillTrackHeightPx else 0f
val dragPillLeftPx = (containerWidthPx - dragPillWidthPx) / 2f
val dragPillTopPx = if (isSelected && isHandleAtTop) {
0f
} else {
containerHeightPx - dragPillHeightPx
}
return TextBoxChromeLayout(
containerWidthPx = containerWidthPx,
containerHeightPx = containerHeightPx,
contentWidthPx = contentWidthPx,
contentHeightPx = contentHeightPx,
contentOffsetX = contentOffsetX,
contentOffsetY = contentOffsetY,
outerTranslationX = textBoundsPx.left - halfHandlePx - contentOffsetX,
outerTranslationY = textBoundsPx.top - halfHandlePx - contentOffsetY,
dragPillLeftPx = dragPillLeftPx,
dragPillTopPx = dragPillTopPx
)
}
// Eagerly consumes pointer events so parent scaled pan/zoom gestures don't intercept it // Eagerly consumes pointer events so parent scaled pan/zoom gestures don't intercept it
suspend fun PointerInputScope.detectEagerDragGestures( suspend fun PointerInputScope.detectEagerDragGestures(
onDragStart: (Offset) -> Unit, onDragStart: (Offset) -> Unit,
@ -95,14 +154,14 @@ suspend fun PointerInputScope.detectEagerDragGestures(
awaitEachGesture { awaitEachGesture {
var dragStarted = false var dragStarted = false
try { try {
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
down.consume() // Consume immediately down.consume() // Consume immediately
onDragStart(down.position) onDragStart(down.position)
dragStarted = true dragStarted = true
val pointerId = down.id val pointerId = down.id
var canceled = false var canceled = false
while (true) { while (true) {
val event = awaitPointerEvent() val event = awaitPointerEvent(PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == pointerId } val change = event.changes.firstOrNull { it.id == pointerId }
if (change == null) { if (change == null) {
canceled = true canceled = true
@ -151,6 +210,13 @@ fun ResizableTextBox(
val density = LocalDensity.current val density = LocalDensity.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val currentOnBoundsChanged by rememberUpdatedState(onBoundsChanged)
val currentOnTextChanged by rememberUpdatedState(onTextChanged)
val currentOnSelect by rememberUpdatedState(onSelect)
val currentOnDragStart by rememberUpdatedState(onDragStart)
val currentOnDrag by rememberUpdatedState(onDrag)
val currentOnDragEnd by rememberUpdatedState(onDragEnd)
val currentOnDragCancel by rememberUpdatedState(onDragCancel)
// Counter-scale fixed sizes so they render proportionally regardless of the zoom level // Counter-scale fixed sizes so they render proportionally regardless of the zoom level
val handleSize = (10f / scale).dp val handleSize = (10f / scale).dp
@ -236,170 +302,207 @@ fun ResizableTextBox(
} }
} }
val dragPillTouchWidth = (TEXT_BOX_DRAG_PILL_TOUCH_WIDTH_DP / scale).dp
val dragPillTouchHeight = (TEXT_BOX_DRAG_PILL_TOUCH_HEIGHT_DP / scale).dp
val dragPillWidthPx = with(density) { dragPillTouchWidth.toPx() }
val dragPillHeightPx = with(density) { dragPillTouchHeight.toPx() }
val dragPillGapPx = with(density) { (TEXT_BOX_DRAG_PILL_GAP_DP / scale).dp.toPx() }
val chromeLayout = calculateTextBoxChromeLayout(
textBoundsPx = currentRectPx,
isSelected = isSelected,
isHandleAtTop = isHandleAtTop,
handleSizePx = handleSizePx,
dragPillWidthPx = dragPillWidthPx,
dragPillHeightPx = dragPillHeightPx,
dragPillGapPx = dragPillGapPx
)
Box( Box(
modifier = modifier modifier = modifier
.zIndex(if (isSelected) 10f else 0f) .zIndex(if (isSelected) 10f else 0f)
.graphicsLayer { .graphicsLayer {
translationX = currentRectPx.left - halfHandlePx translationX = chromeLayout.outerTranslationX
translationY = currentRectPx.top - halfHandlePx translationY = chromeLayout.outerTranslationY
} }
.size( .size(
width = with(density) { (currentRectPx.width + handleSizePx).toDp() }, width = with(density) { chromeLayout.containerWidthPx.toDp() },
height = with(density) { (currentRectPx.height + handleSizePx).toDp() } height = with(density) { chromeLayout.containerHeightPx.toDp() }
) )
) { ) {
// --- 1. Content Body ---
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .offset {
.padding(handleSize / 2) IntOffset(
.pointerInput(Unit) { chromeLayout.contentOffsetX.roundToInt(),
detectTapGestures { chromeLayout.contentOffsetY.roundToInt()
Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]") )
onSelect()
}
} }
.then( .size(
if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier width = with(density) { chromeLayout.contentWidthPx.toDp() },
height = with(density) { chromeLayout.contentHeightPx.toDp() }
) )
.zIndex(1f)
) { ) {
BasicTextField( // --- 1. Content Body ---
value = box.text, Box(
onValueChange = onTextChanged,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(8.dp) .padding(handleSize / 2)
.verticalScroll(rememberScrollState()) .pointerInput(Unit) {
.focusRequester(focusRequester), detectTapGestures {
textStyle = TextStyle( Timber.tag("PdfTextBoxDebug").d("TextBox Tapped[ID: ${box.id}]")
color = box.color, currentOnSelect()
background = box.backgroundColor, }
fontFamily = fontFamily,
fontSize = with(LocalDensity.current) {
(box.fontSize * pageHeightPx).toSp()
},
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
textDecoration = run {
val decs = mutableListOf<TextDecoration>()
if (box.isUnderline) decs.add(TextDecoration.Underline)
if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough)
if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs)
} }
), .then(
cursorBrush = SolidColor(if (isDarkMode) Color.White else MaterialTheme.colorScheme.primary), if (isSelected) Modifier.border((1.5f / scale).dp, borderColor) else Modifier
enabled = isEditMode && isSelected, )
readOnly = !isEditMode ) {
) BasicTextField(
value = box.text,
onValueChange = currentOnTextChanged,
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
.verticalScroll(rememberScrollState())
.focusRequester(focusRequester),
textStyle = TextStyle(
color = box.color,
background = box.backgroundColor,
fontFamily = fontFamily,
fontSize = with(LocalDensity.current) {
(box.fontSize * pageHeightPx).toSp()
},
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
textDecoration = run {
val decs = mutableListOf<TextDecoration>()
if (box.isUnderline) decs.add(TextDecoration.Underline)
if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough)
if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs)
}
),
cursorBrush = SolidColor(if (isDarkMode) Color.White else MaterialTheme.colorScheme.primary),
enabled = isEditMode && isSelected,
readOnly = !isEditMode
)
}
if (isSelected) {
val handles = ResizeHandle.entries.filter { it != ResizeHandle.NONE }
fun getHandleCenter(handle: ResizeHandle, w: Float, h: Float): Offset {
return when (handle) {
ResizeHandle.TOP_LEFT -> Offset(halfHandlePx, halfHandlePx)
ResizeHandle.TOP_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx)
ResizeHandle.TOP_RIGHT -> Offset(halfHandlePx + w, halfHandlePx)
ResizeHandle.RIGHT_CENTER -> Offset(halfHandlePx + w, halfHandlePx + h / 2)
ResizeHandle.BOTTOM_RIGHT -> Offset(halfHandlePx + w, halfHandlePx + h)
ResizeHandle.BOTTOM_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx + h)
ResizeHandle.BOTTOM_LEFT -> Offset(halfHandlePx, halfHandlePx + h)
ResizeHandle.LEFT_CENTER -> Offset(halfHandlePx, halfHandlePx + h / 2)
else -> Offset.Zero
}
}
handles.forEach { handle ->
val center = getHandleCenter(handle, currentRectPx.width, currentRectPx.height)
Box(
modifier = Modifier
.offset {
IntOffset(
(center.x - handleTouchSizePx / 2).roundToInt(),
(center.y - handleTouchSizePx / 2).roundToInt()
)
}
.size(handleTouchSize)
.pointerInput(box.id, handle, pageWidthPx, pageHeightPx) {
detectEagerDragGestures(
onDragStart = {
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle")
isDraggingOrResizing = true
},
onDragEnd = {
isDraggingOrResizing = false
val normalized = Rect(
left = currentRectPx.left / pageWidthPx,
top = currentRectPx.top / pageHeightPx,
right = currentRectPx.right / pageWidthPx,
bottom = currentRectPx.bottom / pageHeightPx
)
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized")
currentOnBoundsChanged(normalized)
},
onDragCancel = { isDraggingOrResizing = false }
) { change, dragAmount ->
Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount")
var l = currentRectPx.left
var t = currentRectPx.top
var r = currentRectPx.right
var b = currentRectPx.bottom
val dx = dragAmount.x
val dy = dragAmount.y
val minSize = 50f / scale
when (handle) {
ResizeHandle.TOP_LEFT -> {
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
}
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
ResizeHandle.TOP_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
}
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
ResizeHandle.BOTTOM_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
}
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
ResizeHandle.BOTTOM_LEFT -> {
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
}
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
else -> {}
}
currentRectPx = Rect(l, t, r, b)
}
}
) {
Box(
modifier = Modifier
.size(handleSize)
.background(handleColor, CircleShape)
.align(Alignment.Center)
)
}
}
}
} }
if (isSelected) { if (isSelected) {
val handles = ResizeHandle.entries.filter { it != ResizeHandle.NONE }
fun getHandleCenter(handle: ResizeHandle, w: Float, h: Float): Offset {
return when (handle) {
ResizeHandle.TOP_LEFT -> Offset(halfHandlePx, halfHandlePx)
ResizeHandle.TOP_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx)
ResizeHandle.TOP_RIGHT -> Offset(halfHandlePx + w, halfHandlePx)
ResizeHandle.RIGHT_CENTER -> Offset(halfHandlePx + w, halfHandlePx + h / 2)
ResizeHandle.BOTTOM_RIGHT -> Offset(halfHandlePx + w, halfHandlePx + h)
ResizeHandle.BOTTOM_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx + h)
ResizeHandle.BOTTOM_LEFT -> Offset(halfHandlePx, halfHandlePx + h)
ResizeHandle.LEFT_CENTER -> Offset(halfHandlePx, halfHandlePx + h / 2)
else -> Offset.Zero
}
}
handles.forEach { handle ->
val center = getHandleCenter(handle, currentRectPx.width, currentRectPx.height)
Box(
modifier = Modifier
.offset {
IntOffset(
(center.x - handleTouchSizePx / 2).roundToInt(),
(center.y - handleTouchSizePx / 2).roundToInt()
)
}
.size(handleTouchSize)
.pointerInput(onBoundsChanged) {
detectEagerDragGestures(
onDragStart = {
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragStart[ID: ${box.id}] Handle=$handle")
isDraggingOrResizing = true
},
onDragEnd = {
isDraggingOrResizing = false
val normalized = Rect(
left = currentRectPx.left / pageWidthPx,
top = currentRectPx.top / pageHeightPx,
right = currentRectPx.right / pageWidthPx,
bottom = currentRectPx.bottom / pageHeightPx
)
Timber.tag("PdfTextBoxDebug").d("ResizeHandle DragEnd [ID: ${box.id}] finalNormalized=$normalized")
onBoundsChanged(normalized)
},
onDragCancel = { isDraggingOrResizing = false }
) { change, dragAmount ->
Timber.tag("PdfTextBoxDebug").v("ResizeHandle Drag [ID: ${box.id}] Handle=$handle | dragAmount=$dragAmount")
var l = currentRectPx.left
var t = currentRectPx.top
var r = currentRectPx.right
var b = currentRectPx.bottom
val dx = dragAmount.x
val dy = dragAmount.y
val minSize = 50f / scale
when (handle) {
ResizeHandle.TOP_LEFT -> {
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
}
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
ResizeHandle.TOP_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
t = (t + dy).coerceIn(0f, maxOf(0f, b - minSize))
}
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
ResizeHandle.BOTTOM_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, maxOf(l + minSize, pageWidthPx))
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
}
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
ResizeHandle.BOTTOM_LEFT -> {
l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
b = (b + dy).coerceIn(t + minSize, maxOf(t + minSize, pageHeightPx))
}
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, maxOf(0f, r - minSize))
else -> {}
}
currentRectPx = Rect(l, t, r, b)
}
}
) {
Box(
modifier = Modifier
.size(handleSize)
.background(handleColor, CircleShape)
.align(Alignment.Center)
)
}
}
DragPill( DragPill(
isDarkMode = isDarkMode, isDarkMode = isDarkMode,
scale = scale, scale = scale,
modifier = Modifier modifier = Modifier
.align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter) .offset {
.offset(y = if (isHandleAtTop) (-32f / scale).dp else (32f / scale).dp) IntOffset(
chromeLayout.dragPillLeftPx.roundToInt(),
chromeLayout.dragPillTopPx.roundToInt()
)
}
.size(width = dragPillTouchWidth, height = dragPillTouchHeight)
.zIndex(20f) .zIndex(20f)
.pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) { .pointerInput(box.id, pageWidthPx, pageHeightPx) {
detectEagerDragGestures( detectEagerDragGestures(
onDragStart = { offset -> onDragStart = { offset ->
Timber.tag("PdfTextBoxDebug").d("DragPill DragStart [ID: ${box.id}] at offset=$offset") Timber.tag("PdfTextBoxDebug").d("DragPill DragStart [ID: ${box.id}] at offset=$offset")
isDraggingOrResizing = true isDraggingOrResizing = true
onDragStart(offset) currentOnDragStart(offset)
}, },
onDragEnd = { onDragEnd = {
isDraggingOrResizing = false isDraggingOrResizing = false
@ -410,12 +513,12 @@ fun ResizableTextBox(
bottom = currentRectPx.bottom / pageHeightPx bottom = currentRectPx.bottom / pageHeightPx
) )
Timber.tag("PdfTextBoxDebug").d("DragPill DragEnd[ID: ${box.id}] finalNormalized=$normalized") Timber.tag("PdfTextBoxDebug").d("DragPill DragEnd[ID: ${box.id}] finalNormalized=$normalized")
onBoundsChanged(normalized) currentOnBoundsChanged(normalized)
onDragEnd() currentOnDragEnd()
}, },
onDragCancel = { onDragCancel = {
isDraggingOrResizing = false isDraggingOrResizing = false
onDragCancel() currentOnDragCancel()
} }
) { change, dragAmount -> ) { change, dragAmount ->
val w = currentRectPx.width val w = currentRectPx.width
@ -426,7 +529,7 @@ fun ResizableTextBox(
val newTop = rawTop.coerceIn(0f, maxOf(0f, pageHeightPx - h)) val newTop = rawTop.coerceIn(0f, maxOf(0f, pageHeightPx - h))
val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h) val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h)
currentRectPx = newRect currentRectPx = newRect
onDrag(dragAmount, newRect) currentOnDrag(dragAmount, newRect)
} }
} }
) )
@ -440,20 +543,28 @@ private fun DragPill(
isDarkMode: Boolean, isDarkMode: Boolean,
scale: Float = 1f scale: Float = 1f
) { ) {
Surface( Box(
modifier = modifier modifier = modifier,
.size(width = (48f / scale).dp, height = (24f / scale).dp), contentAlignment = Alignment.Center
shape = CircleShape,
color = if (isDarkMode) Color.White else Color.Black,
contentColor = if (isDarkMode) Color.Black else Color.White,
shadowElevation = (4f / scale).dp
) { ) {
Box(contentAlignment = Alignment.Center) { Surface(
Icon( modifier = Modifier
painter = painterResource(id = R.drawable.drag_handle), .size(
contentDescription = stringResource(R.string.content_desc_drag_text_box), width = (TEXT_BOX_DRAG_PILL_VISUAL_WIDTH_DP / scale).dp,
modifier = Modifier.size((20f / scale).dp) height = (TEXT_BOX_DRAG_PILL_VISUAL_HEIGHT_DP / scale).dp
) ),
shape = CircleShape,
color = if (isDarkMode) Color.White else Color.Black,
contentColor = if (isDarkMode) Color.Black else Color.White,
shadowElevation = (4f / scale).dp
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(id = R.drawable.drag_handle),
contentDescription = stringResource(R.string.content_desc_drag_text_box),
modifier = Modifier.size((20f / scale).dp)
)
}
} }
} }
} }

View file

@ -133,22 +133,23 @@ object PdfToHtmlGenerator {
headerFooterStrings: Set<String> headerFooterStrings: Set<String>
): String { ): String {
return try { return try {
doc.openPage(pageIdx)?.use { page -> PdfiumEngineProvider.withPdfium {
page.openTextPage().use { textPage -> doc.openPage(pageIdx)?.use { page ->
val charCount = textPage.textPageCountChars() page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars()
val pagePtr = getNativePointer(page) val pagePtr = getNativePointer(page)
val textPagePtr = getNativePointer(textPage) val textPagePtr = getNativePointer(textPage)
val imageElements = mutableListOf<ImageElement>() val imageElements = mutableListOf<ImageElement>()
val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr) val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
for (i in 0 until objCount) { for (i in 0 until objCount) {
if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) { if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) {
val bbox = FloatArray(4) val bbox = FloatArray(4)
if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, bbox)) { if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
val topY = bbox[3] val topY = bbox[3]
val dimens = IntArray(2) val dimens = IntArray(2)
val pixels = PdfiumEngineProvider.bridge.extractImagePixels(pagePtr, i, dimens) val pixels = NativePdfiumBridge.extractImagePixels(pagePtr, i, dimens)
if (pixels != null && dimens[0] > 0 && dimens[1] > 0) { if (pixels != null && dimens[0] > 0 && dimens[1] > 0) {
try { try {
val bmp = Bitmap.createBitmap(pixels, dimens[0], dimens[1], Bitmap.Config.ARGB_8888) val bmp = Bitmap.createBitmap(pixels, dimens[0], dimens[1], Bitmap.Config.ARGB_8888)
@ -179,12 +180,10 @@ object PdfToHtmlGenerator {
val flags: IntArray? val flags: IntArray?
val charBoxes: FloatArray? val charBoxes: FloatArray?
synchronized(PdfiumEngineProvider.lock) { sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount)
sizes = PdfiumEngineProvider.bridge.getPageFontSizes(textPagePtr, actualCount) weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount)
weights = PdfiumEngineProvider.bridge.getPageFontWeights(textPagePtr, actualCount) flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount)
flags = PdfiumEngineProvider.bridge.getPageFontFlags(textPagePtr, actualCount) charBoxes = NativePdfiumBridge.getPageCharBoxes(textPagePtr, actualCount)
charBoxes = PdfiumEngineProvider.bridge.getPageCharBoxes(textPagePtr, actualCount)
}
if (sizes == null || weights == null || flags == null) { if (sizes == null || weights == null || flags == null) {
return@use buildFallbackPageSection(pageNumber, rawText) return@use buildFallbackPageSection(pageNumber, rawText)
@ -302,8 +301,9 @@ object PdfToHtmlGenerator {
} }
buildPageHtml(pageNumber, finalElements, headerFooterStrings) buildPageHtml(pageNumber, finalElements, headerFooterStrings)
} }
} ?: buildEmptyPageSection(pageNumber) } ?: buildEmptyPageSection(pageNumber)
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).w(e, "Error extracting page $pageIdx") Timber.tag(TAG).w(e, "Error extracting page $pageIdx")
buildEmptyPageSection(pageNumber) buildEmptyPageSection(pageNumber)
@ -506,17 +506,19 @@ object PdfToHtmlGenerator {
for (pageIdx in samplePages) { for (pageIdx in samplePages) {
try { try {
doc.openPage(pageIdx)?.use { page -> PdfiumEngineProvider.withPdfium {
page.openTextPage().use { textPage -> doc.openPage(pageIdx)?.use { page ->
val charCount = textPage.textPageCountChars() page.openTextPage().use { textPage ->
if (charCount <= 0) return@use val charCount = textPage.textPageCountChars()
val rawText = textPage.textPageGetText(0, charCount) ?: return@use if (charCount <= 0) return@use
val rawText = textPage.textPageGetText(0, charCount) ?: return@use
val lines = rawText.split('\n').map { it.trim() }.filter { it.length > 2 } val lines = rawText.split('\n').map { it.trim() }.filter { it.length > 2 }
if (lines.isNotEmpty()) { if (lines.isNotEmpty()) {
val edgeLines = lines.take(2) + lines.takeLast(2) val edgeLines = lines.take(2) + lines.takeLast(2)
for (line in edgeLines) { for (line in edgeLines) {
frequency[line] = (frequency[line] ?: 0) + 1 frequency[line] = (frequency[line] ?: 0) + 1
}
} }
} }
} }

View file

@ -44,6 +44,7 @@ import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar import com.aryan.reader.SearchTopBar
import com.aryan.reader.TooltipIconButton import com.aryan.reader.TooltipIconButton
import com.aryan.reader.areReaderAiFeaturesEnabled import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.cardTitle
import com.aryan.reader.epubreader.SystemUiMode import com.aryan.reader.epubreader.SystemUiMode
import kotlin.collections.isNotEmpty import kotlin.collections.isNotEmpty
@ -52,6 +53,7 @@ internal val PdfTabStripHeight = 44.dp
private val pdfToolbarTools = setOf( private val pdfToolbarTools = setOf(
PdfReaderTool.DICTIONARY, PdfReaderTool.DICTIONARY,
PdfReaderTool.THEME, PdfReaderTool.THEME,
PdfReaderTool.BRIGHTNESS,
PdfReaderTool.LOCK_PANNING, PdfReaderTool.LOCK_PANNING,
PdfReaderTool.SLIDER, PdfReaderTool.SLIDER,
PdfReaderTool.TOC, PdfReaderTool.TOC,
@ -63,6 +65,61 @@ private val pdfToolbarTools = setOf(
PdfReaderTool.SCREEN_ORIENTATION PdfReaderTool.SCREEN_ORIENTATION
) )
internal enum class PdfOverflowMenuSection {
CUSTOMIZE_TOOLBAR,
HIDDEN_TOOLS,
OCR_LANGUAGE,
VISUAL_OPTIONS,
READING_MODE,
TAP_TO_TURN,
KEEP_SCREEN_ON,
AUTO_SCROLL,
TTS_SETTINGS,
BOOKMARK,
PAGE_MANAGEMENT,
REFLOW,
FILE_ACTIONS,
FILE_INFO
}
internal fun pdfOverflowMenuSections(
hiddenTools: Set<String>,
hasHiddenToolbarTools: Boolean,
isPro: Boolean,
effectiveFileType: FileType,
hasFileInfo: Boolean = true
): List<PdfOverflowMenuSection> = buildList {
add(PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR)
if (hasHiddenToolbarTools) add(PdfOverflowMenuSection.HIDDEN_TOOLS)
if (isPro && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
add(PdfOverflowMenuSection.OCR_LANGUAGE)
}
if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) add(PdfOverflowMenuSection.VISUAL_OPTIONS)
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) add(PdfOverflowMenuSection.READING_MODE)
if (!hiddenTools.contains(PdfReaderTool.TAP_TO_TURN.name)) add(PdfOverflowMenuSection.TAP_TO_TURN)
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) add(PdfOverflowMenuSection.KEEP_SCREEN_ON)
if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) add(PdfOverflowMenuSection.AUTO_SCROLL)
if (
!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name) ||
!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
) {
add(PdfOverflowMenuSection.TTS_SETTINGS)
}
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) add(PdfOverflowMenuSection.BOOKMARK)
if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) add(PdfOverflowMenuSection.PAGE_MANAGEMENT)
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) add(PdfOverflowMenuSection.REFLOW)
if (
!hiddenTools.contains(PdfReaderTool.SHARE.name) ||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)) ||
(effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name))
) {
add(PdfOverflowMenuSection.FILE_ACTIONS)
}
if (hasFileInfo && !hiddenTools.contains(PdfReaderTool.FILE_INFO.name)) {
add(PdfOverflowMenuSection.FILE_INFO)
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun PdfTopBar( internal fun PdfTopBar(
@ -76,6 +133,7 @@ internal fun PdfTopBar(
isLoadingDocument: Boolean, isLoadingDocument: Boolean,
errorMessage: String?, errorMessage: String?,
currentPageForDisplay: Int, currentPageForDisplay: Int,
currentPageLabel: String? = null,
totalPages: Int, totalPages: Int,
pagerStatePageCount: Int, pagerStatePageCount: Int,
hiddenTools: Set<String>, hiddenTools: Set<String>,
@ -87,6 +145,7 @@ internal fun PdfTopBar(
isRightToLeftPagination: Boolean, isRightToLeftPagination: Boolean,
isKeepScreenOn: Boolean, isKeepScreenOn: Boolean,
isTtsSessionActive: Boolean, isTtsSessionActive: Boolean,
isSliderActive: Boolean,
isBookmarked: Boolean, isBookmarked: Boolean,
canDeletePage: Boolean, canDeletePage: Boolean,
isReflowingThisBook: Boolean, isReflowingThisBook: Boolean,
@ -95,9 +154,11 @@ internal fun PdfTopBar(
isTabsEnabled: Boolean, isTabsEnabled: Boolean,
openTabs: List<RecentFileItem>, openTabs: List<RecentFileItem>,
activeTabBookId: String?, activeTabBookId: String?,
usePdfFileNameAsDisplayName: Boolean,
effectiveFileType: FileType, effectiveFileType: FileType,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onShowThemePanel: () -> Unit, onShowThemePanel: () -> Unit,
onShowBrightnessControl: () -> Unit,
onToggleScrollLock: () -> Unit, onToggleScrollLock: () -> Unit,
onShowDictionarySettings: () -> Unit, onShowDictionarySettings: () -> Unit,
onShowPenPlayground: () -> Unit, onShowPenPlayground: () -> Unit,
@ -125,6 +186,7 @@ internal fun PdfTopBar(
onShowTtsSettings: () -> Unit, onShowTtsSettings: () -> Unit,
onShowTtsReplacements: () -> Unit, onShowTtsReplacements: () -> Unit,
onToggleBookmark: () -> Unit, onToggleBookmark: () -> Unit,
onShowFileInfo: () -> Unit,
onInsertPage: () -> Unit, onInsertPage: () -> Unit,
onDeletePage: () -> Unit, onDeletePage: () -> Unit,
onReflowAction: () -> Unit, onReflowAction: () -> Unit,
@ -176,7 +238,8 @@ internal fun PdfTopBar(
val titleText = when { val titleText = when {
isLoadingDocument -> stringResource(R.string.loading_pdf) isLoadingDocument -> stringResource(R.string.loading_pdf)
errorMessage != null -> stringResource(R.string.error_loading_pdf) errorMessage != null -> stringResource(R.string.error_loading_pdf)
totalPages > 0 && pagerStatePageCount > 0 -> stringResource(R.string.page_of_pages, currentPageForDisplay + 1, totalPages) totalPages > 0 && pagerStatePageCount > 0 -> currentPageLabel
?: stringResource(R.string.page_of_pages, currentPageForDisplay + 1, totalPages)
totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page) totalPages > 0 && pagerStatePageCount == 0 -> stringResource(R.string.loading_page)
else -> stringResource(R.string.pdf_viewer) else -> stringResource(R.string.pdf_viewer)
} }
@ -199,6 +262,13 @@ internal fun PdfTopBar(
) { ) {
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
PdfReaderTool.BRIGHTNESS -> TooltipIconButton(
text = stringResource(R.string.reader_brightness_title),
description = stringResource(R.string.reader_brightness_system_desc),
onClick = onShowBrightnessControl
) {
Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.LOCK_PANNING -> TooltipIconButton( PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
text = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), 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), description = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan_desc) else stringResource(R.string.tooltip_lock_pan_desc),
@ -219,7 +289,11 @@ internal fun PdfTopBar(
onClick = onShowSlider, onClick = onShowSlider,
enabled = !isTtsPlayingOrLoading enabled = !isTtsPlayingOrLoading
) { ) {
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider)) Icon(
painterResource(id = R.drawable.slider),
contentDescription = stringResource(R.string.content_desc_navigate_slider),
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
} }
PdfReaderTool.TOC -> TooltipIconButton( PdfReaderTool.TOC -> TooltipIconButton(
text = stringResource(R.string.tooltip_toc), text = stringResource(R.string.tooltip_toc),
@ -321,267 +395,264 @@ internal fun PdfTopBar(
} }
) { ) {
val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) } val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) }
DropdownMenuItem(
text = { Text(stringResource(R.string.title_customize_toolbar)) },
onClick = { showMoreMenu = false; onShowCustomizeTools() },
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.title_customize_toolbar), modifier = Modifier.size(20.dp)) }
)
HorizontalDivider()
if (hiddenToolbarTools.isNotEmpty()) {
DropdownMenuItem(
text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
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,
onShowScreenOrientation = onShowScreenOrientation
)
}
}
HorizontalDivider()
}
if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_ocr_language)) },
onClick = { showMoreMenu = false; onShowOcrLanguage() }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_visual_options)) },
onClick = { showMoreMenu = false; onShowVisualOptions() },
leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
)
}
)
if (showReadingModeExpanded) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
enabled = !isTtsSessionActive,
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
enabled = !isTtsSessionActive,
onClick = {
onSetRightToLeftPagination(false)
onChangeDisplayMode(DisplayMode.PAGINATION)
showMoreMenu = false
},
trailingIcon = {
if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
}
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
enabled = !isTtsSessionActive,
onClick = {
onSetRightToLeftPagination(true)
onChangeDisplayMode(DisplayMode.PAGINATION)
showMoreMenu = false
},
trailingIcon = {
if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
}
}
)
}
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.TAP_TO_TURN.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
enabled = displayMode == DisplayMode.PAGINATION,
onClick = {
onToggleTapToNavigate()
showMoreMenu = false
},
trailingIcon = {
if (tapToNavigateEnabled) {
Icon(
Icons.Filled.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
)
}
}
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
onClick = { onToggleKeepScreenOn(); showMoreMenu = false },
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.AUTO_SCROLL.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_auto_scroll)) },
enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL,
onClick = { showMoreMenu = false; onStartAutoScroll() }
)
HorizontalDivider()
}
val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name) val showTtsVoiceSettings = !hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)
val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name) val showTtsReplacements = !hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)
if (showTtsVoiceSettings || showTtsReplacements) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_settings)) },
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
)
}
)
if (showTtsSettingsExpanded) {
if (showTtsVoiceSettings) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
enabled = !isTtsSessionActive,
onClick = { showMoreMenu = false; onShowTtsSettings() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
}
if (showTtsReplacements) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
onClick = { showMoreMenu = false; onShowTtsReplacements() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
}
}
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
DropdownMenuItem(
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
onClick = { showMoreMenu = false; onToggleBookmark() }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.PAGE_MANAGEMENT.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_insert_blank_page)) },
onClick = { showMoreMenu = false; onInsertPage() }
)
if (canDeletePage) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_delete_page)) },
onClick = { showMoreMenu = false; onDeletePage() },
colors = MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.error)
)
}
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.REFLOW.name)) {
DropdownMenuItem(
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
onClick = { showMoreMenu = false; onReflowAction() },
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
)
HorizontalDivider()
}
val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name) val showShareAction = !hiddenTools.contains(PdfReaderTool.SHARE.name)
val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name) val showSaveCopyAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.SAVE_COPY.name)
val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name) val showPrintAction = effectiveFileType == FileType.PDF && !hiddenTools.contains(PdfReaderTool.PRINT.name)
if (showShareAction || showSaveCopyAction || showPrintAction) { pdfOverflowMenuSections(
DropdownMenuItem( hiddenTools = hiddenTools,
text = { Text(stringResource(R.string.menu_share_save_print)) }, hasHiddenToolbarTools = hiddenToolbarTools.isNotEmpty(),
onClick = { showFileActionsExpanded = !showFileActionsExpanded }, isPro = BuildConfig.IS_PRO,
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }, effectiveFileType = effectiveFileType
trailingIcon = { ).forEachIndexed { index, section ->
Icon( if (index > 0) HorizontalDivider()
Icons.Default.ArrowDropDown, when (section) {
contentDescription = null, PdfOverflowMenuSection.CUSTOMIZE_TOOLBAR -> {
modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f) DropdownMenuItem(
text = { Text(stringResource(R.string.title_customize_toolbar)) },
onClick = { showMoreMenu = false; onShowCustomizeTools() },
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.title_customize_toolbar), modifier = Modifier.size(20.dp)) }
) )
} }
) PdfOverflowMenuSection.HIDDEN_TOOLS -> {
if (showFileActionsExpanded) {
if (showShareAction) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.action_share)) }, text = { Text(stringResource(R.string.toolbar_hidden_tools_menu)) },
onClick = { showMoreMenu = false; onShare() }, onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) } 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,
isSliderActive = isSliderActive,
closeMenu = {
showHiddenToolsExpanded = false
showMoreMenu = false
},
onShowThemePanel = onShowThemePanel,
onShowBrightnessControl = onShowBrightnessControl,
onToggleScrollLock = onToggleScrollLock,
onShowDictionarySettings = onShowDictionarySettings,
onShowSlider = onShowSlider,
onShowToc = onShowToc,
onSearchClick = onSearchClick,
onToggleHighlights = onToggleHighlights,
onShowAiHub = onShowAiHub,
onToggleEditMode = onToggleEditMode,
onToggleTts = onToggleTts,
onShowScreenOrientation = onShowScreenOrientation
)
}
}
}
PdfOverflowMenuSection.FILE_INFO -> {
DropdownMenuItem(
text = { Text(stringResource(R.string.file_information)) },
onClick = { showMoreMenu = false; onShowFileInfo() },
leadingIcon = { Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(20.dp)) }
) )
} }
if (showSaveCopyAction) { PdfOverflowMenuSection.OCR_LANGUAGE -> {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.action_save_copy_to_device)) }, text = { Text(stringResource(R.string.menu_ocr_language)) },
onClick = { showMoreMenu = false; onSaveCopy() }, onClick = { showMoreMenu = false; onShowOcrLanguage() }
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
) )
} }
if (showPrintAction) { PdfOverflowMenuSection.VISUAL_OPTIONS -> {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.action_print)) }, text = { Text(stringResource(R.string.menu_visual_options)) },
onClick = { showMoreMenu = false; onPrint() }, onClick = { showMoreMenu = false; onShowVisualOptions() },
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) } leadingIcon = { Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(20.dp)) }
) )
} }
PdfOverflowMenuSection.READING_MODE -> {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
)
}
)
if (showReadingModeExpanded) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
enabled = !isTtsSessionActive,
onClick = { onChangeDisplayMode(DisplayMode.VERTICAL_SCROLL); showMoreMenu = false },
trailingIcon = { if (displayMode == DisplayMode.VERTICAL_SCROLL) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
enabled = !isTtsSessionActive,
onClick = {
onSetRightToLeftPagination(false)
onChangeDisplayMode(DisplayMode.PAGINATION)
showMoreMenu = false
},
trailingIcon = {
if (displayMode == DisplayMode.PAGINATION && !isRightToLeftPagination) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
}
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
enabled = !isTtsSessionActive,
onClick = {
onSetRightToLeftPagination(true)
onChangeDisplayMode(DisplayMode.PAGINATION)
showMoreMenu = false
},
trailingIcon = {
if (displayMode == DisplayMode.PAGINATION && isRightToLeftPagination) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected))
}
}
)
}
}
PdfOverflowMenuSection.TAP_TO_TURN -> {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
enabled = displayMode == DisplayMode.PAGINATION,
onClick = {
onToggleTapToNavigate()
showMoreMenu = false
},
trailingIcon = {
if (tapToNavigateEnabled) {
Icon(
Icons.Filled.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
)
}
}
)
}
PdfOverflowMenuSection.KEEP_SCREEN_ON -> {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
onClick = { onToggleKeepScreenOn(); showMoreMenu = false },
trailingIcon = { if (isKeepScreenOn) Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.content_desc_selected)) }
)
}
PdfOverflowMenuSection.AUTO_SCROLL -> {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_auto_scroll)) },
enabled = !isTtsSessionActive && displayMode == DisplayMode.VERTICAL_SCROLL,
onClick = { showMoreMenu = false; onStartAutoScroll() }
)
}
PdfOverflowMenuSection.TTS_SETTINGS -> {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_settings)) },
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
)
}
)
if (showTtsSettingsExpanded) {
if (showTtsVoiceSettings) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
enabled = !isTtsSessionActive,
onClick = { showMoreMenu = false; onShowTtsSettings() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
}
if (showTtsReplacements) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
onClick = { showMoreMenu = false; onShowTtsReplacements() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
}
}
}
PdfOverflowMenuSection.BOOKMARK -> {
DropdownMenuItem(
text = { Text(if (isBookmarked) stringResource(R.string.menu_remove_bookmark) else stringResource(R.string.menu_bookmark_this_page)) },
onClick = { showMoreMenu = false; onToggleBookmark() }
)
}
PdfOverflowMenuSection.PAGE_MANAGEMENT -> {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_insert_blank_page)) },
onClick = { showMoreMenu = false; onInsertPage() }
)
if (canDeletePage) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_delete_page)) },
onClick = { showMoreMenu = false; onDeletePage() },
colors = MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.error)
)
}
}
PdfOverflowMenuSection.REFLOW -> {
DropdownMenuItem(
text = { Text(when { isReflowingThisBook -> stringResource(R.string.generating_text_view); hasReflowFile -> stringResource(R.string.action_open_text_view); else -> stringResource(R.string.action_generate_text_view) }) },
enabled = isPdfDocumentLoaded && !isReflowingThisBook,
onClick = { showMoreMenu = false; onReflowAction() },
leadingIcon = { Icon(painterResource(id = R.drawable.format_size), contentDescription = null, modifier = Modifier.size(20.dp)) }
)
}
PdfOverflowMenuSection.FILE_ACTIONS -> {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_share_save_print)) },
onClick = { showFileActionsExpanded = !showFileActionsExpanded },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showFileActionsExpanded) 180f else 0f)
)
}
)
if (showFileActionsExpanded) {
if (showShareAction) {
DropdownMenuItem(
text = { Text(stringResource(R.string.action_share)) },
onClick = { showMoreMenu = false; onShare() },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
)
}
if (showSaveCopyAction) {
DropdownMenuItem(
text = { Text(stringResource(R.string.action_save_copy_to_device)) },
onClick = { showMoreMenu = false; onSaveCopy() },
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) }
)
}
if (showPrintAction) {
DropdownMenuItem(
text = { Text(stringResource(R.string.action_print)) },
onClick = { showMoreMenu = false; onPrint() },
leadingIcon = { Icon(painterResource(id = R.drawable.print), contentDescription = null, modifier = Modifier.size(20.dp)) }
)
}
}
}
} }
} }
} }
@ -608,7 +679,7 @@ internal fun PdfTopBar(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = tab.customName ?: tab.title ?: tab.displayName, text = tab.cardTitle(usePdfFileNameAsDisplayName),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 140.dp), modifier = Modifier.widthIn(max = 140.dp),
@ -645,8 +716,10 @@ private fun HiddenPdfToolMenuItem(
isHighlightingLoading: Boolean, isHighlightingLoading: Boolean,
isEditMode: Boolean, isEditMode: Boolean,
isTtsSessionActive: Boolean, isTtsSessionActive: Boolean,
isSliderActive: Boolean,
closeMenu: () -> Unit, closeMenu: () -> Unit,
onShowThemePanel: () -> Unit, onShowThemePanel: () -> Unit,
onShowBrightnessControl: () -> Unit,
onToggleScrollLock: () -> Unit, onToggleScrollLock: () -> Unit,
onShowDictionarySettings: () -> Unit, onShowDictionarySettings: () -> Unit,
onShowSlider: () -> Unit, onShowSlider: () -> Unit,
@ -671,6 +744,7 @@ private fun HiddenPdfToolMenuItem(
closeMenu() closeMenu()
when (tool) { when (tool) {
PdfReaderTool.THEME -> onShowThemePanel() PdfReaderTool.THEME -> onShowThemePanel()
PdfReaderTool.BRIGHTNESS -> onShowBrightnessControl()
PdfReaderTool.LOCK_PANNING -> onToggleScrollLock() PdfReaderTool.LOCK_PANNING -> onToggleScrollLock()
PdfReaderTool.DICTIONARY -> onShowDictionarySettings() PdfReaderTool.DICTIONARY -> onShowDictionarySettings()
PdfReaderTool.SLIDER -> onShowSlider() PdfReaderTool.SLIDER -> onShowSlider()
@ -688,8 +762,14 @@ private fun HiddenPdfToolMenuItem(
when (tool) { when (tool) {
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = null, modifier = Modifier.size(20.dp)) 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.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.BRIGHTNESS -> Icon(painterResource(id = R.drawable.contrast), contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, 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.SLIDER -> Icon(
painterResource(id = R.drawable.slider),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, 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.SEARCH -> Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp))
PdfReaderTool.HIGHLIGHT_ALL -> { PdfReaderTool.HIGHLIGHT_ALL -> {
@ -702,7 +782,12 @@ private fun HiddenPdfToolMenuItem(
PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = null, modifier = Modifier.size(20.dp)) PdfReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = null, modifier = Modifier.size(20.dp))
else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp)) else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp))
} }
} },
trailingIcon = if (tool == PdfReaderTool.SLIDER && isSliderActive) {
{
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
}
} else null
) )
} }
@ -849,8 +934,10 @@ fun PdfBottomBar(
isHighlightingLoading: Boolean, isHighlightingLoading: Boolean,
isEditMode: Boolean, isEditMode: Boolean,
isTtsSessionActive: Boolean, isTtsSessionActive: Boolean,
isSliderActive: Boolean,
ttsErrorMessage: String?, ttsErrorMessage: String?,
onShowThemePanel: () -> Unit, onShowThemePanel: () -> Unit,
onShowBrightnessControl: () -> Unit,
onToggleScrollLock: () -> Unit, onToggleScrollLock: () -> Unit,
onShowDictionarySettings: () -> Unit, onShowDictionarySettings: () -> Unit,
onShowSlider: () -> Unit, onShowSlider: () -> Unit,
@ -893,6 +980,13 @@ fun PdfBottomBar(
) { ) {
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
PdfReaderTool.BRIGHTNESS -> TooltipIconButton(
text = stringResource(R.string.reader_brightness_title),
description = stringResource(R.string.reader_brightness_system_desc),
onClick = onShowBrightnessControl
) {
Icon(painterResource(id = R.drawable.contrast), contentDescription = stringResource(R.string.reader_brightness_title), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
PdfReaderTool.LOCK_PANNING -> TooltipIconButton( PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
text = stringResource(R.string.tooltip_lock_pan), text = stringResource(R.string.tooltip_lock_pan),
description = stringResource(R.string.tooltip_lock_pan_desc), description = stringResource(R.string.tooltip_lock_pan_desc),
@ -913,7 +1007,11 @@ fun PdfBottomBar(
onClick = onShowSlider, onClick = onShowSlider,
enabled = !isTtsPlayingOrLoading enabled = !isTtsPlayingOrLoading
) { ) {
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider)) Icon(
painterResource(id = R.drawable.slider),
contentDescription = stringResource(R.string.content_desc_navigate_slider),
tint = if (isSliderActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
} }
PdfReaderTool.TOC -> TooltipIconButton( PdfReaderTool.TOC -> TooltipIconButton(
text = stringResource(R.string.tooltip_toc), text = stringResource(R.string.tooltip_toc),

View file

@ -0,0 +1,35 @@
package com.aryan.reader.pdf
import timber.log.Timber
import kotlin.math.roundToInt
internal object PdfVerticalPerfLog {
const val TAG = "PdfVerticalPerf"
const val SAMPLE_INTERVAL_MS = 250L
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)
}
fun f(value: Float): String {
if (value.isNaN() || value.isInfinite()) return value.toString()
return (value * 10f).roundToInt().let { rounded ->
if (rounded % 10 == 0) (rounded / 10).toString()
else (rounded / 10f).toString()
}
}
fun xy(x: Float, y: Float): String = "(${f(x)},${f(y)})"
}

View file

@ -42,7 +42,6 @@ import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculateCentroidSize import androidx.compose.foundation.gestures.calculateCentroidSize
import androidx.compose.foundation.gestures.calculatePan import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -88,6 +87,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.isPrimaryPressed import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.isSecondaryPressed
@ -116,6 +116,7 @@ import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.shared.pdf.calculatePdfVerticalPageLayoutPx import com.aryan.reader.shared.pdf.calculatePdfVerticalPageLayoutPx
import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp import com.aryan.reader.shared.pdf.pdfVerticalPageGapDp
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -128,6 +129,18 @@ import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
private const val SCROLL_BOUNDS_TAG = "PdfScrollBounds" private const val SCROLL_BOUNDS_TAG = "PdfScrollBounds"
private const val VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS = 220L
internal fun resolvePdfVerticalPageBackgroundColor(
activeTheme: com.aryan.reader.ReaderTheme
): Color {
val resolved = when (activeTheme.id) {
"no_theme", "system" -> Color.White
"reverse" -> Color.Black
else -> activeTheme.backgroundColor
}
return if (resolved.isSpecified) resolved else Color.White
}
@Stable @Stable
class VerticalPdfReaderState { class VerticalPdfReaderState {
@ -333,11 +346,7 @@ internal fun PdfVerticalReader(
var isStylusEraserOverride by remember { mutableStateOf(false) } var isStylusEraserOverride by remember { mutableStateOf(false) }
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse" val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
val verticalPageBackgroundColor = remember(activeTheme) { val verticalPageBackgroundColor = remember(activeTheme) {
when (activeTheme.id) { resolvePdfVerticalPageBackgroundColor(activeTheme)
"no_theme", "system" -> Color.White
"reverse" -> Color.Black
else -> activeTheme.backgroundColor
}
} }
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
@ -374,6 +383,22 @@ internal fun PdfVerticalReader(
var isFastFlinging by remember { mutableStateOf(false) } var isFastFlinging by remember { mutableStateOf(false) }
var isInteracting by remember { mutableStateOf(false) } var isInteracting by remember { mutableStateOf(false) }
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
var isTileRenderIdleCooldownActive by remember { mutableStateOf(false) }
LaunchedEffect(isInteracting, isFlinging) {
if (isInteracting || isFlinging) {
if (!isTileRenderIdleCooldownActive) {
PdfVerticalPerfLog.d("tile-render-cooldown active=true reason=busy")
}
isTileRenderIdleCooldownActive = true
} else if (isTileRenderIdleCooldownActive) {
delay(VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS)
PdfVerticalPerfLog.d(
"tile-render-cooldown active=false idleFor=${VERTICAL_TILE_RENDER_IDLE_COOLDOWN_MS}ms"
)
isTileRenderIdleCooldownActive = false
}
}
val layoutState = remember(ratios, constraints.maxWidth, constraints.maxHeight, density, showPageGap, dividerHeightPxInt) { val layoutState = remember(ratios, constraints.maxWidth, constraints.maxHeight, density, showPageGap, dividerHeightPxInt) {
data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float) data class LayoutResult(val pages: List<PdfPageLayout>, val totalHeight: Float)
@ -417,9 +442,48 @@ internal fun PdfVerticalReader(
} }
} }
LaunchedEffect(layoutInfo, totalDocHeight, screenWidth, screenHeight, fitZoom, headerHeightPx, footerHeightPx) {
PdfVerticalPerfLog.i(
"layout-ready pages=${layoutInfo.size} totalH=${PdfVerticalPerfLog.f(totalDocHeight)} " +
"screen=${PdfVerticalPerfLog.xy(screenWidth, screenHeight)} chrome=${PdfVerticalPerfLog.xy(headerHeightPx, footerHeightPx)} " +
"fitZoom=${PdfVerticalPerfLog.f(fitZoom)} firstPageH=${PdfVerticalPerfLog.f(layoutInfo.firstOrNull()?.height ?: 0f)}"
)
}
val zoomAnimatable = remember { Animatable(fitZoom) } val zoomAnimatable = remember { Animatable(fitZoom) }
val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) } val panXAnimatable = remember { Animatable(if ((screenWidth * fitZoom) < screenWidth) (screenWidth - (screenWidth * fitZoom)) / 2f else 0f) }
val panYAnimatable = remember { Animatable(0f) } val panYAnimatable = remember { Animatable(0f) }
val dragCameraUpdates = remember {
Channel<Triple<Float, Float, Float>>(Channel.CONFLATED)
}
val oneHandZoomDistancePx = with(density) {
PDF_ONE_HAND_ZOOM_DRAG_DISTANCE_FOR_DOUBLE_DP.dp.toPx()
}
var oneHandZoomStartZoom by remember { mutableFloatStateOf(fitZoom) }
var oneHandZoomStartPan by remember { mutableStateOf(Offset.Zero) }
var oneHandZoomPivotScreen by remember { mutableStateOf(Offset.Zero) }
var isVerticalOneHandZooming by remember { mutableStateOf(false) }
val latestIsVerticalOneHandZooming by rememberUpdatedState(isVerticalOneHandZooming)
DisposableEffect(dragCameraUpdates) {
onDispose {
dragCameraUpdates.close()
}
}
LaunchedEffect(dragCameraUpdates) {
for ((targetZoom, targetPanX, targetPanY) in dragCameraUpdates) {
if (zoomAnimatable.value != targetZoom) {
zoomAnimatable.snapTo(targetZoom)
}
if (panXAnimatable.value != targetPanX) {
panXAnimatable.snapTo(targetPanX)
}
if (panYAnimatable.value != targetPanY) {
panYAnimatable.snapTo(targetPanY)
}
}
}
LaunchedEffect(zoomAnimatable.value, panXAnimatable.value, panYAnimatable.value) { LaunchedEffect(zoomAnimatable.value, panXAnimatable.value, panYAnimatable.value) {
onZoomAndPanChanged?.invoke(zoomAnimatable.value, Offset(panXAnimatable.value, panYAnimatable.value)) onZoomAndPanChanged?.invoke(zoomAnimatable.value, Offset(panXAnimatable.value, panYAnimatable.value))
@ -601,6 +665,21 @@ internal fun PdfVerticalReader(
return clampValues(targetZoom, targetPanX, targetPanY) return clampValues(targetZoom, targetPanX, targetPanY)
} }
fun updatePanBoundsForZoom(finalZoom: Float) {
val zoomedDocWidth = screenWidth * finalZoom
val (finalMinX, finalMaxX) = if (zoomedDocWidth < screenWidth) {
val centeredX = (screenWidth - zoomedDocWidth) / 2f
centeredX to centeredX
} else {
-(zoomedDocWidth - screenWidth) to 0f
}
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
val zoomedDocHeight = totalDocHeight * finalZoom
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
panYAnimatable.updateBounds(lowerBound = minPanY, upperBound = headerHeightPx)
}
LaunchedEffect(resetZoomTrigger) { LaunchedEffect(resetZoomTrigger) {
if (resetZoomTrigger != 0L && zoomAnimatable.value > fitZoom && !isScrollLocked) { if (resetZoomTrigger != 0L && zoomAnimatable.value > fitZoom && !isScrollLocked) {
scope.launch { scope.launch {
@ -864,23 +943,39 @@ internal fun PdfVerticalReader(
LaunchedEffect(isInteracting) { LaunchedEffect(isInteracting) {
Timber.tag("PdfTouchDebug").i("VerticalReader: isInteracting changed to $isInteracting") Timber.tag("PdfTouchDebug").i("VerticalReader: isInteracting changed to $isInteracting")
PdfVerticalPerfLog.i(
"interaction-state interacting=$isInteracting dragging=$isDragging flinging=$isFlinging " +
"zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)}"
)
} }
LaunchedEffect(highResScale) { LaunchedEffect(highResScale) {
Timber.tag("PdfPerformance").i("VerticalReader HighResScale changed to: $highResScale") Timber.tag("PdfPerformance").i("VerticalReader HighResScale changed to: $highResScale")
PdfVerticalPerfLog.i(
"high-res-scale scale=${PdfVerticalPerfLog.f(highResScale)} zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} " +
"interacting=$isInteracting flinging=$isFlinging fastFlinging=$isFastFlinging"
)
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { isInteracting || (isFlinging && isFastFlinging) }.collectLatest { isBusy -> snapshotFlow { isInteracting || isFlinging }.collectLatest { isBusy ->
Timber.tag("PdfDrawPerf").d( Timber.tag("PdfDrawPerf").d(
"VerticalReader Interaction State: isBusy=$isBusy (Interacting=$isInteracting, Flinging=$isFlinging, Fast=$isFastFlinging)" "VerticalReader Interaction State: isBusy=$isBusy (Interacting=$isInteracting, Flinging=$isFlinging, Fast=$isFastFlinging)"
) )
PdfVerticalPerfLog.d(
"render-resolution-gate busy=$isBusy interacting=$isInteracting flinging=$isFlinging " +
"fastFlinging=$isFastFlinging highRes=${PdfVerticalPerfLog.f(highResScale)} zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)}"
)
if (!isBusy) { if (!isBusy) {
delay(50) delay(50)
val target = zoomAnimatable.value val target = zoomAnimatable.value
if (highResScale != target) { if (highResScale != target) {
Timber.tag("PdfDrawPerf").v("VerticalReader: Updating highResScale to $target") Timber.tag("PdfDrawPerf").v("VerticalReader: Updating highResScale to $target")
PdfVerticalPerfLog.i(
"high-res-scale-update from=${PdfVerticalPerfLog.f(highResScale)} to=${PdfVerticalPerfLog.f(target)} " +
"pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)}"
)
highResScale = target highResScale = target
} }
} }
@ -893,7 +988,7 @@ internal fun PdfVerticalReader(
} }
LaunchedEffect(zoomAnimatable.value) { LaunchedEffect(zoomAnimatable.value) {
if (!isInteracting && !(isFlinging && isFastFlinging)) { if (!isInteracting && !isFlinging) {
if (highResScale != zoomAnimatable.value) { if (highResScale != zoomAnimatable.value) {
highResScale = zoomAnimatable.value highResScale = zoomAnimatable.value
} }
@ -1071,6 +1166,91 @@ internal fun PdfVerticalReader(
} }
} }
val onDoubleTapDragZoomStart: (Offset) -> Unit = {
if (!isScrollLocked) {
isVerticalOneHandZooming = true
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
"vertical.oneHandStart requestedPivot=$it center=(${(screenWidth / 2f).toInt()},${(screenHeight / 2f).toInt()}) " +
"zoom=${zoomAnimatable.value} pan=(${panXAnimatable.value},${panYAnimatable.value})"
)
oneHandZoomPivotScreen = Offset(screenWidth / 2f, screenHeight / 2f)
oneHandZoomStartZoom = zoomAnimatable.value
oneHandZoomStartPan = Offset(panXAnimatable.value, panYAnimatable.value)
isInteracting = true
isDragging = true
scope.launch {
zoomAnimatable.stop()
panXAnimatable.stop()
panYAnimatable.stop()
panXAnimatable.updateBounds(null, null)
panYAnimatable.updateBounds(null, null)
}
}
}
val onDoubleTapDragZoom: (Offset, Float) -> Unit = { _, totalDragY ->
if (!isScrollLocked) {
val screenDragY = totalDragY * oneHandZoomStartZoom
val targetZoom = pdfOneHandZoomScale(
startScale = oneHandZoomStartZoom,
totalDragY = screenDragY,
dragDistanceForDoublePx = oneHandZoomDistancePx,
minScale = fitZoom,
maxScale = 5f
)
val rawPan = topLeftPdfPanForScaleChange(
previousScale = oneHandZoomStartZoom,
nextScale = targetZoom,
previousPan = oneHandZoomStartPan,
pivot = oneHandZoomPivotScreen
)
val (finalZoom, finalX, finalY) = clampCamera(targetZoom, rawPan.x, rawPan.y)
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v(
"vertical.oneHandUpdate dragY=$totalDragY screenDragY=$screenDragY " +
"targetZoom=$targetZoom finalZoom=$finalZoom pan=($finalX,$finalY)"
)
onZoomChange(finalZoom)
dragCameraUpdates.trySend(Triple(finalZoom, finalX, finalY))
}
}
val onDoubleTapDragZoomEnd: () -> Unit = {
val wasOneHandZooming = isVerticalOneHandZooming
isVerticalOneHandZooming = false
if (!isScrollLocked || wasOneHandZooming) {
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
"vertical.oneHandEnd zoom=${zoomAnimatable.value} pan=(${panXAnimatable.value},${panYAnimatable.value})"
)
isInteracting = false
isDragging = false
val currentZoom = zoomAnimatable.value
if (currentZoom > fitZoom && currentZoom < fitZoom * 1.05f) {
scope.launch {
val (finalZoom, finalX, finalY) = clampCamera(
fitZoom,
panXAnimatable.value,
panYAnimatable.value
)
coroutineScope {
launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(180, easing = FastOutSlowInEasing)) }
launch { panXAnimatable.animateTo(finalX, animationSpec = tween(180, easing = FastOutSlowInEasing)) }
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(180, easing = FastOutSlowInEasing)) }
}
onZoomChange(finalZoom)
updatePanBoundsForZoom(finalZoom)
}
} else {
updatePanBoundsForZoom(currentZoom)
}
}
}
val currentOnPageClick by rememberUpdatedState(onPageClick)
val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom)
val currentOnDoubleTapDragZoomStart by rememberUpdatedState(onDoubleTapDragZoomStart)
val currentOnDoubleTapDragZoom by rememberUpdatedState(onDoubleTapDragZoom)
val currentOnDoubleTapDragZoomEnd by rememberUpdatedState(onDoubleTapDragZoomEnd)
val globalDrawingModifier = Modifier.pointerInput( val globalDrawingModifier = Modifier.pointerInput(
isEditMode, isEditMode,
layoutInfo, layoutInfo,
@ -1180,31 +1360,86 @@ internal fun PdfVerticalReader(
.fillMaxSize() .fillMaxSize()
.background(if (showPageGap) Color.Transparent else verticalPageBackgroundColor) .background(if (showPageGap) Color.Transparent else verticalPageBackgroundColor)
.then(globalDrawingModifier) .then(globalDrawingModifier)
.pointerInput(isEditMode, selectedTool, isStylusOnlyMode, isScrollLocked) { // Vertical zoom gestures live here so page tap handlers do not steal
Timber.tag("PdfTouchDebug").v( // alternating double-tap-hold attempts.
"VerticalReader: TapPointerInput init. isEditMode=$isEditMode" .pointerInput(
) layoutInfo,
isEditMode,
selectedTool,
isStylusOnlyMode,
isScrollLocked
) {
val isTapDetectionAllowed = !isEditMode || val isTapDetectionAllowed = !isEditMode ||
selectedTool == InkType.TEXT || selectedTool == InkType.TEXT ||
isStylusOnlyMode isStylusOnlyMode
if (!isTapDetectionAllowed) return@pointerInput if (!isTapDetectionAllowed) {
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
"vertical.rootDetector.disabled edit=$isEditMode tool=$selectedTool stylusOnly=$isStylusOnlyMode"
)
return@pointerInput
}
detectTapGestures(onTap = { Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
if (!isEditMode) { "vertical.rootDetector.enabled scrollLocked=$isScrollLocked edit=$isEditMode " +
Timber.tag("PdfTouchDebug").d("VerticalReader: Tap detected") "tool=$selectedTool pages=${layoutInfo.size} zoom=${zoomAnimatable.value}"
selectionClearTrigger++ )
onPageClick()
} else if (selectedTool == InkType.TEXT) { fun isOverPage(screenOffset: Offset): Boolean {
onPageClick() val zoom = zoomAnimatable.value.takeIf { it > 0f } ?: fitZoom
val docX = (screenOffset.x - panXAnimatable.value) / zoom
val docY = (screenOffset.y - panYAnimatable.value) / zoom
return layoutInfo.any { page ->
docX >= 0f &&
docX <= page.width &&
docY >= page.y &&
docY <= page.y + page.height
} }
}, onDoubleTap = { offset -> }
if (!isScrollLocked) {
Timber.tag("PdfTouchDebug").d("VerticalReader: DoubleTap detected") detectPdfTapAndOneHandZoomGestures(
onDoubleTapToZoom(offset) viewConfiguration = viewConfiguration,
canStartOneHandZoom = { !isScrollLocked },
canHandleQuickDoubleTap = { !isScrollLocked },
consumeSingleTap = false,
onTap = { offset ->
val overPage = isOverPage(offset)
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
"vertical.rootTap offset=$offset overPage=$overPage"
)
if (!overPage) {
selectionClearTrigger++
currentOnPageClick()
}
},
onQuickDoubleTap = { offset ->
if (!isScrollLocked) {
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
"vertical.rootQuickDoubleTap offset=$offset zoom=${zoomAnimatable.value}"
)
currentOnDoubleTapToZoom(offset)
}
},
onOneHandZoomHoldStart = { offset ->
if (!isScrollLocked) {
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
"vertical.rootOneHandHoldStart offset=$offset"
)
currentOnDoubleTapDragZoomStart(offset)
}
},
onOneHandZoom = { offset, totalDragY ->
if (!isScrollLocked) {
currentOnDoubleTapDragZoom(offset, totalDragY)
}
},
onOneHandZoomEnd = { _ ->
if (!isScrollLocked || latestIsVerticalOneHandZooming) {
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d("vertical.rootOneHandEnd")
currentOnDoubleTapDragZoomEnd()
}
} }
}) )
} }
.pointerInput( .pointerInput(
totalDocHeight, totalDocHeight,
@ -1224,8 +1459,25 @@ internal fun PdfVerticalReader(
) )
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
"vertical.scrollDetector.down consumed=${down.isConsumed} pos=${down.position} " +
"zoom=${zoomAnimatable.value} pan=(${panXAnimatable.value},${panYAnimatable.value})"
)
isInteracting = true isInteracting = true
isDragging = false isDragging = false
val gestureStartNanos = PdfVerticalPerfLog.nowNanos()
var gestureLastSampleMs = System.currentTimeMillis()
var gestureEventCount = 0
var gestureConsumedEventCount = 0
var gestureCanceledEventCount = 0
var gestureZoomEventCount = 0
var gestureMaxPanDelta = 0f
PdfVerticalPerfLog.i(
"gesture-start type=${down.type} scrollLocked=$isScrollLocked edit=$isEditMode tool=$selectedTool " +
"zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} highRes=${PdfVerticalPerfLog.f(highResScale)} " +
"pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)} currentPage=${state.currentPage} " +
"visible=${state.firstVisiblePage}-${state.lastVisiblePage}"
)
Timber.tag("PointerTypeDebug").d("VerticalReader: Input Type detected: ${down.type}") Timber.tag("PointerTypeDebug").d("VerticalReader: Input Type detected: ${down.type}")
@ -1282,10 +1534,28 @@ internal fun PdfVerticalReader(
do { do {
val event = awaitPointerEvent() val event = awaitPointerEvent()
gestureEventCount++
val isMultiTouch = event.changes.size > 1 val isMultiTouch = event.changes.size > 1
val canceled = event.changes.any { it.isConsumed } && !isMultiTouch val canceled = event.changes.any { it.isConsumed } && !isMultiTouch
if (latestIsVerticalOneHandZooming && !isMultiTouch) {
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).v(
"vertical.scrollDetector.skipOneHandActive events=$gestureEventCount " +
"changes=${event.changes.joinToString { change ->
"pressed=${change.pressed},consumed=${change.isConsumed},moved=${change.positionChanged()}"
}}"
)
continue
}
if (canceled) { if (canceled) {
gestureCanceledEventCount++
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
"vertical.scrollDetector.canceledByConsumed mode=$gestureDisambiguationMode " +
"events=$gestureEventCount changes=${event.changes.joinToString { change ->
"pressed=${change.pressed},consumed=${change.isConsumed},moved=${change.positionChanged()}"
}}"
)
Timber.tag("PdfTouchDebug").v( Timber.tag("PdfTouchDebug").v(
"VerticalReader: Event Canceled (Child consumed?)." "VerticalReader: Event Canceled (Child consumed?)."
) )
@ -1311,7 +1581,11 @@ internal fun PdfVerticalReader(
) )
totalPanDistance += panMagnitude totalPanDistance += panMagnitude
gestureMaxPanDelta = max(gestureMaxPanDelta, panMagnitude)
gestureZoomAccumulator *= zoomChange gestureZoomAccumulator *= zoomChange
if (abs(zoomChange - 1f) > 0.001f) {
gestureZoomEventCount++
}
val isZoomPastSlop = abs(gestureZoomAccumulator - 1f) > 0.05f val isZoomPastSlop = abs(gestureZoomAccumulator - 1f) > 0.05f
val isPanPastSlop = totalPanDistance > touchSlop val isPanPastSlop = totalPanDistance > touchSlop
@ -1320,11 +1594,17 @@ internal fun PdfVerticalReader(
if (isPanPastSlop || isZoomPastSlop) { if (isPanPastSlop || isZoomPastSlop) {
if (spanMagnitude > panMagnitude * 1.5f) { if (spanMagnitude > panMagnitude * 1.5f) {
gestureDisambiguationMode = 2 gestureDisambiguationMode = 2
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
"vertical.scrollDetector.modeZoom span=$spanMagnitude pan=$panMagnitude totalPan=$totalPanDistance"
)
Timber.tag("PdfTouchDebug").d( Timber.tag("PdfTouchDebug").d(
"Locked to ZOOM (Span > Pan * 1.5)" "Locked to ZOOM (Span > Pan * 1.5)"
) )
} else { } else {
gestureDisambiguationMode = 1 gestureDisambiguationMode = 1
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
"vertical.scrollDetector.modePan span=$spanMagnitude pan=$panMagnitude totalPan=$totalPanDistance"
)
Timber.tag("PdfTouchDebug").d( Timber.tag("PdfTouchDebug").d(
"Locked to PAN (Pan Dominant)" "Locked to PAN (Pan Dominant)"
) )
@ -1333,6 +1613,9 @@ internal fun PdfVerticalReader(
} else if (gestureDisambiguationMode == 1) { } else if (gestureDisambiguationMode == 1) {
if (spanMagnitude > (panMagnitude * 3f) && spanMagnitude > 4f) { if (spanMagnitude > (panMagnitude * 3f) && spanMagnitude > 4f) {
gestureDisambiguationMode = 2 gestureDisambiguationMode = 2
Timber.tag(PDF_ONE_HAND_ZOOM_TRACE_TAG).d(
"vertical.scrollDetector.modePanToZoom span=$spanMagnitude pan=$panMagnitude"
)
Timber.tag("PdfTouchDebug").d( Timber.tag("PdfTouchDebug").d(
"Breakout: Switching PAN -> ZOOM" "Breakout: Switching PAN -> ZOOM"
) )
@ -1379,15 +1662,29 @@ internal fun PdfVerticalReader(
onZoomChange(accumulatedZoom) onZoomChange(accumulatedZoom)
} }
scope.launch { dragCameraUpdates.trySend(
zoomAnimatable.snapTo(accumulatedZoom) Triple(accumulatedZoom, accumulatedPanX, accumulatedPanY)
panXAnimatable.snapTo(accumulatedPanX) )
panYAnimatable.snapTo(accumulatedPanY)
}
val consumedChanges = event.changes.count { it.positionChanged() }
event.changes.forEach { event.changes.forEach {
if (it.positionChanged()) it.consume() if (it.positionChanged()) it.consume()
} }
if (consumedChanges > 0) {
gestureConsumedEventCount++
}
val nowMs = System.currentTimeMillis()
if (nowMs - gestureLastSampleMs >= PdfVerticalPerfLog.SAMPLE_INTERVAL_MS) {
gestureLastSampleMs = nowMs
PdfVerticalPerfLog.d(
"gesture-drag-sample events=$gestureEventCount consumed=$gestureConsumedEventCount " +
"mode=$gestureDisambiguationMode multi=$isMultiTouch panDelta=${PdfVerticalPerfLog.f(panMagnitude)} " +
"totalPan=${PdfVerticalPerfLog.f(totalPanDistance)} zoomChange=${PdfVerticalPerfLog.f(zoomChange)} " +
"zoom=${PdfVerticalPerfLog.f(accumulatedZoom)} pan=${PdfVerticalPerfLog.xy(accumulatedPanX, accumulatedPanY)} " +
"highRes=${PdfVerticalPerfLog.f(highResScale)}"
)
}
if (event.changes.isNotEmpty()) { if (event.changes.isNotEmpty()) {
velocityTrackerAccumulator += panChange velocityTrackerAccumulator += panChange
@ -1405,75 +1702,102 @@ internal fun PdfVerticalReader(
} }
isDragging = false isDragging = false
val validFlingCondition = panLocked val gestureDurationMs = PdfVerticalPerfLog.elapsedMs(gestureStartNanos)
if (validFlingCondition) { if (panLocked) {
val velocity = tracker.calculateVelocity() val velocity = tracker.calculateVelocity()
val flingSensitivity = 2.0f val flingSensitivity = 2.0f
val minFlingVelocity = 250f val minFlingVelocity = 250f
val (finalZoom, finalX, finalY) = clampCamera( val (finalZoom, finalX, finalY) = clampCamera(
accumulatedZoom, accumulatedPanX, accumulatedPanY accumulatedZoom, accumulatedPanX, accumulatedPanY
) )
val zoomedDocWidth = screenWidth * finalZoom
val zoomedDocHeight = totalDocHeight * finalZoom
scope.launch { val flingMinX: Float
isFlinging = true val flingMaxX: Float
try { if (zoomedDocWidth < screenWidth) {
if (accumulatedZoom !in fitZoom..5f) { val centeredX = (screenWidth - zoomedDocWidth) / 2f
zoomAnimatable.animateTo( flingMinX = centeredX
finalZoom, animationSpec = tween(300) flingMaxX = centeredX
) } else {
} flingMinX = -(zoomedDocWidth - screenWidth)
onZoomChange(zoomAnimatable.targetValue) flingMaxX = 0f
val zoomedDocWidth = screenWidth * finalZoom
val zoomedDocHeight = totalDocHeight * finalZoom
val flingMinX: Float
val flingMaxX: Float
if (zoomedDocWidth < screenWidth) {
val centeredX = (screenWidth - zoomedDocWidth) / 2f
flingMinX = centeredX
flingMaxX = centeredX
} else {
flingMinX = -(zoomedDocWidth - screenWidth)
flingMaxX = 0f
}
val minPanY =
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
headerHeightPx
)
Timber.tag(SCROLL_BOUNDS_TAG).i("Fling Logic:")
Timber.tag(SCROLL_BOUNDS_TAG)
.d("- totalDocHeight: $totalDocHeight, zoom: $finalZoom -> zoomedDocHeight: $zoomedDocHeight")
Timber.tag(SCROLL_BOUNDS_TAG)
.d("- Fling bounds set to Y:[$minPanY, $headerHeightPx]")
panXAnimatable.updateBounds(flingMinX, flingMaxX)
panYAnimatable.updateBounds(minPanY, headerHeightPx)
coroutineScope {
launch {
val rawX = velocity.x * flingSensitivity
val flingX = if (abs(rawX) > minFlingVelocity && !isScrollLocked) rawX
else 0f
if (flingX != 0f) panXAnimatable.animateDecay(
flingX, decay
)
}
launch {
val rawY = velocity.y * flingSensitivity
val flingY = if (abs(rawY) > minFlingVelocity) rawY
else 0f
if (flingY != 0f) panYAnimatable.animateDecay(
flingY, decay
)
}
}
} finally {
isFlinging = false
}
} }
val minPanY =
(screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(
headerHeightPx
)
val rawX = velocity.x * flingSensitivity
val rawY = velocity.y * flingSensitivity
val flingX = if (abs(rawX) > minFlingVelocity && !isScrollLocked) rawX else 0f
val flingY = if (abs(rawY) > minFlingVelocity) rawY else 0f
val shouldRunFling = flingX != 0f || flingY != 0f || accumulatedZoom !in fitZoom..5f
PdfVerticalPerfLog.i(
"gesture-end duration=${gestureDurationMs}ms events=$gestureEventCount consumed=$gestureConsumedEventCount " +
"canceled=$gestureCanceledEventCount zoomEvents=$gestureZoomEventCount maxPanDelta=${PdfVerticalPerfLog.f(gestureMaxPanDelta)} " +
"totalPan=${PdfVerticalPerfLog.f(totalPanDistance)} mode=$gestureDisambiguationMode panLocked=$panLocked shouldRunFling=$shouldRunFling " +
"velocity=${PdfVerticalPerfLog.xy(velocity.x, velocity.y)} fling=${PdfVerticalPerfLog.xy(flingX, flingY)} " +
"zoom=${PdfVerticalPerfLog.f(finalZoom)} pan=${PdfVerticalPerfLog.xy(finalX, finalY)}"
)
if (shouldRunFling) {
scope.launch {
isFlinging = true
val flingStartNanos = PdfVerticalPerfLog.nowNanos()
PdfVerticalPerfLog.i(
"fling-start fling=${PdfVerticalPerfLog.xy(flingX, flingY)} " +
"boundsX=${PdfVerticalPerfLog.xy(flingMinX, flingMaxX)} boundsY=${PdfVerticalPerfLog.xy(minPanY, headerHeightPx)} " +
"zoomedDocH=${PdfVerticalPerfLog.f(zoomedDocHeight)} highRes=${PdfVerticalPerfLog.f(highResScale)}"
)
try {
if (accumulatedZoom !in fitZoom..5f) {
zoomAnimatable.animateTo(
finalZoom, animationSpec = tween(300)
)
}
onZoomChange(zoomAnimatable.targetValue)
Timber.tag(SCROLL_BOUNDS_TAG).i("Fling Logic:")
Timber.tag(SCROLL_BOUNDS_TAG)
.d("- totalDocHeight: $totalDocHeight, zoom: $finalZoom -> zoomedDocHeight: $zoomedDocHeight")
Timber.tag(SCROLL_BOUNDS_TAG)
.d("- Fling bounds set to Y:[$minPanY, $headerHeightPx]")
panXAnimatable.updateBounds(flingMinX, flingMaxX)
panYAnimatable.updateBounds(minPanY, headerHeightPx)
coroutineScope {
launch {
if (flingX != 0f) panXAnimatable.animateDecay(
flingX, decay
)
}
launch {
if (flingY != 0f) panYAnimatable.animateDecay(
flingY, decay
)
}
}
} finally {
PdfVerticalPerfLog.i(
"fling-end duration=${PdfVerticalPerfLog.elapsedMs(flingStartNanos)}ms " +
"zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)} " +
"velocity=${PdfVerticalPerfLog.xy(panXAnimatable.velocity, panYAnimatable.velocity)}"
)
isFlinging = false
}
}
} else {
panXAnimatable.updateBounds(flingMinX, flingMaxX)
panYAnimatable.updateBounds(minPanY, headerHeightPx)
}
} else {
PdfVerticalPerfLog.i(
"gesture-end duration=${gestureDurationMs}ms events=$gestureEventCount consumed=$gestureConsumedEventCount " +
"canceled=$gestureCanceledEventCount zoomEvents=$gestureZoomEventCount maxPanDelta=${PdfVerticalPerfLog.f(gestureMaxPanDelta)} " +
"totalPan=${PdfVerticalPerfLog.f(totalPanDistance)} mode=$gestureDisambiguationMode panLocked=false no-fling " +
"zoom=${PdfVerticalPerfLog.f(accumulatedZoom)} pan=${PdfVerticalPerfLog.xy(accumulatedPanX, accumulatedPanY)}"
)
} }
} }
}) { }) {
@ -1543,6 +1867,11 @@ internal fun PdfVerticalReader(
Timber.tag("PdfDrawPerf").d( Timber.tag("PdfDrawPerf").d(
"Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})" "Vertical Visible Pages Changed: ${finalPages.map { it.index }} (Dragging: ${draggedBox != null})"
) )
PdfVerticalPerfLog.d(
"visible-pages pages=${finalPages.map { it.index }} base=${baseVisiblePages.map { it.index }} " +
"draggingBox=${draggedBox != null} zoom=${PdfVerticalPerfLog.f(zoom)} panY=${PdfVerticalPerfLog.f(panY)} " +
"viewport=${PdfVerticalPerfLog.xy(viewportTop, viewportBottom)} buffered=${PdfVerticalPerfLog.xy(searchTop, searchBottom)}"
)
finalPages finalPages
} else { } else {
cached cached
@ -1569,6 +1898,10 @@ internal fun PdfVerticalReader(
if (mostVisible != null && mostVisible.index != state.currentPage) { if (mostVisible != null && mostVisible.index != state.currentPage) {
Timber.tag("PdfPositionDebug").v("VerticalReader: Page changed to ${mostVisible.index} (PanY: $panY)") Timber.tag("PdfPositionDebug").v("VerticalReader: Page changed to ${mostVisible.index} (PanY: $panY)")
PdfVerticalPerfLog.d(
"current-page-change from=${state.currentPage} to=${mostVisible.index} " +
"viewport=${PdfVerticalPerfLog.xy(realViewportTop, realViewportBottom)} panY=${PdfVerticalPerfLog.f(panY)} zoom=${PdfVerticalPerfLog.f(zoom)}"
)
state.currentPage = mostVisible.index state.currentPage = mostVisible.index
} }
} }
@ -1678,24 +2011,6 @@ internal fun PdfVerticalReader(
{ text: String -> onSearchText(text) } { text: String -> onSearchText(text) }
} }
val currentOnDoubleTapToZoom by rememberUpdatedState(onDoubleTapToZoom)
val onDoubleTapLambda = remember(page, screenWidth, screenHeight) {
{ localOffset: Offset ->
Timber.tag("PdfZoomDebug").d(
"Page ${page.index} Double Tap: Local=$localOffset, PageY=${page.y}"
)
val contentX = localOffset.x
val contentY = localOffset.y + page.y
val currentZ = zoomAnimatable.value
val panX = panXAnimatable.value
val panY = panYAnimatable.value
val screenX = contentX * currentZ + panX
val screenY = contentY * currentZ + panY
Timber.tag("PdfZoomDebug").d("Mapped to Screen: ($screenX, $screenY)") // Added log
currentOnDoubleTapToZoom(Offset(screenX, screenY))
}
}
val onTtsHighlightCenter: (Float) -> Unit = val onTtsHighlightCenter: (Float) -> Unit =
remember(page.index, ttsReadingPage) { remember(page.index, ttsReadingPage) {
{ highlightCenterY -> { highlightCenterY ->
@ -1792,12 +2107,14 @@ internal fun PdfVerticalReader(
onOcrStateChange = onOcrStateChange, onOcrStateChange = onOcrStateChange,
onBookmarkClick = { onBookmarkClick(page.index) }, onBookmarkClick = { onBookmarkClick(page.index) },
isZoomEnabled = false, isZoomEnabled = false,
isScrolling = isDragging || (isFlinging && isFastFlinging), isScrolling = isInteracting ||
isDragging ||
isFlinging ||
isTileRenderIdleCooldownActive,
isVerticalScroll = true, isVerticalScroll = true,
showPageNumberOverlay = showPageNumberOverlay, showPageNumberOverlay = showPageNumberOverlay,
isScrollLocked = isScrollLocked, isScrollLocked = isScrollLocked,
visualScaleProvider = currentScaleProvider, visualScaleProvider = currentScaleProvider,
onDoubleTap = onDoubleTapLambda,
clearSelectionTrigger = selectionClearTrigger, clearSelectionTrigger = selectionClearTrigger,
onTtsHighlightCenterCalculated = onTtsHighlightCenter, onTtsHighlightCenterCalculated = onTtsHighlightCenter,
onSearchHighlightCenterCalculated = onSearchHighlightCenter, onSearchHighlightCenterCalculated = onSearchHighlightCenter,
@ -1993,6 +2310,11 @@ internal fun PdfVerticalReader(
Timber.tag("PdfPerformance").d( Timber.tag("PdfPerformance").d(
"VerticalReader Layout Measure/Place took ${layoutTime}ms for ${measurables.size} items" "VerticalReader Layout Measure/Place took ${layoutTime}ms for ${measurables.size} items"
) )
PdfVerticalPerfLog.d(
"compose-layout-slow duration=${PdfVerticalPerfLog.f(layoutTime)}ms items=${measurables.size} " +
"visible=${visiblePages.map { it.index }} zoom=${PdfVerticalPerfLog.f(zoomAnimatable.value)} " +
"pan=${PdfVerticalPerfLog.xy(panXAnimatable.value, panYAnimatable.value)}"
)
} }
measureResult measureResult
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,141 @@
package com.aryan.reader.pdf
import androidx.compose.ui.geometry.Offset
import com.aryan.reader.shared.pdf.PdfSpreadLayout
import com.aryan.reader.shared.reader.ReaderSettings
internal fun resolveEraserStrokeWidth(
isEraserOverride: Boolean,
activeToolThickness: Float,
eraserToolThickness: Float
): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness
internal fun canUsePdfSidecarsForBook(
activeBookId: String?,
loadedSidecarBookId: String?,
areSidecarsLoaded: Boolean
): Boolean = activeBookId != null && areSidecarsLoaded && loadedSidecarBookId == activeBookId
internal fun canManagePdfVirtualPages(
isDocumentReady: Boolean,
currentBookId: String?,
loadedPageLayoutBookId: String?,
virtualPageCount: Int
): Boolean {
return isDocumentReady &&
currentBookId != null &&
loadedPageLayoutBookId == currentBookId &&
virtualPageCount > 0
}
internal fun currentPageScaleAfterPdfPageChange(
displayMode: DisplayMode,
isScrollLocked: Boolean,
lockedState: Triple<Float, Float, Float>?,
currentActiveScale: Float
): Float {
return if (displayMode == DisplayMode.PAGINATION && isScrollLocked) {
lockedState?.first ?: currentActiveScale
} else {
1f
}
}
internal fun pdfPageRangeText(
pageIndex: Int,
pageCount: Int,
displayMode: DisplayMode,
settings: ReaderSettings
): String {
val pageRange = if (displayMode == DisplayMode.PAGINATION) {
PdfSpreadLayout.pageRangeLabel(pageIndex, pageCount, settings)
} else {
"${pageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + 1}"
}
return "$pageRange / $pageCount"
}
internal fun pdfPageRangeLabel(
pageIndex: Int,
pageCount: Int,
displayMode: DisplayMode,
settings: ReaderSettings
): String {
val pageRange = if (displayMode == DisplayMode.PAGINATION) {
PdfSpreadLayout.pageRangeLabel(pageIndex, pageCount, settings)
} else {
"${pageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + 1}"
}
return if ('-' in pageRange) {
"Pages $pageRange of $pageCount"
} else {
"Page $pageRange of $pageCount"
}
}
internal fun clampPdfSpreadCameraOffset(
scale: Float,
offset: Offset,
viewportWidth: Float,
viewportHeight: Float
): Offset {
if (viewportWidth <= 0f || viewportHeight <= 0f || scale <= 1f) return Offset.Zero
val maxOffsetX = ((viewportWidth * scale) - viewportWidth).coerceAtLeast(0f) / 2f
val maxOffsetY = ((viewportHeight * scale) - viewportHeight).coerceAtLeast(0f) / 2f
return Offset(
x = offset.x.coerceIn(-maxOffsetX, maxOffsetX),
y = offset.y.coerceIn(-maxOffsetY, maxOffsetY)
)
}
internal fun activePdfCameraAfterLockPreferenceLoad(
isScrollLocked: Boolean,
lockedState: Triple<Float, Float, Float>?
): Pair<Float, Offset> {
return if (isScrollLocked && lockedState != null) {
lockedState.first to Offset(lockedState.second, lockedState.third)
} else {
1f to Offset.Zero
}
}
internal fun shouldReportPdfPageCamera(
isZoomEnabled: Boolean,
isVerticalScroll: Boolean,
isScrollLocked: Boolean,
lockedState: Triple<Float, Float, Float>?,
hasAppliedLockedState: Boolean
): Boolean {
return !isZoomEnabled ||
isVerticalScroll ||
!isScrollLocked ||
lockedState == null ||
hasAppliedLockedState
}
internal fun initialPdfPageCamera(
isZoomEnabled: Boolean,
isVerticalScroll: Boolean,
isScrollLocked: Boolean,
lockedState: Triple<Float, Float, Float>?
): Pair<Float, Offset> {
return if (isZoomEnabled && !isVerticalScroll && isScrollLocked && lockedState != null) {
lockedState.first to Offset(lockedState.second, lockedState.third)
} else {
1f to Offset.Zero
}
}
internal fun shouldResetPdfZoomAfterBubbleZoomCleanup(
isBubbleZoomModeActive: Boolean,
scale: Float,
isVerticalScroll: Boolean,
isZoomEnabled: Boolean,
isScrollLocked: Boolean
): Boolean {
return !isBubbleZoomModeActive &&
scale > 1f &&
!isVerticalScroll &&
isZoomEnabled &&
!isScrollLocked
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.pdf.PdfRenderer import android.graphics.pdf.PdfRenderer
import android.net.Uri import android.net.Uri
@ -33,12 +34,22 @@ import androidx.compose.ui.unit.isSpecified
import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.PdfPageBounds
import com.aryan.reader.shared.pdf.PdfPagePoint
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfAnnotationExportMapper
import com.aryan.reader.shared.pdf.pdfInkAppearancePoints
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
@ -111,7 +122,8 @@ internal object PdfiumAnnotationExporter {
textBoxes = emptyList(), textBoxes = emptyList(),
highlights = highlights.orEmpty(), highlights = highlights.orEmpty(),
richTextPageLayouts = emptyList(), richTextPageLayouts = emptyList(),
rasterOverlays = rasterOverlays rasterOverlays = rasterOverlays,
pageSizes = pageSizes
) )
if (!payload.hasAnnotations()) { if (!payload.hasAnnotations()) {
@ -119,38 +131,51 @@ internal object PdfiumAnnotationExporter {
return@withContext return@withContext
} }
val exported = NativePdfiumBridge.exportAnnotatedPdf( val exported = PdfiumEngineProvider.withPdfium {
sourcePath = sourceFile.absolutePath, NativePdfiumBridge.exportAnnotatedPdf(
destPath = destFile.absolutePath, sourcePath = sourceFile.absolutePath,
inkPageIndices = payload.inkPageIndices, destPath = destFile.absolutePath,
inkTypes = payload.inkTypes, inkPageIndices = payload.inkPageIndices,
inkColors = payload.inkColors, inkTypes = payload.inkTypes,
inkStrokeWidths = payload.inkStrokeWidths, inkColors = payload.inkColors,
inkPointOffsets = payload.inkPointOffsets, inkStrokeWidths = payload.inkStrokeWidths,
inkPointCounts = payload.inkPointCounts, inkPointOffsets = payload.inkPointOffsets,
inkPoints = payload.inkPoints, inkPointCounts = payload.inkPointCounts,
textPageIndices = payload.textPageIndices, inkPoints = payload.inkPoints,
textBounds = payload.textBounds, inkNames = payload.inkNames,
textColors = payload.textColors, inkContents = payload.inkContents,
textBackgroundColors = payload.textBackgroundColors, textPageIndices = payload.textPageIndices,
textFontSizes = payload.textFontSizes, textBounds = payload.textBounds,
textFlags = payload.textFlags, textColors = payload.textColors,
textValues = payload.textValues, textBackgroundColors = payload.textBackgroundColors,
textFontPaths = payload.textFontPaths, textFontSizes = payload.textFontSizes,
textFontNames = payload.textFontNames, textFlags = payload.textFlags,
rasterPageIndices = payload.rasterPageIndices, textValues = payload.textValues,
rasterBounds = payload.rasterBounds, textFontPaths = payload.textFontPaths,
rasterWidths = payload.rasterWidths, textFontNames = payload.textFontNames,
rasterHeights = payload.rasterHeights, rasterPageIndices = payload.rasterPageIndices,
rasterPixelOffsets = payload.rasterPixelOffsets, rasterBounds = payload.rasterBounds,
rasterPixels = payload.rasterPixels, rasterWidths = payload.rasterWidths,
highlightPageIndices = payload.highlightPageIndices, rasterHeights = payload.rasterHeights,
highlightColors = payload.highlightColors, rasterPixelOffsets = payload.rasterPixelOffsets,
highlightRectOffsets = payload.highlightRectOffsets, rasterPixels = payload.rasterPixels,
highlightRectCounts = payload.highlightRectCounts, highlightPageIndices = payload.highlightPageIndices,
highlightRects = payload.highlightRects, highlightColors = payload.highlightColors,
highlightContents = payload.highlightContents highlightRectOffsets = payload.highlightRectOffsets,
) highlightRectCounts = payload.highlightRectCounts,
highlightRects = payload.highlightRects,
highlightNames = payload.highlightNames,
highlightContents = payload.highlightContents,
highlightCommentOffsets = payload.highlightCommentOffsets,
highlightCommentCounts = payload.highlightCommentCounts,
highlightCommentParentIndices = payload.highlightCommentParentIndices,
highlightCommentNames = payload.highlightCommentNames,
highlightCommentAuthors = payload.highlightCommentAuthors,
highlightCommentContents = payload.highlightCommentContents,
highlightCommentCreatedDates = payload.highlightCommentCreatedDates,
highlightCommentModifiedDates = payload.highlightCommentModifiedDates
)
}
if (!exported) { if (!exported) {
throw IOException("PDFium failed to write annotated PDF.") throw IOException("PDFium failed to write annotated PDF.")
@ -183,15 +208,21 @@ internal object PdfiumAnnotationExporter {
highlights: List<PdfUserHighlight>, highlights: List<PdfUserHighlight>,
richTextPageLayouts: List<PageTextLayout> = emptyList(), richTextPageLayouts: List<PageTextLayout> = emptyList(),
fontPathResolver: (String?) -> String? = { it }, fontPathResolver: (String?) -> String? = { it },
rasterOverlays: List<PdfiumRasterOverlay> = emptyList() rasterOverlays: List<PdfiumRasterOverlay> = emptyList(),
pageSizes: List<PdfiumPageSize> = emptyList()
): PdfiumAnnotationExportPayload { ): PdfiumAnnotationExportPayload {
val inkItems = inkAnnotations.entries val exportPayload = SharedPdfAnnotationExportMapper.build(
.flatMap { (pageIndex, annotations) -> annotations.map { pageIndex to it } } sharedExportAnnotations(
.filter { (_, annotation) -> inkAnnotations = inkAnnotations,
annotation.points.size >= 2 && highlights = highlights,
annotation.inkType != InkType.ERASER && pageSizes = pageSizes
annotation.inkType != InkType.TEXT )
} )
val inkItems = exportPayload.inkAnnotations
val inkPointsForExport = inkItems.map { annotation ->
val pageSize = pageSizeFor(pageSizes, annotation.pageIndex)
annotation.pdfInkAppearancePoints(pageSize.width.toFloat(), pageSize.height.toFloat())
}
val inkPageIndices = IntArray(inkItems.size) val inkPageIndices = IntArray(inkItems.size)
val inkTypes = IntArray(inkItems.size) val inkTypes = IntArray(inkItems.size)
@ -199,17 +230,22 @@ internal object PdfiumAnnotationExporter {
val inkStrokeWidths = FloatArray(inkItems.size) val inkStrokeWidths = FloatArray(inkItems.size)
val inkPointOffsets = IntArray(inkItems.size) val inkPointOffsets = IntArray(inkItems.size)
val inkPointCounts = IntArray(inkItems.size) val inkPointCounts = IntArray(inkItems.size)
val inkPoints = FloatArray(inkItems.sumOf { it.second.points.size } * 2) val inkPoints = FloatArray(inkPointsForExport.sumOf { it.size } * 2)
val inkNames = Array(inkItems.size) { "" }
val inkContents = Array(inkItems.size) { "" }
var inkPointCursor = 0 var inkPointCursor = 0
inkItems.forEachIndexed { index, (pageIndex, annotation) -> inkItems.forEachIndexed { index, annotation ->
inkPageIndices[index] = pageIndex val points = inkPointsForExport[index]
inkTypes[index] = annotation.inkType.ordinal inkPageIndices[index] = annotation.pageIndex
inkColors[index] = annotation.color.toArgb() inkTypes[index] = annotation.tool.toAndroidInkTypeOrdinal()
inkColors[index] = annotation.colorArgb
inkStrokeWidths[index] = annotation.strokeWidth inkStrokeWidths[index] = annotation.strokeWidth
inkPointOffsets[index] = inkPointCursor / 2 inkPointOffsets[index] = inkPointCursor / 2
inkPointCounts[index] = annotation.points.size inkPointCounts[index] = points.size
annotation.points.forEach { point -> inkNames[index] = annotation.id
inkContents[index] = annotation.contents
points.forEach { point ->
inkPoints[inkPointCursor++] = point.x inkPoints[inkPointCursor++] = point.x
inkPoints[inkPointCursor++] = point.y inkPoints[inkPointCursor++] = point.y
} }
@ -246,22 +282,49 @@ internal object PdfiumAnnotationExporter {
rasterPixelCursor += overlay.pixels.size rasterPixelCursor += overlay.pixels.size
} }
val boundedHighlights = highlights.filter { it.bounds.isNotEmpty() } val boundedHighlights = exportPayload.highlightAnnotations
val highlightPageIndices = IntArray(boundedHighlights.size) val highlightPageIndices = IntArray(boundedHighlights.size)
val highlightColors = IntArray(boundedHighlights.size) val highlightColors = IntArray(boundedHighlights.size)
val highlightRectOffsets = IntArray(boundedHighlights.size) val highlightRectOffsets = IntArray(boundedHighlights.size)
val highlightRectCounts = IntArray(boundedHighlights.size) val highlightRectCounts = IntArray(boundedHighlights.size)
val highlightRects = FloatArray(boundedHighlights.sumOf { it.bounds.size } * 4) val highlightRects = FloatArray(boundedHighlights.sumOf { it.boundsList.size } * 4)
val highlightNames = Array(boundedHighlights.size) { "" }
val highlightContents = Array(boundedHighlights.size) { "" } val highlightContents = Array(boundedHighlights.size) { "" }
val highlightCommentCount = boundedHighlights.sumOf { it.comments.size }
val highlightCommentOffsets = IntArray(boundedHighlights.size)
val highlightCommentCounts = IntArray(boundedHighlights.size)
val highlightCommentParentIndices = IntArray(highlightCommentCount)
val highlightCommentNames = Array(highlightCommentCount) { "" }
val highlightCommentAuthors = Array(highlightCommentCount) { "" }
val highlightCommentContents = Array(highlightCommentCount) { "" }
val highlightCommentCreatedDates = Array(highlightCommentCount) { "" }
val highlightCommentModifiedDates = Array(highlightCommentCount) { "" }
var highlightRectCursor = 0 var highlightRectCursor = 0
var highlightCommentCursor = 0
boundedHighlights.forEachIndexed { index, highlight -> boundedHighlights.forEachIndexed { index, highlight ->
highlightPageIndices[index] = highlight.pageIndex highlightPageIndices[index] = highlight.pageIndex
highlightColors[index] = highlight.color.color.toArgb() highlightColors[index] = highlight.colorArgb
highlightRectOffsets[index] = highlightRectCursor / 4 highlightRectOffsets[index] = highlightRectCursor / 4
highlightRectCounts[index] = highlight.bounds.size highlightRectCounts[index] = highlight.boundsList.size
highlightContents[index] = highlight.note?.takeIf { it.isNotBlank() } ?: highlight.text highlightNames[index] = highlight.id
highlight.bounds.forEach { rect -> highlightContents[index] = highlight.contents
highlightCommentOffsets[index] = highlightCommentCursor
highlightCommentCounts[index] = highlight.comments.size
val localCommentIndices = mutableMapOf<String, Int>()
highlight.comments.forEachIndexed { localIndex, comment ->
val globalIndex = highlightCommentCursor + localIndex
highlightCommentParentIndices[globalIndex] = comment.parentId?.let(localCommentIndices::get) ?: -1
localCommentIndices[comment.id] = localIndex
highlightCommentNames[globalIndex] = comment.id
highlightCommentAuthors[globalIndex] = comment.author
highlightCommentContents[globalIndex] = comment.contents
highlightCommentCreatedDates[globalIndex] = comment.createdAt.toPdfDateString()
highlightCommentModifiedDates[globalIndex] = comment.modifiedAt.toPdfDateString()
.ifBlank { comment.createdAt.toPdfDateString() }
}
highlightCommentCursor += highlight.comments.size
highlight.boundsList.forEach { rect ->
highlightRects[highlightRectCursor++] = rect.left highlightRects[highlightRectCursor++] = rect.left
highlightRects[highlightRectCursor++] = rect.top highlightRects[highlightRectCursor++] = rect.top
highlightRects[highlightRectCursor++] = rect.right highlightRects[highlightRectCursor++] = rect.right
@ -277,6 +340,8 @@ internal object PdfiumAnnotationExporter {
inkPointOffsets = inkPointOffsets, inkPointOffsets = inkPointOffsets,
inkPointCounts = inkPointCounts, inkPointCounts = inkPointCounts,
inkPoints = inkPoints, inkPoints = inkPoints,
inkNames = inkNames,
inkContents = inkContents,
textPageIndices = textPageIndices, textPageIndices = textPageIndices,
textBounds = textBounds, textBounds = textBounds,
textColors = textColors, textColors = textColors,
@ -297,7 +362,103 @@ internal object PdfiumAnnotationExporter {
highlightRectOffsets = highlightRectOffsets, highlightRectOffsets = highlightRectOffsets,
highlightRectCounts = highlightRectCounts, highlightRectCounts = highlightRectCounts,
highlightRects = highlightRects, highlightRects = highlightRects,
highlightContents = highlightContents highlightNames = highlightNames,
highlightContents = highlightContents,
highlightCommentOffsets = highlightCommentOffsets,
highlightCommentCounts = highlightCommentCounts,
highlightCommentParentIndices = highlightCommentParentIndices,
highlightCommentNames = highlightCommentNames,
highlightCommentAuthors = highlightCommentAuthors,
highlightCommentContents = highlightCommentContents,
highlightCommentCreatedDates = highlightCommentCreatedDates,
highlightCommentModifiedDates = highlightCommentModifiedDates
)
}
private fun sharedExportAnnotations(
inkAnnotations: Map<Int, List<PdfAnnotation>>,
highlights: List<PdfUserHighlight>,
pageSizes: List<PdfiumPageSize>
): List<SharedPdfAnnotation> {
val annotations = mutableListOf<SharedPdfAnnotation>()
inkAnnotations.entries.forEach { (pageIndex, pageAnnotations) ->
pageAnnotations.forEach { annotation ->
if (annotation.type != AnnotationType.INK) return@forEach
annotations += SharedPdfAnnotation(
id = annotation.id,
pageIndex = pageIndex,
kind = PdfAnnotationKind.INK,
tool = annotation.inkType.toSharedPdfInkTool(),
points = annotation.points.map { point ->
PdfPagePoint(point.x, point.y, point.timestamp)
},
note = annotation.note,
colorArgb = annotation.color.toArgb(),
strokeWidth = annotation.strokeWidth
)
}
}
highlights.forEach { highlight ->
val boundsList = highlight.bounds.mapNotNull { rect ->
rect.toNormalizedPdfPageBounds(pageSizeFor(pageSizes, highlight.pageIndex))
}
annotations += SharedPdfAnnotation(
id = highlight.id,
pageIndex = highlight.pageIndex,
kind = PdfAnnotationKind.HIGHLIGHT,
tool = PdfInkTool.HIGHLIGHTER,
bounds = boundsList.firstOrNull(),
boundsList = boundsList,
text = highlight.text,
note = highlight.note,
comments = highlight.comments,
colorArgb = highlight.color.color.toArgb(),
rangeStartIndex = highlight.range.first,
rangeEndIndex = (highlight.range.second - 1).coerceAtLeast(highlight.range.first)
)
}
return annotations
}
private fun InkType.toSharedPdfInkTool(): PdfInkTool {
return when (this) {
InkType.PEN -> PdfInkTool.PEN
InkType.HIGHLIGHTER -> PdfInkTool.HIGHLIGHTER
InkType.HIGHLIGHTER_ROUND -> PdfInkTool.HIGHLIGHTER_ROUND
InkType.ERASER -> PdfInkTool.ERASER
InkType.FOUNTAIN_PEN -> PdfInkTool.FOUNTAIN_PEN
InkType.PENCIL -> PdfInkTool.PENCIL
InkType.TEXT -> PdfInkTool.TEXT
}
}
private fun PdfInkTool.toAndroidInkTypeOrdinal(): Int {
return when (this) {
PdfInkTool.HIGHLIGHTER -> InkType.HIGHLIGHTER.ordinal
PdfInkTool.HIGHLIGHTER_ROUND -> InkType.HIGHLIGHTER_ROUND.ordinal
PdfInkTool.FOUNTAIN_PEN -> InkType.FOUNTAIN_PEN.ordinal
PdfInkTool.PENCIL -> InkType.PENCIL.ordinal
PdfInkTool.TEXT -> InkType.TEXT.ordinal
PdfInkTool.ERASER -> InkType.ERASER.ordinal
PdfInkTool.NONE,
PdfInkTool.PEN -> InkType.PEN.ordinal
}
}
private fun RectF.toNormalizedPdfPageBounds(pageSize: PdfiumPageSize): PdfPageBounds? {
val pageWidth = pageSize.width.takeIf { it > 0 }?.toFloat() ?: return null
val pageHeight = pageSize.height.takeIf { it > 0 }?.toFloat() ?: return null
val pdfLeft = minOf(left, right)
val pdfRight = maxOf(left, right)
val pdfTop = maxOf(top, bottom)
val pdfBottom = minOf(top, bottom)
if (pdfRight <= pdfLeft || pdfTop <= pdfBottom) return null
return PdfPageBounds(
left = pdfLeft / pageWidth,
top = (pageHeight - pdfTop) / pageHeight,
right = pdfRight / pageWidth,
bottom = (pageHeight - pdfBottom) / pageHeight
) )
} }
@ -681,6 +842,13 @@ internal object PdfiumAnnotationExporter {
private fun String.sanitizeRasterTextPreservingLength(): String = private fun String.sanitizeRasterTextPreservingLength(): String =
replace(PAGE_BREAK_CHAR, '\n') replace(PAGE_BREAK_CHAR, '\n')
.replace('\r', ' ') .replace('\r', ' ')
private fun Long.toPdfDateString(): String {
if (this <= 0L) return ""
return SimpleDateFormat("'D:'yyyyMMddHHmmss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(this))
}
} }
internal data class PdfiumRasterOverlay( internal data class PdfiumRasterOverlay(
@ -694,7 +862,7 @@ internal data class PdfiumRasterOverlay(
val pixels: IntArray val pixels: IntArray
) )
private data class PdfiumPageSize( internal data class PdfiumPageSize(
val width: Int, val width: Int,
val height: Int val height: Int
) { ) {
@ -738,6 +906,8 @@ internal data class PdfiumAnnotationExportPayload(
val inkPointOffsets: IntArray, val inkPointOffsets: IntArray,
val inkPointCounts: IntArray, val inkPointCounts: IntArray,
val inkPoints: FloatArray, val inkPoints: FloatArray,
val inkNames: Array<String>,
val inkContents: Array<String>,
val textPageIndices: IntArray, val textPageIndices: IntArray,
val textBounds: FloatArray, val textBounds: FloatArray,
val textColors: IntArray, val textColors: IntArray,
@ -758,7 +928,16 @@ internal data class PdfiumAnnotationExportPayload(
val highlightRectOffsets: IntArray, val highlightRectOffsets: IntArray,
val highlightRectCounts: IntArray, val highlightRectCounts: IntArray,
val highlightRects: FloatArray, val highlightRects: FloatArray,
val highlightContents: Array<String> val highlightNames: Array<String>,
val highlightContents: Array<String>,
val highlightCommentOffsets: IntArray,
val highlightCommentCounts: IntArray,
val highlightCommentParentIndices: IntArray,
val highlightCommentNames: Array<String>,
val highlightCommentAuthors: Array<String>,
val highlightCommentContents: Array<String>,
val highlightCommentCreatedDates: Array<String>,
val highlightCommentModifiedDates: Array<String>
) { ) {
fun hasAnnotations(): Boolean = fun hasAnnotations(): Boolean =
inkPageIndices.isNotEmpty() || inkPageIndices.isNotEmpty() ||

View file

@ -44,9 +44,11 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.aryan.reader.pdf.data.VirtualPage
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@ -460,6 +462,65 @@ private fun AnnotatedString.withRestoredTrailingAndroidPageBreak(shouldRestore:
return this + AnnotatedString(PAGE_BREAK_CHAR.toString()) return this + AnnotatedString(PAGE_BREAK_CHAR.toString())
} }
internal fun androidRichTextInsertionIndexForPage(
insertPageIndex: Int,
pageLayouts: List<PageTextLayout>,
textLength: Int
): Int {
val rawIndex = if (insertPageIndex <= 0) {
0
} else {
pageLayouts.find { it.pageIndex == insertPageIndex - 1 }?.globalEndIndex ?: textLength
}
return rawIndex.coerceIn(0, textLength)
}
internal fun androidRichTextBlankInsertBreakCount(text: String, insertionCharIndex: Int): Int {
val safeIndex = insertionCharIndex.coerceIn(0, text.length)
if (safeIndex == 0 || safeIndex == text.length) return 1
val hasBoundaryBreakBefore = text.getOrNull(safeIndex - 1) == PAGE_BREAK_CHAR
val hasBoundaryBreakAfter = text.getOrNull(safeIndex) == PAGE_BREAK_CHAR
return if (hasBoundaryBreakBefore || hasBoundaryBreakAfter) 1 else 2
}
internal fun remapAndroidRichTextForLayoutChange(
currentLayout: List<VirtualPage>,
updatedLayout: List<VirtualPage>,
pageLayouts: List<PageTextLayout>
): AnnotatedString {
if (pageLayouts.isEmpty()) return AnnotatedString("")
val mapping = buildPdfPageIndexMapping(
currentLayout = currentLayout,
updatedLayout = updatedLayout,
sourcePageIndices = pageLayouts.map { it.pageIndex }
)
if (mapping.isEmpty()) return AnnotatedString("")
val contentByTargetPage = linkedMapOf<Int, AnnotatedString>()
pageLayouts.sortedBy { it.pageIndex }.forEach { layout ->
val targetPageIndex = mapping[layout.pageIndex] ?: return@forEach
val pageContent = layout.visibleText.withoutTrailingAndroidPageBreak()
contentByTargetPage[targetPageIndex] = pageContent
}
val lastPageWithContent = contentByTargetPage
.filterValues { it.text.isNotEmpty() }
.keys
.maxOrNull()
?: return AnnotatedString("")
val builder = AnnotatedString.Builder()
for (pageIndex in 0..lastPageWithContent) {
contentByTargetPage[pageIndex]?.let { builder.append(it) }
if (pageIndex < lastPageWithContent) {
builder.append(PAGE_BREAK_CHAR.toString())
}
}
return builder.toAnnotatedString()
}
class PdfRichTextRepository(private val context: Context) { class PdfRichTextRepository(private val context: Context) {
private val _document = MutableStateFlow<GlobalRichDocument?>(null) private val _document = MutableStateFlow<GlobalRichDocument?>(null)
val document = _document.asStateFlow() val document = _document.asStateFlow()
@ -1166,30 +1227,139 @@ class RichTextController(
val original = globalTextFieldValue.annotatedString val original = globalTextFieldValue.annotatedString
Timber.tag("RichTextMigration").d("insertPageBreakAt: Target Page Index: $insertPageIndex, Count: $count") Timber.tag("RichTextMigration").d("insertPageBreakAt: Target Page Index: $insertPageIndex, Count: $count")
val insertionCharIndex = if (insertPageIndex == 0) 0 else { val safeIndex = androidRichTextInsertionIndexForPage(
val prevLayout = pageLayouts.find { it.pageIndex == insertPageIndex - 1 } insertPageIndex = insertPageIndex,
val idx = prevLayout?.globalEndIndex ?: original.length pageLayouts = pageLayouts,
Timber.tag("RichTextMigration").v("insertPageBreakAt: Prev Page (${insertPageIndex - 1}) ends at global index $idx") textLength = original.length
idx )
} Timber.tag("RichTextMigration").v("insertPageBreakAt: insertion index $safeIndex")
val safeIndex = insertionCharIndex.coerceIn(0, original.length)
Timber.tag("RichTextMigration").i("insertPageBreakAt: Inserting $count PAGE_BREAK_CHARs at global index $safeIndex") insertPageBreaksIntoGlobalText(
original = original,
safeIndex = safeIndex,
count = count,
caller = "InsertPageBreakAt"
)
}
}
val builder = AnnotatedString.Builder() fun insertBlankPageAt(insertPageIndex: Int) {
builder.append(original.subSequence(0, safeIndex)) scope.launch {
forceSyncAndClear()
repeat(count) { val original = globalTextFieldValue.annotatedString
builder.append(PAGE_BREAK_CHAR.toString()) val safeIndex = androidRichTextInsertionIndexForPage(
} insertPageIndex = insertPageIndex,
pageLayouts = pageLayouts,
textLength = original.length
)
val requiredBreaks = androidRichTextBlankInsertBreakCount(
text = original.text,
insertionCharIndex = safeIndex
)
builder.append(original.subSequence(safeIndex, original.length)) Timber.tag("RichTextMigration").i(
"insertBlankPageAt: page=$insertPageIndex index=$safeIndex breaks=$requiredBreaks"
)
val newCursorPos = safeIndex + count insertPageBreaksIntoGlobalText(
original = original,
safeIndex = safeIndex,
count = requiredBreaks,
caller = "InsertBlankPageAt"
)
}
}
globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(newCursorPos)) suspend fun remapPagesForLayoutChange(
debouncedSave(globalTextFieldValue) currentLayout: List<VirtualPage>,
repaginate(dirtyStartIndex = safeIndex, caller = "InsertPageBreakAt") updatedLayout: List<VirtualPage>
) = withContext(NonCancellable) {
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"rich.remap.start current=${currentLayout.pdfLayoutDebugSummary()} " +
"updated=${updatedLayout.pdfLayoutDebugSummary()} pageLayouts=${pageLayouts.size} " +
"textLen=${globalTextFieldValue.annotatedString.length}"
)
forceSyncAndClear()
val original = globalTextFieldValue.annotatedString
if (pageLayouts.isEmpty() && original.text.isNotEmpty()) {
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
"rich.remap.skipNoLayouts current=${currentLayout.pdfLayoutDebugSummary()} " +
"updated=${updatedLayout.pdfLayoutDebugSummary()} textLen=${original.length}"
)
Timber.tag("RichTextMigration").w(
"remapPagesForLayoutChange skipped: no rich text page layouts for non-empty text"
)
return@withContext
}
val remapped = remapAndroidRichTextForLayoutChange(
currentLayout = currentLayout,
updatedLayout = updatedLayout,
pageLayouts = pageLayouts
)
if (remapped == original) {
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"rich.remap.noChange current=${currentLayout.pdfLayoutDebugSummary()} " +
"updated=${updatedLayout.pdfLayoutDebugSummary()} textLen=${original.length}"
)
return@withContext
}
Timber.tag("RichTextMigration").i(
"remapPagesForLayoutChange: textLen ${original.length} -> ${remapped.length}, " +
"pages ${currentLayout.size} -> ${updatedLayout.size}"
)
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"rich.remap.apply textLen=${original.length}->${remapped.length} " +
"current=${currentLayout.pdfLayoutDebugSummary()} updated=${updatedLayout.pdfLayoutDebugSummary()}"
)
globalTextFieldValue = TextFieldValue(
remapped,
selection = TextRange(remapped.length)
)
repaginateSync(0)
saveCurrentGlobalTextImmediately()
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"rich.remap.done textLen=${globalTextFieldValue.annotatedString.length} pageLayouts=${pageLayouts.size}"
)
}
private fun insertPageBreaksIntoGlobalText(
original: AnnotatedString,
safeIndex: Int,
count: Int,
caller: String
) {
val safeCount = count.coerceAtLeast(0)
if (safeCount == 0) return
Timber.tag("RichTextMigration").i("$caller: Inserting $safeCount PAGE_BREAK_CHARs at global index $safeIndex")
val builder = AnnotatedString.Builder()
builder.append(original.subSequence(0, safeIndex))
repeat(safeCount) {
builder.append(PAGE_BREAK_CHAR.toString())
}
builder.append(original.subSequence(safeIndex, original.length))
val newCursorPos = safeIndex + safeCount
globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(newCursorPos))
debouncedSave(globalTextFieldValue)
repaginate(dirtyStartIndex = safeIndex, caller = caller)
}
private suspend fun saveCurrentGlobalTextImmediately() {
saveJob?.cancel()
val finalAnnotated = globalTextFieldValue.annotatedString
withContext(Dispatchers.Default) {
val doc = RichTextMapper.fromAnnotatedString(finalAnnotated, lastPageHeight)
repository.save(bookId, doc)
} }
} }

View file

@ -64,6 +64,19 @@ interface ReaderTextPage : AutoCloseable {
data class ReaderLink(val uri: String?, val destPageIdx: Int?, val bounds: RectF) data class ReaderLink(val uri: String?, val destPageIdx: Int?, val bounds: RectF)
data class ReaderTextRect(val rect: RectF) data class ReaderTextRect(val rect: RectF)
internal data class PdfNativePageOverlayExtraction(
val embeddedAnnotations: List<EmbeddedAnnotation> = emptyList(),
val annotationScreenRects: List<Pair<EmbeddedAnnotation, Rect>> = emptyList(),
val imageScreenRects: List<Rect> = emptyList(),
val resolvedNativePointer: Boolean = true
)
internal data class PdfNativeTapResult(
val linkInfo: String? = null,
val clickHandled: Boolean = false,
val resolvedNativePointer: Boolean = true
)
interface ReaderWebLinks : AutoCloseable { interface ReaderWebLinks : AutoCloseable {
suspend fun countWebLinks(): Int suspend fun countWebLinks(): Int
suspend fun getURL(linkIndex: Int, maxLength: Int): String? suspend fun getURL(linkIndex: Int, maxLength: Int): String?
@ -103,7 +116,16 @@ object DocumentFactory {
ArchiveDocumentWrapper(cacheFile) ArchiveDocumentWrapper(cacheFile)
} else { } else {
val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw Exception("Failed to open PDF") val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw Exception("Failed to open PDF")
PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) }) try {
PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) })
} catch (e: Throwable) {
try {
pfd.close()
} catch (closeError: Exception) {
e.addSuppressed(closeError)
}
throw e
}
} }
} }
} }
@ -134,13 +156,25 @@ class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
val page = PdfiumEngineProvider.withPdfium { val page = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else pdfDocument.openPage(pageIndex) if (isClosed.get()) null else pdfDocument.openPage(pageIndex)
} ?: return null } ?: return null
return PdfPageWrapper(page) return PdfPageWrapper(page, isClosed)
} }
override suspend fun getTableOfContents() = PdfiumEngineProvider.withPdfium { override suspend fun getTableOfContents() = PdfiumEngineProvider.withPdfium {
pdfDocument.getFixedTableOfContents() pdfDocument.getFixedTableOfContents()
} }
internal fun getNativeDocumentPointerForLockedAccess(): Long {
if (isClosed.get()) return 0L
return try {
val documentField = pdfDocument.javaClass.getDeclaredField("document").apply { isAccessible = true }
val docUInstance = documentField.get(pdfDocument) ?: return 0L
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
ptrField.get(docUInstance) as? Long ?: 0L
} catch (_: Exception) {
0L
}
}
override fun close() { override fun close() {
if (!isClosed.compareAndSet(false, true)) return if (!isClosed.compareAndSet(false, true)) return
PdfiumEngineProvider.withPdfiumBlocking { PdfiumEngineProvider.withPdfiumBlocking {
@ -149,24 +183,28 @@ class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
} }
} }
class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage { class PdfPageWrapper(
val pdfPage: PdfPageKt,
private val ownerClosed: AtomicBoolean = AtomicBoolean(false)
) : ReaderPage {
private val isClosed = AtomicBoolean(false) private val isClosed = AtomicBoolean(false)
private fun isUnavailable(): Boolean = isClosed.get() || ownerClosed.get()
override suspend fun getPageWidthPoint() = PdfiumEngineProvider.withPdfium { override suspend fun getPageWidthPoint() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else pdfPage.getPageWidthPoint() if (isUnavailable()) 0 else pdfPage.getPageWidthPoint()
} }
override suspend fun getPageHeightPoint() = PdfiumEngineProvider.withPdfium { override suspend fun getPageHeightPoint() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else pdfPage.getPageHeightPoint() if (isUnavailable()) 0 else pdfPage.getPageHeightPoint()
} }
override suspend fun getPageRotation() = PdfiumEngineProvider.withPdfium { override suspend fun getPageRotation() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else pdfPage.getPageRotation() if (isUnavailable()) 0 else pdfPage.getPageRotation()
} }
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) { override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
PdfiumEngineProvider.withPdfium { PdfiumEngineProvider.withPdfium {
if (!isClosed.get()) { if (!isUnavailable()) {
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot) pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
} }
} }
@ -174,21 +212,21 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF) = override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF) =
PdfiumEngineProvider.withPdfium { PdfiumEngineProvider.withPdfium {
if (isClosed.get()) Rect() else pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords) if (isUnavailable()) 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) = override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int) =
PdfiumEngineProvider.withPdfium { PdfiumEngineProvider.withPdfium {
if (isClosed.get()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY) if (isUnavailable()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
} }
override suspend fun openTextPage(): ReaderTextPage = PdfiumEngineProvider.withPdfium { override suspend fun openTextPage(): ReaderTextPage = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage()) if (isUnavailable()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage(), ownerClosed, isClosed)
} }
override suspend fun getLinks(): List<ReaderLink> { override suspend fun getLinks(): List<ReaderLink> {
return PdfiumEngineProvider.withPdfium { return PdfiumEngineProvider.withPdfium {
if (isClosed.get()) { if (isUnavailable()) {
emptyList() emptyList()
} else { } else {
pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) } pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
@ -197,9 +235,191 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
} }
override fun getNativePointer(): Long { override fun getNativePointer(): Long {
if (isUnavailable()) return 0L
return extractNativePointer(pdfPage) return extractNativePointer(pdfPage)
} }
internal suspend fun extractNativePageOverlays(
bitmapWidthPx: Int,
bitmapHeightPx: Int,
pageRotation: Int,
pageIndex: Int,
linkAnnotationSubtype: Int
): PdfNativePageOverlayExtraction = PdfiumEngineProvider.withPdfium {
if (isUnavailable() || bitmapWidthPx <= 0 || bitmapHeightPx <= 0) {
return@withPdfium PdfNativePageOverlayExtraction()
}
val pagePtr = extractNativePointer(pdfPage)
if (pagePtr == 0L) {
return@withPdfium PdfNativePageOverlayExtraction(resolvedNativePointer = false)
}
val imageRects = extractImageScreenRectsLocked(pagePtr, bitmapWidthPx, bitmapHeightPx, pageRotation)
val embeddedAnnotations = extractEmbeddedAnnotationsLocked(pagePtr, pageIndex, linkAnnotationSubtype)
val mappedAnnots = embeddedAnnotations.mapNotNull { annotation ->
val screenRect = pdfPage.mapRectToDevice(
0,
0,
bitmapWidthPx,
bitmapHeightPx,
pageRotation,
annotation.rect
)
if (screenRect.width() > 0 && screenRect.height() > 0) {
annotation to screenRect
} else {
null
}
}
PdfNativePageOverlayExtraction(
embeddedAnnotations = embeddedAnnotations,
annotationScreenRects = mappedAnnots,
imageScreenRects = imageRects
)
}
internal suspend fun resolveNativeTap(
documentWrapper: PdfDocumentWrapper?,
bitmapWidthPx: Int,
bitmapHeightPx: Int,
pageRotation: Int,
deviceX: Int,
deviceY: Int
): PdfNativeTapResult = PdfiumEngineProvider.withPdfium {
if (isUnavailable() || bitmapWidthPx <= 0 || bitmapHeightPx <= 0) {
return@withPdfium PdfNativeTapResult()
}
val pagePtr = extractNativePointer(pdfPage)
if (pagePtr == 0L) {
return@withPdfium PdfNativeTapResult(resolvedNativePointer = false)
}
val pdfCoords = pdfPage.mapDeviceCoordsToPage(
0,
0,
bitmapWidthPx,
bitmapHeightPx,
pageRotation,
deviceX,
deviceY
)
val docPtr = documentWrapper?.getNativeDocumentPointerForLockedAccess() ?: 0L
Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr")
val linkInfo = NativePdfiumBridge.getLinkInfoAtPoint(
docPtr,
pagePtr,
pdfCoords.x.toDouble(),
pdfCoords.y.toDouble()
)
if (linkInfo != null) {
return@withPdfium PdfNativeTapResult(linkInfo = linkInfo)
}
PdfNativeTapResult(
clickHandled = NativePdfiumBridge.performClick(
pagePtr,
pdfCoords.x.toDouble(),
pdfCoords.y.toDouble()
)
)
}
private suspend fun extractImageScreenRectsLocked(
pagePtr: Long,
bitmapWidthPx: Int,
bitmapHeightPx: Int,
pageRotation: Int
): List<Rect> {
return try {
val objectCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
if (objectCount <= 0) return emptyList()
val rects = mutableListOf<Rect>()
val outRect = FloatArray(4)
for (index in 0 until objectCount) {
if (NativePdfiumBridge.getPageObjectType(pagePtr, index) != 3) continue
if (!NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, index, outRect)) continue
val pdfRect = RectF(
minOf(outRect[0], outRect[2]),
maxOf(outRect[1], outRect[3]),
maxOf(outRect[0], outRect[2]),
minOf(outRect[1], outRect[3])
)
val deviceRect = pdfPage.mapRectToDevice(
0,
0,
bitmapWidthPx,
bitmapHeightPx,
pageRotation,
pdfRect
)
if (deviceRect.width() > 0 && deviceRect.height() > 0) {
rects += Rect(deviceRect.left, deviceRect.top, deviceRect.right, deviceRect.bottom)
}
}
rects
} catch (e: Exception) {
Timber.tag("PdfImageDebug").e(e, "Error extracting image rects")
emptyList()
}
}
private fun extractEmbeddedAnnotationsLocked(
pagePtr: Long,
pageIndex: Int,
linkAnnotationSubtype: Int
): List<EmbeddedAnnotation> {
return try {
val count = NativePdfiumBridge.getAnnotCount(pagePtr)
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
if (count <= 0) return emptyList()
val annotations = mutableListOf<EmbeddedAnnotation>()
for (index in 0 until count) {
val subtype = NativePdfiumBridge.getAnnotSubtype(pagePtr, index)
if (subtype == linkAnnotationSubtype) continue
var contents = NativePdfiumBridge.getAnnotString(pagePtr, index, "Contents")
if (contents.isNullOrBlank()) {
contents = NativePdfiumBridge.getAnnotString(pagePtr, index, "RC")
}
val pdfRectArray = NativePdfiumBridge.getAnnotRect(pagePtr, index)
val pdfRect = if (pdfRectArray != null) {
RectF(
minOf(pdfRectArray[0], pdfRectArray[2]),
maxOf(pdfRectArray[1], pdfRectArray[3]),
maxOf(pdfRectArray[0], pdfRectArray[2]),
minOf(pdfRectArray[1], pdfRectArray[3])
)
} else {
RectF()
}
annotations += EmbeddedAnnotation(
index = index,
subtype = subtype,
rect = pdfRect,
contents = contents,
author = NativePdfiumBridge.getAnnotString(pagePtr, index, "T"),
name = NativePdfiumBridge.getAnnotString(pagePtr, index, "NM"),
inReplyTo = NativePdfiumBridge.getAnnotString(pagePtr, index, "IRT")
)
}
groupEmbeddedAnnotationsForDisplay(annotations)
} catch (e: Exception) {
Timber.tag("PdfCommentDebug").e(e, "Error extracting annotations")
emptyList()
}
}
private fun extractNativePointer(obj: Any): Long { private fun extractNativePointer(obj: Any): Long {
val priorityFields = listOf("page", "mNativePagePtr", "pagePtr", "mNativePage") val priorityFields = listOf("page", "mNativePagePtr", "pagePtr", "mNativePage")
@ -231,66 +451,74 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
override fun close() { override fun close() {
if (!isClosed.compareAndSet(false, true)) return if (!isClosed.compareAndSet(false, true)) return
if (ownerClosed.get()) return
PdfiumEngineProvider.withPdfiumBlocking { PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfPageWrapper") { pdfPage.close() } closePdfiumResource("PdfPageWrapper") { pdfPage.close() }
} }
} }
} }
class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage { class PdfTextPageWrapper(
private val textPage: PdfTextPageKt,
private val ownerClosed: AtomicBoolean = AtomicBoolean(false),
private val pageClosed: AtomicBoolean = AtomicBoolean(false)
) : ReaderTextPage {
private val isClosed = AtomicBoolean(false) private val isClosed = AtomicBoolean(false)
private fun isUnavailable(): Boolean = isClosed.get() || ownerClosed.get() || pageClosed.get()
override suspend fun textPageCountChars() = PdfiumEngineProvider.withPdfium { override suspend fun textPageCountChars() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else textPage.textPageCountChars() if (isUnavailable()) 0 else textPage.textPageCountChars()
} }
override suspend fun textPageGetText(startIndex: Int, count: Int) = PdfiumEngineProvider.withPdfium { override suspend fun textPageGetText(startIndex: Int, count: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.textPageGetText(startIndex, count) if (isUnavailable()) null else textPage.textPageGetText(startIndex, count)
} }
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = PdfiumEngineProvider.withPdfium { override suspend fun textPageGetRectsForRanges(ranges: IntArray) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) } if (isUnavailable()) null else textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
} }
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = PdfiumEngineProvider.withPdfium { 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) if (isUnavailable()) -1 else textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
} }
override suspend fun textPageGetCharBox(index: Int) = PdfiumEngineProvider.withPdfium { override suspend fun textPageGetCharBox(index: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.textPageGetCharBox(index) if (isUnavailable()) null else textPage.textPageGetCharBox(index)
} }
override suspend fun textPageGetUnicode(index: Int): Int { override suspend fun textPageGetUnicode(index: Int): Int {
return PdfiumEngineProvider.withPdfium { return PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else textPage.textPageGetUnicode(index).code if (isUnavailable()) 0 else textPage.textPageGetUnicode(index).code
} }
} }
override suspend fun loadWebLink(): ReaderWebLinks? { override suspend fun loadWebLink(): ReaderWebLinks? {
val links = PdfiumEngineProvider.withPdfium { val links = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else textPage.loadWebLink() if (isUnavailable()) null else textPage.loadWebLink()
} ?: return null } ?: return null
return object : ReaderWebLinks { return object : ReaderWebLinks {
private val isClosed = AtomicBoolean(false) private val isClosed = AtomicBoolean(false)
private fun isUnavailable(): Boolean = isClosed.get() || ownerClosed.get() || pageClosed.get()
override suspend fun countWebLinks() = PdfiumEngineProvider.withPdfium { override suspend fun countWebLinks() = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else links.countWebLinks() if (isUnavailable()) 0 else links.countWebLinks()
} }
override suspend fun getURL(linkIndex: Int, maxLength: Int) = PdfiumEngineProvider.withPdfium { override suspend fun getURL(linkIndex: Int, maxLength: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) null else links.getURL(linkIndex, maxLength) if (isUnavailable()) null else links.getURL(linkIndex, maxLength)
} }
override suspend fun countRects(linkIndex: Int) = PdfiumEngineProvider.withPdfium { override suspend fun countRects(linkIndex: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) 0 else links.countRects(linkIndex) if (isUnavailable()) 0 else links.countRects(linkIndex)
} }
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = PdfiumEngineProvider.withPdfium { override suspend fun getRect(linkIndex: Int, rectIndex: Int) = PdfiumEngineProvider.withPdfium {
if (isClosed.get()) RectF() else links.getRect(linkIndex, rectIndex) if (isUnavailable()) RectF() else links.getRect(linkIndex, rectIndex)
} }
override fun close() { override fun close() {
if (!isClosed.compareAndSet(false, true)) return if (!isClosed.compareAndSet(false, true)) return
if (ownerClosed.get() || pageClosed.get()) return
PdfiumEngineProvider.withPdfiumBlocking { PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfWebLinksWrapper") { links.close() } closePdfiumResource("PdfWebLinksWrapper") { links.close() }
} }
@ -299,6 +527,7 @@ class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
} }
override fun close() { override fun close() {
if (!isClosed.compareAndSet(false, true)) return if (!isClosed.compareAndSet(false, true)) return
if (ownerClosed.get() || pageClosed.get()) return
PdfiumEngineProvider.withPdfiumBlocking { PdfiumEngineProvider.withPdfiumBlocking {
closePdfiumResource("PdfTextPageWrapper") { textPage.close() } closePdfiumResource("PdfTextPageWrapper") { textPage.close() }
} }

View file

@ -20,6 +20,8 @@
package com.aryan.reader.pdf.data package com.aryan.reader.pdf.data
import android.content.Context import android.content.Context
import com.aryan.reader.pdf.PDF_BLANK_PAGE_PERSISTENCE_TAG
import com.aryan.reader.pdf.pdfLayoutDebugSummary
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
@ -41,6 +43,12 @@ class PageLayoutRepository(private val context: Context) {
} }
suspend fun saveLayout(bookId: String, pages: List<VirtualPage>) = withContext(Dispatchers.IO) { suspend fun saveLayout(bookId: String, pages: List<VirtualPage>) = withContext(Dispatchers.IO) {
val file = getFile(bookId)
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"repo.saveLayout.start bookId=$bookId file=${file.absolutePath} " +
"beforeExists=${file.exists()} beforeBytes=${if (file.exists()) file.length() else 0L} " +
"beforeMtime=${if (file.exists()) file.lastModified() else 0L} layout=${pages.pdfLayoutDebugSummary()}"
)
val jsonArray = JSONArray() val jsonArray = JSONArray()
pages.forEach { page -> pages.forEach { page ->
val obj = JSONObject() val obj = JSONObject()
@ -59,13 +67,27 @@ class PageLayoutRepository(private val context: Context) {
} }
jsonArray.put(obj) jsonArray.put(obj)
} }
getFile(bookId).writeText(jsonArray.toString()) file.writeText(jsonArray.toString())
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"repo.saveLayout.done bookId=$bookId file=${file.absolutePath} " +
"afterExists=${file.exists()} afterBytes=${file.length()} afterMtime=${file.lastModified()} " +
"layout=${pages.pdfLayoutDebugSummary()}"
)
} }
suspend fun loadLayout(bookId: String, totalPdfPages: Int): List<VirtualPage> = withContext(Dispatchers.IO) { suspend fun loadLayout(bookId: String, totalPdfPages: Int): List<VirtualPage> = withContext(Dispatchers.IO) {
val file = getFile(bookId) val file = getFile(bookId)
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"repo.loadLayout.start bookId=$bookId totalPdfPages=$totalPdfPages file=${file.absolutePath} " +
"exists=${file.exists()} bytes=${if (file.exists()) file.length() else 0L} " +
"mtime=${if (file.exists()) file.lastModified() else 0L}"
)
if (!file.exists()) { if (!file.exists()) {
return@withContext (0 until totalPdfPages).map { VirtualPage.PdfPage(it) } val fallback = (0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w(
"repo.loadLayout.missing bookId=$bookId returningDefault=${fallback.pdfLayoutDebugSummary()}"
)
return@withContext fallback
} }
try { try {
@ -84,18 +106,32 @@ class PageLayoutRepository(private val context: Context) {
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h, isManual)) list.add(VirtualPage.BlankPage(obj.getString("id"), w, h, isManual))
} }
} }
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"repo.loadLayout.parsed bookId=$bookId layout=${list.pdfLayoutDebugSummary()}"
)
list list
} catch (_: Exception) { } catch (e: Exception) {
(0 until totalPdfPages).map { VirtualPage.PdfPage(it) } val fallback = (0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e(
e,
"repo.loadLayout.failed bookId=$bookId returningDefault=${fallback.pdfLayoutDebugSummary()}"
)
fallback
} }
} }
suspend fun getLayoutOrNull(bookId: String): List<VirtualPage>? = withContext(Dispatchers.IO) { suspend fun getLayoutOrNull(bookId: String): List<VirtualPage>? = withContext(Dispatchers.IO) {
val file = getFile(bookId) val file = getFile(bookId)
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"repo.getLayoutOrNull.start bookId=$bookId file=${file.absolutePath} " +
"exists=${file.exists()} bytes=${if (file.exists()) file.length() else 0L} " +
"mtime=${if (file.exists()) file.lastModified() else 0L}"
)
Timber.tag("PdfExportDebug").d("PageLayoutRepo: Looking for layout at ${file.absolutePath}") Timber.tag("PdfExportDebug").d("PageLayoutRepo: Looking for layout at ${file.absolutePath}")
Timber.tag("PdfExportDebug").d("PageLayoutRepo: File exists: ${file.exists()}") Timber.tag("PdfExportDebug").d("PageLayoutRepo: File exists: ${file.exists()}")
if (!file.exists()) { if (!file.exists()) {
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w("repo.getLayoutOrNull.missing bookId=$bookId")
Timber.tag("PdfExportDebug").w("PageLayoutRepo: No layout file for book $bookId") Timber.tag("PdfExportDebug").w("PageLayoutRepo: No layout file for book $bookId")
return@withContext null return@withContext null
} }
@ -114,14 +150,19 @@ class PageLayoutRepository(private val context: Context) {
} else { } else {
val w = obj.optInt("w", 595) val w = obj.optInt("w", 595)
val h = obj.optInt("h", 842) val h = obj.optInt("h", 842)
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h)) val isManual = obj.optBoolean("manual", false)
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h, isManual))
} }
} }
Timber.tag("PdfExportDebug").i("PageLayoutRepo: Parsed ${list.size} virtual pages (${ Timber.tag("PdfExportDebug").i("PageLayoutRepo: Parsed ${list.size} virtual pages (${
list.count { it is VirtualPage.PdfPage } list.count { it is VirtualPage.PdfPage }
} PDF, ${list.count { it is VirtualPage.BlankPage }} blank)") } PDF, ${list.count { it is VirtualPage.BlankPage }} blank)")
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i(
"repo.getLayoutOrNull.parsed bookId=$bookId layout=${list.pdfLayoutDebugSummary()}"
)
list list
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).e(e, "repo.getLayoutOrNull.failed bookId=$bookId")
Timber.tag("PdfExportDebug").e(e, "PageLayoutRepo: Failed to parse layout") Timber.tag("PdfExportDebug").e(e, "PageLayoutRepo: Failed to parse layout")
null null
} }

View file

@ -28,6 +28,7 @@ import com.aryan.reader.pdf.InkType
import com.aryan.reader.pdf.PdfHighlightColor import com.aryan.reader.pdf.PdfHighlightColor
import com.aryan.reader.pdf.PdfPoint import com.aryan.reader.pdf.PdfPoint
import com.aryan.reader.pdf.PdfUserHighlight import com.aryan.reader.pdf.PdfUserHighlight
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.Locale import java.util.Locale
@ -237,6 +238,10 @@ object HighlightSerializer {
if (!h.note.isNullOrBlank()) { if (!h.note.isNullOrBlank()) {
obj.put("note", h.note) obj.put("note", h.note)
} }
val commentsArray = h.comments.toJsonArray()
if (commentsArray.length() > 0) {
obj.put("comments", commentsArray)
}
val boundsArray = JSONArray() val boundsArray = JSONArray()
h.bounds.forEach { r -> h.bounds.forEach { r ->
@ -279,7 +284,8 @@ object HighlightSerializer {
color = try { PdfHighlightColor.valueOf(obj.getString("color")) } catch(_: Exception) { PdfHighlightColor.YELLOW }, color = try { PdfHighlightColor.valueOf(obj.getString("color")) } catch(_: Exception) { PdfHighlightColor.YELLOW },
text = obj.optString("text", ""), text = obj.optString("text", ""),
range = Pair(obj.optInt("rangeStart", 0), obj.optInt("rangeEnd", 0)), range = Pair(obj.optInt("rangeStart", 0), obj.optInt("rangeEnd", 0)),
note = obj.optString("note").takeIf { !it.isNullOrBlank() } note = obj.optString("note").takeIf { !it.isNullOrBlank() },
comments = obj.optJSONArray("comments").toSharedPdfAnnotationComments()
) )
) )
} }
@ -288,4 +294,50 @@ object HighlightSerializer {
} }
return result return result
} }
private fun List<SharedPdfAnnotationComment>.toJsonArray(): JSONArray {
val array = JSONArray()
forEach { comment ->
val contents = comment.contents.trim()
if (contents.isBlank()) return@forEach
val obj = JSONObject()
obj.put("id", comment.id)
comment.parentId?.takeIf { it.isNotBlank() }?.let { obj.put("parentId", it) }
comment.author.takeIf { it.isNotBlank() }?.let { obj.put("author", it) }
obj.put("contents", contents)
if (comment.createdAt > 0L) obj.put("createdAt", comment.createdAt)
val modifiedAt = comment.modifiedAt.takeIf { it > 0L } ?: comment.createdAt
if (modifiedAt > 0L) obj.put("modifiedAt", modifiedAt)
array.put(obj)
}
return array
}
private fun JSONArray?.toSharedPdfAnnotationComments(): List<SharedPdfAnnotationComment> {
if (this == null) return emptyList()
val comments = mutableListOf<SharedPdfAnnotationComment>()
for (index in 0 until length()) {
val obj = optJSONObject(index) ?: continue
val contents = obj.optString("contents")
.ifBlank { obj.optString("text") }
.ifBlank { obj.optString("comment") }
.trim()
if (contents.isBlank()) continue
val createdAt = obj.optLong("createdAt", obj.optLong("created", 0L))
comments += SharedPdfAnnotationComment(
id = obj.optString("id").takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString(),
parentId = obj.optString("parentId")
.ifBlank { obj.optString("inReplyTo") }
.takeIf { it.isNotBlank() },
author = obj.optString("author").trim(),
contents = contents,
createdAt = createdAt,
modifiedAt = obj.optLong(
"modifiedAt",
obj.optLong("modified", createdAt)
)
)
}
return comments
}
} }

View file

@ -372,43 +372,50 @@ class PdfTextRepository(context: Context) {
): List<RectF> { ): List<RectF> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val rects = mutableListOf<RectF>() val rects = mutableListOf<RectF>()
var bitmap: android.graphics.Bitmap? = null
var targetWidth = 0
var targetHeight = 0
try { try {
document.openPage(pageIndex)?.use { page -> PdfiumEngineProvider.withPdfium {
val targetWidth = 1080 document.openPage(pageIndex)?.use { page ->
val ptrWidth = page.getPageWidthPoint() targetWidth = 1080
val ptrHeight = page.getPageHeightPoint() val ptrWidth = page.getPageWidthPoint()
val ptrHeight = page.getPageHeightPoint()
if (ptrWidth <= 0 || ptrHeight <= 0) return@use if (ptrWidth <= 0 || ptrHeight <= 0) return@use
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat() val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1) targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
val bitmap = createBitmap(targetWidth, targetHeight) bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false) page.renderPageBitmap(bitmap!!, 0, 0, targetWidth, targetHeight, false)
}
}
val visionText = OcrHelper.extractTextFromBitmap(bitmap, onModelDownloading) val renderedBitmap = bitmap ?: return@withContext rects
val visionText = OcrHelper.extractTextFromBitmap(renderedBitmap, onModelDownloading)
visionText?.textBlocks?.forEach { block -> visionText?.textBlocks?.forEach { block ->
block.lines.forEach { line -> block.lines.forEach { line ->
line.elements.forEach { element -> line.elements.forEach { element ->
if (element.text.contains(query, ignoreCase = true)) { if (element.text.contains(query, ignoreCase = true)) {
element.boundingBox?.let { box -> element.boundingBox?.let { box ->
val normalized = RectF( val normalized = RectF(
box.left.toFloat() / targetWidth, box.left.toFloat() / targetWidth,
box.top.toFloat() / targetHeight, box.top.toFloat() / targetHeight,
box.right.toFloat() / targetWidth, box.right.toFloat() / targetWidth,
box.bottom.toFloat() / targetHeight box.bottom.toFloat() / targetHeight
) )
rects.add(normalized) rects.add(normalized)
}
} }
} }
} }
} }
bitmap.recycle()
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to get OCR rects for page $pageIndex") Timber.tag(TAG).e(e, "Failed to get OCR rects for page $pageIndex")
} finally {
bitmap?.recycle()
} }
rects rects
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,198 @@
package com.aryan.reader.tts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.R
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
private const val TTS_MINI_BAR_EDGE_PADDING_DP = 16
private const val TTS_MINI_BAR_MAIN_BOTTOM_PADDING_DP = 96
internal fun shouldShowReaderTtsMiniBar(
ttsState: TtsState,
isOnReaderRoute: Boolean
): Boolean {
if (isOnReaderRoute) return false
if (ttsState.playbackSource != "READER") return false
if (ttsState.sessionEndedByStop || ttsState.sessionFinished) return false
return ttsState.isLoading || !ttsState.currentText.isNullOrBlank()
}
internal fun readerTtsMiniBarBottomPaddingDp(isOnMainRoute: Boolean): Int {
return if (isOnMainRoute) {
TTS_MINI_BAR_MAIN_BOTTOM_PADDING_DP
} else {
TTS_MINI_BAR_EDGE_PADDING_DP
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun ReaderTtsMiniBar(
ttsController: TtsController,
ttsState: TtsState,
onOpenReader: () -> Unit,
modifier: Modifier = Modifier
) {
val canOpenReader = !ttsState.bookId.isNullOrBlank()
val canSkipPreviousChunk = !ttsState.isLoading &&
ttsState.currentChunkIndex > 0 &&
ttsState.totalChunks > 0
val canSkipNextChunk = !ttsState.isLoading &&
ttsState.currentChunkIndex >= 0 &&
ttsState.currentChunkIndex < ttsState.totalChunks - 1
val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) {
if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) {
"Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}"
} else {
null
}
}
val title = ttsState.bookTitle
?.takeIf { it.isNotBlank() }
?: stringResource(R.string.action_read_aloud)
val subtitle = remember(title, ttsState.chapterTitle, chunkLabel) {
listOfNotNull(
ttsState.chapterTitle
?.takeIf { it.isNotBlank() && it != title },
chunkLabel
).joinToString(" - ")
}
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 0.dp,
shadowElevation = 8.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(16.dp))
.clickable(enabled = canOpenReader, onClick = onOpenReader)
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (subtitle.isNotBlank()) {
Text(
text = subtitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(Modifier.width(4.dp))
IconButton(
enabled = canSkipPreviousChunk,
onClick = ttsController::skipToPreviousChunk,
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.SkipPrevious,
contentDescription = stringResource(R.string.content_desc_tts_previous_chunk),
modifier = Modifier.size(24.dp)
)
}
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
onClick = {
if (ttsState.isPlaying) {
ttsController.pause()
} else {
ttsController.resume()
}
},
modifier = Modifier.size(44.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Icon(
painter = painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
contentDescription = stringResource(
if (ttsState.isPlaying) {
R.string.content_desc_pause_tts
} else {
R.string.content_desc_resume_tts
}
),
modifier = Modifier.size(22.dp)
)
}
if (ttsState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 2.dp
)
}
}
IconButton(
enabled = canSkipNextChunk,
onClick = ttsController::skipToNextChunk,
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.SkipNext,
contentDescription = stringResource(R.string.content_desc_tts_next_chunk),
modifier = Modifier.size(24.dp)
)
}
}
}
}

View file

@ -153,8 +153,11 @@ class TtsController(context: Context) : Player.Listener {
bookTitle: String, bookTitle: String,
chapterTitle: String?, chapterTitle: String?,
coverImageUri: String?, coverImageUri: String?,
bookId: String? = null,
chapterIndex: Int? = null, chapterIndex: Int? = null,
totalChapters: Int? = null, totalChapters: Int? = null,
pageIndex: Int? = null,
startChunkIndex: Int = 0,
continueSession: Boolean = false, continueSession: Boolean = false,
ttsMode: TtsPlaybackManager.TtsMode, ttsMode: TtsPlaybackManager.TtsMode,
playbackSource: String = "READER", playbackSource: String = "READER",
@ -184,8 +187,11 @@ class TtsController(context: Context) : Player.Listener {
putString(KEY_BOOK_TITLE, bookTitle) putString(KEY_BOOK_TITLE, bookTitle)
putString(KEY_CHAPTER_TITLE, chapterTitle) putString(KEY_CHAPTER_TITLE, chapterTitle)
putString(KEY_COVER_IMAGE_URI, coverImageUri) putString(KEY_COVER_IMAGE_URI, coverImageUri)
bookId?.let { putString(KEY_BOOK_ID, it) }
chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) } chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) }
totalChapters?.let { putInt(KEY_TOTAL_CHAPTERS, it) } totalChapters?.let { putInt(KEY_TOTAL_CHAPTERS, it) }
pageIndex?.let { putInt(KEY_PAGE_INDEX, it) }
putInt(KEY_START_CHUNK_INDEX, startChunkIndex)
putBoolean(KEY_CONTINUE_SESSION, continueSession) putBoolean(KEY_CONTINUE_SESSION, continueSession)
putString(KEY_TTS_MODE, ttsMode.name) putString(KEY_TTS_MODE, ttsMode.name)
putString(KEY_PLAYBACK_SOURCE, playbackSource) putString(KEY_PLAYBACK_SOURCE, playbackSource)
@ -254,6 +260,16 @@ class TtsController(context: Context) : Player.Listener {
mediaController?.sendCustomCommand(SLICE_CURRENT_AND_RELOAD_COMMAND, Bundle.EMPTY) mediaController?.sendCustomCommand(SLICE_CURRENT_AND_RELOAD_COMMAND, Bundle.EMPTY)
} }
fun skipToPreviousChunk() {
Timber.d("UI sending SKIP_TO_PREVIOUS_TTS_CHUNK command.")
mediaController?.sendCustomCommand(SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND, Bundle.EMPTY)
}
fun skipToNextChunk() {
Timber.d("UI sending SKIP_TO_NEXT_TTS_CHUNK command.")
mediaController?.sendCustomCommand(SKIP_TO_NEXT_TTS_CHUNK_COMMAND, Bundle.EMPTY)
}
override fun onEvents(player: Player, events: Player.Events) { override fun onEvents(player: Player, events: Player.Events) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( 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" "Controller onEvents. playbackState=${player.playbackState}, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}, events=$events"
@ -266,6 +282,7 @@ class TtsController(context: Context) : Player.Listener {
val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY
val currentMediaItem = controller.currentMediaItem val currentMediaItem = controller.currentMediaItem
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
val currentMediaBookTitle = currentMediaItem?.mediaMetadata?.title?.toString()
val currentTextFromMediaItem = mediaItemExtras?.getString("ttsText") val currentTextFromMediaItem = mediaItemExtras?.getString("ttsText")
?: currentMediaItem?.mediaMetadata?.subtitle?.toString() ?: currentMediaItem?.mediaMetadata?.subtitle?.toString()
val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING
@ -278,12 +295,17 @@ class TtsController(context: Context) : Player.Listener {
val serviceChapterTitle = customState.getString("chapterTitle") val serviceChapterTitle = customState.getString("chapterTitle")
val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 } val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 }
val serviceTotalChapters = customState.getInt("totalChapters", -1).takeIf { it > 0 } val serviceTotalChapters = customState.getInt("totalChapters", -1).takeIf { it > 0 }
val serviceBookId = customState.getString("bookId") ?: mediaItemExtras?.getString("bookId")
val servicePageIndex = customState.getInt("pageIndex", -1)
.takeIf { it >= 0 }
?: mediaItemExtras?.getInt("pageIndex", -1)?.takeIf { it >= 0 }
val serviceCurrentChunkIndex = customState.getInt("currentChunkIndex", -1) val serviceCurrentChunkIndex = customState.getInt("currentChunkIndex", -1)
val serviceTotalChunks = customState.getInt("totalChunks", 0) val serviceTotalChunks = customState.getInt("totalChunks", 0)
val serviceBookProgressPercent = customState.getInt("bookProgressPercent", -1).takeIf { it >= 0 } val serviceBookProgressPercent = customState.getInt("bookProgressPercent", -1).takeIf { it >= 0 }
val sourceCfi = mediaItemExtras?.getString("sourceCfi") val sourceCfi = mediaItemExtras?.getString("sourceCfi") ?: customState.getString("sourceCfi")
val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1 val startOffset = mediaItemExtras?.getInt("startOffset", -1)
?: customState.getInt("startOffset", -1)
val currentWordSourceCfi = customState.getString("currentWordSourceCfi") val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
val currentWordStartOffset = customState.getInt("currentWordStartOffset", -1) val currentWordStartOffset = customState.getInt("currentWordStartOffset", -1)
val serviceMode = customState.getString("ttsMode", _ttsState.value.ttsMode) val serviceMode = customState.getString("ttsMode", _ttsState.value.ttsMode)
@ -298,8 +320,13 @@ class TtsController(context: Context) : Player.Listener {
if (isLoading) currentState.currentText else null if (isLoading) currentState.currentText else null
}, },
errorMessage = customState.getString("errorMessage"), errorMessage = customState.getString("errorMessage"),
bookId = if (isPlaybackActive || isLoading) {
serviceBookId ?: currentState.bookId
} else {
serviceBookId
},
bookTitle = if (isPlaybackActive) { bookTitle = if (isPlaybackActive) {
currentMediaItem?.mediaMetadata?.artist?.toString() ?: serviceBookTitle currentMediaBookTitle ?: serviceBookTitle
} else { } else {
if (isLoading) currentState.bookTitle else serviceBookTitle if (isLoading) currentState.bookTitle else serviceBookTitle
}, },
@ -318,6 +345,11 @@ class TtsController(context: Context) : Player.Listener {
} else { } else {
serviceTotalChapters serviceTotalChapters
}, },
pageIndex = if (isPlaybackActive || isLoading) {
servicePageIndex ?: currentState.pageIndex
} else {
servicePageIndex
},
currentChunkIndex = serviceCurrentChunkIndex, currentChunkIndex = serviceCurrentChunkIndex,
totalChunks = serviceTotalChunks, totalChunks = serviceTotalChunks,
bookProgressPercent = serviceBookProgressPercent, bookProgressPercent = serviceBookProgressPercent,

View file

@ -0,0 +1,8 @@
package com.aryan.reader.tts
const val ACTION_OPEN_TTS_SESSION = "com.aryan.reader.tts.OPEN_SESSION"
const val EXTRA_TTS_BOOK_ID = "com.aryan.reader.tts.extra.BOOK_ID"
const val EXTRA_TTS_CHAPTER_INDEX = "com.aryan.reader.tts.extra.CHAPTER_INDEX"
const val EXTRA_TTS_SOURCE_CFI = "com.aryan.reader.tts.extra.SOURCE_CFI"
const val EXTRA_TTS_START_OFFSET = "com.aryan.reader.tts.extra.START_OFFSET"
const val EXTRA_TTS_PAGE_INDEX = "com.aryan.reader.tts.extra.PAGE_INDEX"

File diff suppressed because it is too large Load diff

View file

@ -30,10 +30,16 @@ import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.aryan.reader.R import com.aryan.reader.R
@ -61,6 +67,7 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import com.google.common.collect.ImmutableList
data class WordTimingInfo(val word: String, val startTime: Double) data class WordTimingInfo(val word: String, val startTime: Double)
@ -242,6 +249,227 @@ private const val TTS_FOREGROUND_CHANNEL_ID = "tts_playback"
// Keep this aligned with Media3's default notification ID so playback updates replace the fallback. // Keep this aligned with Media3's default notification ID so playback updates replace the fallback.
private const val TTS_FOREGROUND_NOTIFICATION_ID = 1001 private const val TTS_FOREGROUND_NOTIFICATION_ID = 1001
private const val TTS_FOREGROUND_IDLE_GRACE_MS = 15_000L private const val TTS_FOREGROUND_IDLE_GRACE_MS = 15_000L
private const val ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK = "com.aryan.reader.tts.NOTIFICATION_PREVIOUS_CHUNK"
private const val ACTION_TTS_NOTIFICATION_NEXT_CHUNK = "com.aryan.reader.tts.NOTIFICATION_NEXT_CHUNK"
private const val TTS_NOTIFICATION_PREVIOUS_REQUEST_CODE = 4208
private const val TTS_NOTIFICATION_NEXT_REQUEST_CODE = 4209
@UnstableApi
private class TtsMediaNotificationProvider(
context: android.content.Context
) : DefaultMediaNotificationProvider(
context,
{ _ -> TTS_FOREGROUND_NOTIFICATION_ID },
TTS_FOREGROUND_CHANNEL_ID,
R.string.tts_notification_channel_name
) {
private val appContext = context.applicationContext
override fun addNotificationActions(
mediaSession: MediaSession,
mediaButtons: ImmutableList<CommandButton>,
builder: NotificationCompat.Builder,
actionFactory: MediaNotification.ActionFactory
): IntArray {
return super.addNotificationActions(
mediaSession,
mediaButtons,
builder,
TtsNotificationActionFactory(appContext, actionFactory)
)
}
}
@UnstableApi
private class TtsNotificationActionFactory(
private val context: android.content.Context,
private val delegate: MediaNotification.ActionFactory
) : MediaNotification.ActionFactory {
override fun createMediaAction(
mediaSession: MediaSession,
icon: IconCompat,
title: CharSequence,
command: Int
): NotificationCompat.Action {
return when (command) {
Player.COMMAND_SEEK_TO_PREVIOUS,
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> createChunkSkipAction(
iconResId = R.drawable.skip_previous,
title = title,
action = ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK,
requestCode = TTS_NOTIFICATION_PREVIOUS_REQUEST_CODE
)
Player.COMMAND_SEEK_TO_NEXT,
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> createChunkSkipAction(
iconResId = R.drawable.skip_next,
title = title,
action = ACTION_TTS_NOTIFICATION_NEXT_CHUNK,
requestCode = TTS_NOTIFICATION_NEXT_REQUEST_CODE
)
else -> delegate.createMediaAction(mediaSession, icon, title, command)
}
}
override fun createCustomAction(
mediaSession: MediaSession,
icon: IconCompat,
title: CharSequence,
customAction: String,
extras: android.os.Bundle
): NotificationCompat.Action {
return delegate.createCustomAction(mediaSession, icon, title, customAction, extras)
}
override fun createCustomActionFromCustomCommandButton(
mediaSession: MediaSession,
customCommandButton: CommandButton
): NotificationCompat.Action {
return delegate.createCustomActionFromCustomCommandButton(mediaSession, customCommandButton)
}
override fun createMediaActionPendingIntent(mediaSession: MediaSession, command: Long): PendingIntent {
return delegate.createMediaActionPendingIntent(mediaSession, command)
}
override fun createNotificationDismissalIntent(mediaSession: MediaSession): PendingIntent {
return delegate.createNotificationDismissalIntent(mediaSession)
}
private fun createChunkSkipAction(
iconResId: Int,
title: CharSequence,
action: String,
requestCode: Int
): NotificationCompat.Action {
val intent = Intent(context, TtsService::class.java).apply {
this.action = action
setPackage(context.packageName)
}
val pendingIntent = PendingIntent.getService(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Action.Builder(
IconCompat.createWithResource(context, iconResId),
title,
pendingIntent
)
.setShowsUserInterface(false)
.build()
}
}
@UnstableApi
private class TtsSessionPlayer(
player: Player,
private val canSkipToPreviousChunk: () -> Boolean,
private val canSkipToNextChunk: () -> Boolean,
private val skipToPreviousChunk: () -> Unit,
private val skipToNextChunk: () -> Unit,
private val isCurrentChunkStreaming: () -> Boolean,
private val currentChunkDurationForNotification: (Long) -> Long
) : ForwardingPlayer(player) {
override fun getAvailableCommands(): Player.Commands {
val builder = super.getAvailableCommands().buildUpon()
if (canSkipToPreviousChunk()) {
builder
.add(Player.COMMAND_SEEK_TO_PREVIOUS)
.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
} else {
builder
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
}
if (canSkipToNextChunk()) {
builder
.add(Player.COMMAND_SEEK_TO_NEXT)
.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
} else {
builder
.remove(Player.COMMAND_SEEK_TO_NEXT)
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
}
return builder.build()
}
override fun isCommandAvailable(command: Int): Boolean {
return getAvailableCommands().contains(command)
}
override fun hasPreviousMediaItem(): Boolean {
return canSkipToPreviousChunk() || super.hasPreviousMediaItem()
}
override fun hasNextMediaItem(): Boolean {
return canSkipToNextChunk() || super.hasNextMediaItem()
}
override fun seekToPrevious() {
skipToPreviousChunk()
}
override fun seekToPreviousMediaItem() {
skipToPreviousChunk()
}
override fun seekToNext() {
skipToNextChunk()
}
override fun seekToNextMediaItem() {
skipToNextChunk()
}
override fun getDuration(): Long {
return notificationDurationMs().takeIf { it != C.TIME_UNSET } ?: super.getDuration()
}
override fun getContentDuration(): Long {
return getDuration()
}
override fun getBufferedPosition(): Long {
return adjustedStreamingBufferedPosition(super.getBufferedPosition())
}
override fun getContentBufferedPosition(): Long {
return getBufferedPosition()
}
override fun getBufferedPercentage(): Int {
val duration = notificationDurationMs()
if (!isCurrentChunkStreaming() || duration == C.TIME_UNSET || duration <= 0L) {
return super.getBufferedPercentage()
}
val bufferedPosition = getBufferedPosition().coerceIn(0L, duration)
return ((bufferedPosition * 100L) / duration).toInt().coerceIn(0, 100)
}
override fun isCurrentMediaItemDynamic(): Boolean {
return if (isCurrentChunkStreaming()) false else super.isCurrentMediaItemDynamic()
}
private fun notificationDurationMs(): Long {
val currentPositionMs = super.getCurrentPosition().coerceAtLeast(0L)
return currentChunkDurationForNotification(currentPositionMs)
}
private fun adjustedStreamingBufferedPosition(delegatePositionMs: Long): Long {
val duration = notificationDurationMs()
if (!isCurrentChunkStreaming() || duration == C.TIME_UNSET || duration <= 0L) {
return delegatePositionMs
}
val currentPositionMs = super.getCurrentPosition().coerceAtLeast(0L)
val bufferedPositionMs = if (delegatePositionMs == C.TIME_UNSET || delegatePositionMs < currentPositionMs) {
currentPositionMs
} else {
delegatePositionMs
}
return bufferedPositionMs.coerceIn(0L, duration)
}
}
@UnstableApi @UnstableApi
class TtsService : MediaSessionService() { class TtsService : MediaSessionService() {
@ -258,6 +486,29 @@ class TtsService : MediaSessionService() {
private var foregroundChapterTitle: String? = null private var foregroundChapterTitle: String? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK -> {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Notification previous chunk action received.")
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).i(
"serviceAction=notification-previous hasPlaybackManager=${::playbackManager.isInitialized} playerInitialized=${::player.isInitialized}"
)
if (::playbackManager.isInitialized) {
playbackManager.skipToPreviousChunkFromTransport()
}
return START_STICKY
}
ACTION_TTS_NOTIFICATION_NEXT_CHUNK -> {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Notification next chunk action received.")
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).i(
"serviceAction=notification-next hasPlaybackManager=${::playbackManager.isInitialized} playerInitialized=${::player.isInitialized}"
)
if (::playbackManager.isInitialized) {
playbackManager.skipToNextChunkFromTransport()
}
return START_STICKY
}
}
val result = super.onStartCommand(intent, flags, startId) val result = super.onStartCommand(intent, flags, startId)
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onStartCommand. action=${intent?.action}, startId=$startId, result=$result" "onStartCommand. action=${intent?.action}, startId=$startId, result=$result"
@ -747,6 +998,7 @@ class TtsService : MediaSessionService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Timber.d("TtsService created.") Timber.d("TtsService created.")
setMediaNotificationProvider(TtsMediaNotificationProvider(this))
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i( Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
@ -818,7 +1070,17 @@ class TtsService : MediaSessionService() {
onPlaybackSessionStopped = ::onPlaybackSessionStopped onPlaybackSessionStopped = ::onPlaybackSessionStopped
) )
mediaSession = MediaSession.Builder(this, player) val sessionPlayer = TtsSessionPlayer(
player = player,
canSkipToPreviousChunk = playbackManager::canSkipToPreviousChunk,
canSkipToNextChunk = playbackManager::canSkipToNextChunk,
skipToPreviousChunk = playbackManager::skipToPreviousChunkFromTransport,
skipToNextChunk = playbackManager::skipToNextChunkFromTransport,
isCurrentChunkStreaming = playbackManager::isCurrentChunkStreaming,
currentChunkDurationForNotification = playbackManager::currentChunkDurationForNotification
)
mediaSession = MediaSession.Builder(this, sessionPlayer)
.setCallback(playbackManager) .setCallback(playbackManager)
.build() .build()

View file

@ -29,8 +29,10 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import com.materialkolor.PaletteStyle import com.materialkolor.PaletteStyle
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.aryan.reader.shared.ui.withAppFontFamily
import com.materialkolor.dynamicColorScheme import com.materialkolor.dynamicColorScheme
private val lightScheme = lightColorScheme( private val lightScheme = lightColorScheme(
@ -116,6 +118,7 @@ fun AppTheme(
seedColor: Color? = null, seedColor: Color? = null,
contrastLevel: Double = 0.0, contrastLevel: Double = 0.0,
textDimFactor: Float = 1.0f, textDimFactor: Float = 1.0f,
appFontFamily: FontFamily? = null,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@ -174,7 +177,7 @@ fun AppTheme(
MaterialTheme( MaterialTheme(
colorScheme = finalColorScheme, colorScheme = finalColorScheme,
typography = AppTypography, typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography,
content = content content = content
) )
} }

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Q397,880 324,848.5ZM520,797Q639,782 719.5,692.5Q800,603 800,480Q800,357 719.5,267.5Q639,178 520,163L520,797Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M660,720L660,240L740,240L740,720L660,720ZM220,720L220,240L580,480L220,720ZM300,480L300,480L300,480ZM300,570L436,480L300,390L300,570Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M220,720L220,240L300,240L300,720L220,720ZM740,720L380,480L740,240L740,720ZM660,480L660,480L660,480ZM660,570L660,390L524,480L660,570Z"/>
</vector>

View file

@ -807,7 +807,7 @@
<string name="ai_generate_story_recap">Сгенерировать пересказ сюжета</string> <string name="ai_generate_story_recap">Сгенерировать пересказ сюжета</string>
<string name="ai_story_recap">Пересказ сюжета</string> <string name="ai_story_recap">Пересказ сюжета</string>
<string name="ai_cache_hit_free">Загружено из кэша (Бесплатно)</string> <string name="ai_cache_hit_free">Загружено из кэша (Бесплатно)</string>
<string name="ai_generated_free_remaining">Сгенерировано • Бесплатно (1%$d/10)</string> <string name="ai_generated_free_remaining">Сгенерировано • Бесплатно (%1$d/10)</string>
<string name="ai_generated_cost">Сгенерировано • Стоимость: %1$s кредитов</string> <string name="ai_generated_cost">Сгенерировано • Стоимость: %1$s кредитов</string>
<string name="ai_generating_cost_calculating">Расчет стоимости...</string> <string name="ai_generating_cost_calculating">Расчет стоимости...</string>
<string name="ai_output_title">Результат ИИ</string> <string name="ai_output_title">Результат ИИ</string>
@ -816,7 +816,7 @@
<string name="credits_tab">Кредиты</string> <string name="credits_tab">Кредиты</string>
<string name="credits_title">Баланс кредитов</string> <string name="credits_title">Баланс кредитов</string>
<string name="credits_available">Доступно кредитов</string> <string name="credits_available">Доступно кредитов</string>
<string name="credits_count">%1$ кредитов</string> <string name="credits_count">%1$d кредитов</string>
<string name="credits_estimated_cost_breakdown">Примерный расчет стоимости</string> <string name="credits_estimated_cost_breakdown">Примерный расчет стоимости</string>
<string name="credits_cloud_tts_title">Облачный синтез речи (TTS)</string> <string name="credits_cloud_tts_title">Облачный синтез речи (TTS)</string>
<string name="credits_cloud_tts_desc">Стоимость: ~3-4 кредита за минуту сгенерированного аудио.\n\nЧтобы включить : Читатель экрана&gt; Дополнительно &gt; Настройки голоса TTS.</string> <string name="credits_cloud_tts_desc">Стоимость: ~3-4 кредита за минуту сгенерированного аудио.\n\nЧтобы включить : Читатель экрана&gt; Дополнительно &gt; Настройки голоса TTS.</string>

View file

@ -59,18 +59,144 @@
<item quantity="other">%1$d books removed from library.</item> <item quantity="other">%1$d books removed from library.</item>
</plurals> </plurals>
<!-- Android bulk import progress banner. %1$d = number of selected books being imported. -->
<plurals name="banner_importing_books_count">
<item quantity="one">Importing %1$d book… It will appear in your Library shortly.</item>
<item quantity="other">Importing %1$d books… They will appear in your Library shortly.</item>
</plurals>
<!-- Android bulk import result banner. %1$d = number of books imported into the Library tab. -->
<plurals name="banner_books_imported_library_tab">
<item quantity="one">Imported %1$d book. You can find it in the Library tab.</item>
<item quantity="other">Imported %1$d books. You can find them in the Library tab.</item>
</plurals>
<!-- Shared library mutation banner. %1$d = number of selected books added to a shelf. -->
<plurals name="banner_books_added_to_shelf">
<item quantity="one">%1$d book added to shelf.</item>
<item quantity="other">%1$d books added to shelf.</item>
</plurals>
<!-- Shared library mutation banner. %1$d = number of selected books; %2$s = tag name. -->
<plurals name="banner_books_tagged_with_tag">
<item quantity="one">%1$d book tagged with &quot;%2$s&quot;.</item>
<item quantity="other">%1$d books tagged with &quot;%2$s&quot;.</item>
</plurals>
<!-- Shared library mutation banner. %1$s = folder name; %2$d = number of books removed with the folder. -->
<plurals name="banner_folder_removed_with_book_count">
<item quantity="one">Removed folder &quot;%1$s&quot; and %2$d book from the app.</item>
<item quantity="other">Removed folder &quot;%1$s&quot; and %2$d books from the app.</item>
</plurals>
<!-- Word count for folders shown in shelf subtitles. %1$d = number of folders. --> <!-- Word count for folders shown in shelf subtitles. %1$d = number of folders. -->
<plurals name="folder_count"> <plurals name="folder_count">
<item quantity="one">%1$d folder</item> <item quantity="one">%1$d folder</item>
<item quantity="other">%1$d folders</item> <item quantity="other">%1$d folders</item>
</plurals> </plurals>
<!-- File count label. %1$d = number of files. Use a full count phrase, not a reusable standalone noun. -->
<plurals name="file_count">
<item quantity="one">%1$d file</item>
<item quantity="other">%1$d files</item>
</plurals>
<!-- Desktop drag-and-drop overlay title. %1$d = number of importable files currently dragged over the app. -->
<plurals name="desktop_drop_import_file_count">
<item quantity="one">Drop to import %1$d file</item>
<item quantity="other">Drop to import %1$d files</item>
</plurals>
<!-- Desktop drag-and-drop overlay detail. %1$d = number of unsupported dragged files that will not be imported. -->
<plurals name="desktop_unsupported_import_file_count">
<item quantity="one">%1$d unsupported file will be skipped.</item>
<item quantity="other">%1$d unsupported files will be skipped.</item>
</plurals>
<!-- Desktop import progress banner. %1$d = number of files being imported. -->
<plurals name="desktop_importing_file_count">
<item quantity="one">Importing %1$d file…</item>
<item quantity="other">Importing %1$d files…</item>
</plurals>
<!-- Desktop import result banner sentence. %1$d = number of files successfully imported. -->
<plurals name="desktop_imported_file_count">
<item quantity="one">Imported %1$d file.</item>
<item quantity="other">Imported %1$d files.</item>
</plurals>
<!-- Legacy desktop import model message. %1$d = number of files imported before reader support is available. -->
<plurals name="desktop_imported_file_count_reader_support_later">
<item quantity="one">Imported %1$d file. Reader support comes later.</item>
<item quantity="other">Imported %1$d files. Reader support comes later.</item>
</plurals>
<!-- Desktop import result banner sentence. %1$d = number of files that could not be imported. -->
<plurals name="desktop_import_failed_file_count">
<item quantity="one">Could not import %1$d file.</item>
<item quantity="other">Could not import %1$d files.</item>
</plurals>
<!-- Desktop import result banner sentence. %1$d = number of files skipped during import. -->
<plurals name="desktop_skipped_file_count">
<item quantity="one">Skipped %1$d file.</item>
<item quantity="other">Skipped %1$d files.</item>
</plurals>
<!-- Desktop remove-folder confirmation body. %1$s = folder name; %2$d = number of books from that folder. -->
<plurals name="desktop_remove_folder_desc_with_book_count">
<item quantity="one">Remove "%1$s" and its %2$d book from the app? Files on disk will not be deleted.</item>
<item quantity="other">Remove "%1$s" and its %2$d books from the app? Files on disk will not be deleted.</item>
</plurals>
<!-- Desktop folder-sync result banner. %1$d = number of linked folders whose sync failed. -->
<plurals name="desktop_folder_sync_failed_folder_count">
<item quantity="one">Folder sync failed for %1$d folder.</item>
<item quantity="other">Folder sync failed for %1$d folders.</item>
</plurals>
<!-- Desktop folder-sync result banner. %1$d = number of linked folders skipped after a partial sync. -->
<plurals name="desktop_folder_sync_skipped_folder_count">
<item quantity="one">Folder sync finished with %1$d folder skipped.</item>
<item quantity="other">Folder sync finished with %1$d folders skipped.</item>
</plurals>
<!-- Desktop OPDS catalog removal banner. %1$d = number of streamed OPDS books removed with the catalog. -->
<plurals name="desktop_opds_removed_stream_book_count">
<item quantity="one">Removed %1$d streamed OPDS book from that catalog.</item>
<item quantity="other">Removed %1$d streamed OPDS books from that catalog.</item>
</plurals>
<!-- Word count for tags shown in filter chips. %1$d = number of tags. --> <!-- Word count for tags shown in filter chips. %1$d = number of tags. -->
<plurals name="tag_count"> <plurals name="tag_count">
<item quantity="one">%1$d tag</item> <item quantity="one">%1$d tag</item>
<item quantity="other">%1$d tags</item> <item quantity="other">%1$d tags</item>
</plurals> </plurals>
<!-- Desktop library tab label with count. %1$d = number of visible books in the tab. -->
<plurals name="desktop_library_tab_books_count">
<item quantity="one">All Books %1$d</item>
<item quantity="other">All Books %1$d</item>
</plurals>
<!-- Desktop library tab label with count. %1$d = number of manual/series shelf groups. -->
<plurals name="desktop_library_tab_shelves_count">
<item quantity="one">Shelves %1$d</item>
<item quantity="other">Shelves %1$d</item>
</plurals>
<!-- Desktop library tab label with count. %1$d = number of tag groups. -->
<plurals name="desktop_library_tab_tags_count">
<item quantity="one">Tags %1$d</item>
<item quantity="other">Tags %1$d</item>
</plurals>
<!-- Desktop library tab label with count. %1$d = number of source folder groups. -->
<plurals name="desktop_library_tab_folders_count">
<item quantity="one">Folders %1$d</item>
<item quantity="other">Folders %1$d</item>
</plurals>
<!-- Parenthetical cache chunk count in TTS cache entries. %1$d = number of cached audio chunks. --> <!-- Parenthetical cache chunk count in TTS cache entries. %1$d = number of cached audio chunks. -->
<plurals name="tts_cache_chunk_count_parenthetical"> <plurals name="tts_cache_chunk_count_parenthetical">
<item quantity="one">(%1$d chunk)</item> <item quantity="one">(%1$d chunk)</item>

View file

@ -22,6 +22,7 @@
<!-- Tab label in the multi-tab PDF reader — means the slot is available (unused), NOT "free of cost". --> <!-- Tab label in the multi-tab PDF reader — means the slot is available (unused), NOT "free of cost". -->
<string name="tab_free">Free</string> <string name="tab_free">Free</string>
<string name="active_tabs">Active Tabs</string> <string name="active_tabs">Active Tabs</string>
<string name="pdf_tabs_show_top_app_bar_tabs">Show tabs in top app bar</string>
<string name="close_tab">Close Tab</string> <string name="close_tab">Close Tab</string>
<string name="close_all_tabs">Close All Tabs</string> <string name="close_all_tabs">Close All Tabs</string>
<string name="dialog_close_all_tabs">Close All Tabs?</string> <string name="dialog_close_all_tabs">Close All Tabs?</string>
@ -733,6 +734,10 @@
<string name="content_desc_pause_tts">Pause TTS</string> <string name="content_desc_pause_tts">Pause TTS</string>
<!-- TTS = Text-to-Speech. Accessibility label. --> <!-- TTS = Text-to-Speech. Accessibility label. -->
<string name="content_desc_resume_tts">Resume TTS</string> <string name="content_desc_resume_tts">Resume TTS</string>
<!-- TTS = Text-to-Speech. Accessibility label. -->
<string name="content_desc_tts_previous_chunk">Previous TTS chunk</string>
<!-- TTS = Text-to-Speech. Accessibility label. -->
<string name="content_desc_tts_next_chunk">Next TTS chunk</string>
<string name="content_desc_exit_slider">Exit slider navigation</string> <string name="content_desc_exit_slider">Exit slider navigation</string>
<string name="content_desc_start_page_thumbnail">Start page thumbnail</string> <string name="content_desc_start_page_thumbnail">Start page thumbnail</string>
<string name="content_desc_expand">Expand</string> <string name="content_desc_expand">Expand</string>
@ -769,10 +774,13 @@
<string name="tab_bookmarks">Bookmarks</string> <string name="tab_bookmarks">Bookmarks</string>
<string name="tab_highlights">Highlights</string> <string name="tab_highlights">Highlights</string>
<string name="tab_pages">Pages</string> <string name="tab_pages">Pages</string>
<string name="tab_images">Images</string>
<string name="action_expand_all">Expand All</string> <string name="action_expand_all">Expand All</string>
<string name="action_collapse_all">Collapse All</string> <string name="action_collapse_all">Collapse All</string>
<string name="action_locate">Locate</string> <string name="action_locate">Locate</string>
<string name="no_bookmarks_yet">You haven\'t added any bookmarks yet.</string> <string name="no_bookmarks_yet">You haven\'t added any bookmarks yet.</string>
<string name="no_images_found">No images found.</string>
<string name="content_desc_download_image">Download image</string>
<string name="content_desc_more_options_bookmark">More options for bookmark</string> <string name="content_desc_more_options_bookmark">More options for bookmark</string>
<string name="dialog_rename_bookmark">Rename Bookmark</string> <string name="dialog_rename_bookmark">Rename Bookmark</string>
<string name="label_new_name">New Name</string> <string name="label_new_name">New Name</string>
@ -786,6 +794,8 @@
<string name="dialog_delete_highlight_desc">Are you sure you want to permanently delete this highlight?</string> <string name="dialog_delete_highlight_desc">Are you sure you want to permanently delete this highlight?</string>
<!-- EpubReaderScreen.kt --> <!-- EpubReaderScreen.kt -->
<string name="saved_image_message">Saved %1$s</string>
<string name="error_save_image">Could not save image.</string>
<!-- PDF = file format name — do not translate. --> <!-- PDF = file format name — do not translate. -->
<string name="banner_original_pdf_not_found">Original PDF not found.</string> <string name="banner_original_pdf_not_found">Original PDF not found.</string>
<!-- Error state shown in the reader. %1$s = the file path that could not be found. --> <!-- Error state shown in the reader. %1$s = the file path that could not be found. -->
@ -833,8 +843,13 @@
<string name="no_imported_fonts_yet">No imported fonts yet.</string> <string name="no_imported_fonts_yet">No imported fonts yet.</string>
<string name="visual_options_title">Visual Options</string> <string name="visual_options_title">Visual Options</string>
<string name="visual_options_page_layout">Page layout</string> <string name="visual_options_page_layout">Page layout</string>
<string name="visual_options_pdf_page_spread">PDF page spread</string>
<string name="visual_options_pdf_spread_single">Single page</string>
<string name="visual_options_pdf_spread_two">Two pages</string>
<string name="visual_options_pdf_first_page_alone">First page alone</string>
<string name="visual_options_pdf_first_page_alone_desc">Starts facing-page spreads after the cover page.</string>
<string name="visual_options_remove_page_gap">Remove gap between pages</string> <string name="visual_options_remove_page_gap">Remove gap between pages</string>
<string name="visual_options_remove_page_gap_desc">Applies to vertical reading mode.</string> <string name="visual_options_remove_page_gap_desc">Applies to vertical reading and two-page spreads.</string>
<string name="visual_options_hide_page_number_overlay">Hide page number overlay</string> <string name="visual_options_hide_page_number_overlay">Hide page number overlay</string>
<string name="visual_options_hide_page_number_overlay_desc">Removes the small page count label from each page.</string> <string name="visual_options_hide_page_number_overlay_desc">Removes the small page count label from each page.</string>
<!-- "Status Bar" and "Navigation Bar" are standard Android UI terms — keep consistent with your OS locale if applicable. --> <!-- "Status Bar" and "Navigation Bar" are standard Android UI terms — keep consistent with your OS locale if applicable. -->
@ -850,6 +865,12 @@
<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> <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>
<string name="visual_options_edge_padding">Remove Edge Padding</string> <string name="visual_options_edge_padding">Remove Edge Padding</string>
<string name="visual_options_edge_padding_desc">Removes the horizontal gap on the left and right edges.</string> <string name="visual_options_edge_padding_desc">Removes the horizontal gap on the left and right edges.</string>
<string name="reader_brightness_title">Brightness</string>
<string name="reader_brightness_system">Use system brightness</string>
<string name="reader_brightness_system_desc">Follows the device brightness setting.</string>
<string name="reader_brightness_custom">Custom brightness</string>
<string name="reader_brightness_custom_desc">Applies while a reader screen is open.</string>
<string name="reader_brightness_percent">%1$d%%</string>
<!-- ExternalDictionaryHelper.kt --> <!-- ExternalDictionaryHelper.kt -->
<!-- Label for the "Search" app category in the dictionary settings. --> <!-- Label for the "Search" app category in the dictionary settings. -->
@ -877,6 +898,20 @@
<string name="about_licenses_desc">Open source libraries used.</string> <string name="about_licenses_desc">Open source libraries used.</string>
<!-- %1$d = number of books being imported. --> <!-- %1$d = number of books being imported. -->
<string name="banner_importing_multiple">Importing %1$d books… They will appear in your Library shortly.</string> <string name="banner_importing_multiple">Importing %1$d books… They will appear in your Library shortly.</string>
<!-- Shared library mutation banner. %1$s = shelf name. -->
<string name="banner_shelf_created">Created shelf &quot;%1$s&quot;.</string>
<!-- Shared library mutation banner. %1$s = smart shelf name. -->
<string name="banner_smart_shelf_created">Created smart shelf &quot;%1$s&quot;.</string>
<!-- Shared library mutation banner. %1$s = new shelf name. -->
<string name="banner_shelf_renamed">Renamed shelf to &quot;%1$s&quot;.</string>
<!-- Shared library mutation banner. %1$s = shelf name. -->
<string name="banner_shelf_deleted">Deleted shelf &quot;%1$s&quot;.</string>
<!-- Shared library mutation banner. %1$s = book title. -->
<string name="banner_book_updated">Updated &quot;%1$s&quot;.</string>
<!-- Shared import result banner shown when selected files already exist in the library. -->
<string name="banner_duplicate_files_already_in_library">Those files are already in the library.</string>
<!-- Folder shelf subtitle that joins already-localized count phrases. %1$s = folder count phrase; %2$s = book count phrase. -->
<string name="folder_subtitle_folder_book_counts">%1$s - %2$s</string>
<!-- General / Dialogs --> <!-- General / Dialogs -->
<string name="dialog_external_link_title">External Link</string> <string name="dialog_external_link_title">External Link</string>
@ -887,13 +922,21 @@
<!-- Annotations & Notes --> <!-- Annotations & Notes -->
<string name="action_save_note">Save Note</string> <string name="action_save_note">Save Note</string>
<string name="action_save_annotation">Save</string>
<string name="action_save_comment">Save Comment</string>
<string name="action_add_comment">Add Comment</string>
<string name="action_reply">Reply</string>
<string name="placeholder_add_note">Add a note…</string> <string name="placeholder_add_note">Add a note…</string>
<string name="placeholder_add_comment">Add a comment…</string>
<!-- Short label for the dictionary action in a text-selection popup. --> <!-- Short label for the dictionary action in a text-selection popup. -->
<string name="label_dict">Dict</string> <string name="label_dict">Dict</string>
<!-- Short label for the speak/TTS action in a text-selection popup. --> <!-- Short label for the speak/TTS action in a text-selection popup. -->
<string name="label_speak">Speak</string> <string name="label_speak">Speak</string>
<!-- Short label for the note action in a text-selection popup. --> <!-- Short label for the note action in a text-selection popup. -->
<string name="label_note">Note</string> <string name="label_note">Note</string>
<string name="label_comments">Comments</string>
<string name="label_editing_comment">Editing comment</string>
<string name="label_replying_to">Replying to %1$s</string>
<!-- Short label for the edit action in a text-selection popup. --> <!-- Short label for the edit action in a text-selection popup. -->
<string name="label_edit">Edit</string> <string name="label_edit">Edit</string>
@ -1097,6 +1140,8 @@
<string name="options_enable_multi_tab_reading">Enable Multi-Tab Reading</string> <string name="options_enable_multi_tab_reading">Enable Multi-Tab Reading</string>
<!-- Home overflow menu item: uses stricter MIME filters when choosing local files. --> <!-- Home overflow menu item: uses stricter MIME filters when choosing local files. -->
<string name="options_use_strict_file_filter">Use Strict File Filter</string> <string name="options_use_strict_file_filter">Use Strict File Filter</string>
<!-- Home overflow menu item: prefers the PDF filename over embedded document metadata titles. -->
<string name="options_use_pdf_filename_display_name">Use PDF Filenames</string>
<string name="options_language">Language</string> <string name="options_language">Language</string>
<!-- Debug-only menu item that runs local panel detection diagnostics. --> <!-- Debug-only menu item that runs local panel detection diagnostics. -->
<string name="options_test_panel_ml_detection">Test Panel ML Detection</string> <string name="options_test_panel_ml_detection">Test Panel ML Detection</string>
@ -1175,8 +1220,8 @@
<string name="sort_recent">Recent</string> <string name="sort_recent">Recent</string>
<string name="sort_title_az">Title A-Z</string> <string name="sort_title_az">Title A-Z</string>
<string name="sort_author_az">Author A-Z</string> <string name="sort_author_az">Author A-Z</string>
<string name="sort_percent_asc">Percent complete 0-100</string> <string name="sort_percent_asc">Percent complete 0100</string>
<string name="sort_percent_desc">Percent complete 100-0</string> <string name="sort_percent_desc">Percent complete 1000</string>
<string name="sort_size_smallest">Size (Smallest)</string> <string name="sort_size_smallest">Size (Smallest)</string>
<string name="sort_size_biggest">Size (Biggest)</string> <string name="sort_size_biggest">Size (Biggest)</string>
<string name="read_status_all">All</string> <string name="read_status_all">All</string>
@ -1243,9 +1288,9 @@
<string name="credits_count">%1$d Credits</string> <string name="credits_count">%1$d Credits</string>
<string name="credits_estimated_cost_breakdown">Estimated Cost Breakdown</string> <string name="credits_estimated_cost_breakdown">Estimated Cost Breakdown</string>
<string name="credits_cloud_tts_title">Cloud TTS</string> <string name="credits_cloud_tts_title">Cloud TTS</string>
<string name="credits_cloud_tts_desc">Cost: ~3-4 credits per minute of audio generated.\nTo enable: Reader Screen &gt; More &gt; TTS Voice Settings.</string> <string name="credits_cloud_tts_desc">Cost: ~34 credits per minute of audio generated.\nTo enable: Reader Screen &gt; More &gt; TTS Voice Settings.</string>
<string name="credits_ai_summaries_title">AI Summaries &amp; Recap</string> <string name="credits_ai_summaries_title">AI Summaries &amp; Recap</string>
<string name="credits_ai_summaries_desc">Cost: ~1-4 credits per request based on chapter length.\nPro Users get 10 free summaries daily.</string> <string name="credits_ai_summaries_desc">Cost: ~14 credits per request based on chapter length.\nPro Users get 10 free summaries daily.</string>
<string name="legal_by_purchasing">By purchasing,</string> <string name="legal_by_purchasing">By purchasing,</string>
<string name="dialog_out_of_credits_title">Out of Credits</string> <string name="dialog_out_of_credits_title">Out of Credits</string>
<string name="dialog_out_of_credits_desc">You don\'t have enough credits. Get Episteme Pro for 10 free Summaries per day, or add more credits to use Summaries, Cloud TTS and Story Recap.</string> <string name="dialog_out_of_credits_desc">You don\'t have enough credits. Get Episteme Pro for 10 free Summaries per day, or add more credits to use Summaries, Cloud TTS and Story Recap.</string>
@ -1511,6 +1556,7 @@
<string name="content_desc_drag_to_reorder">Drag to reorder</string> <string name="content_desc_drag_to_reorder">Drag to reorder</string>
<string name="tool_external_apps">External Apps</string> <string name="tool_external_apps">External Apps</string>
<string name="tool_navigation_slider">Navigation Slider</string> <string name="tool_navigation_slider">Navigation Slider</string>
<string name="tool_brightness">Brightness</string>
<string name="tool_sidebar">Sidebar</string> <string name="tool_sidebar">Sidebar</string>
<string name="tool_highlight_selectable_text">Highlight selectable text</string> <string name="tool_highlight_selectable_text">Highlight selectable text</string>
<string name="tool_edit_mode">Edit Mode</string> <string name="tool_edit_mode">Edit Mode</string>
@ -1561,4 +1607,403 @@
<string name="language_dutch">Nederlands (Dutch)</string> <string name="language_dutch">Nederlands (Dutch)</string>
<string name="language_ukrainian">Українська (Ukrainian)</string> <string name="language_ukrainian">Українська (Ukrainian)</string>
<string name="language_indonesian">Bahasa Indonesia (Indonesian)</string> <string name="language_indonesian">Bahasa Indonesia (Indonesian)</string>
<!-- Desktop localization. Desktop reuses this Android string file as its source of truth. -->
<string name="desktop_about">About</string>
<string name="desktop_about_subtitle">Desktop reader</string>
<string name="desktop_access">Desktop access</string>
<string name="desktop_account">Account</string>
<string name="desktop_ai_hub">AI hub</string>
<string name="desktop_ai_settings_summaries_desc">Used for EPUB summaries and PDF page summaries.</string>
<string name="desktop_author_text">Author text</string>
<!-- %1$s = voice/cache label. -->
<string name="desktop_cache_format">Cache: %1$s</string>
<string name="desktop_cached">Cached</string>
<string name="desktop_cached_summary">Cached summary</string>
<string name="desktop_choose_cloud_tts_voice">Choose the Gemini voice used for cloud read aloud.</string>
<string name="desktop_clear_book_cache_desc">Delete generated desktop book and EPUB pagination cache files? They will be recreated the next time books are opened.</string>
<string name="desktop_clear_voice_cache">Clear voice cache</string>
<string name="desktop_close_tools">Close tools</string>
<string name="desktop_cloud_tts_needs_gemini">Cloud TTS needs Gemini</string>
<string name="desktop_cloud_tts_needs_signed_in_credits">Cloud TTS needs signed-in credits</string>
<string name="desktop_cloud_tts_ready">Cloud TTS ready</string>
<string name="desktop_cloud_tts_settings">Cloud TTS settings</string>
<string name="desktop_cloud_tts_unavailable">Cloud TTS unavailable</string>
<string name="desktop_cloud_tts_voice">Cloud TTS voice</string>
<string name="desktop_contains">Contains</string>
<string name="desktop_cost_calculating">Cost calculating</string>
<string name="desktop_create_recap_current_position">Create a recap up to your current position.</string>
<string name="desktop_create_smart_shelf">Create smart shelf</string>
<!-- %1$d = integer credit count. -->
<string name="desktop_credits_available_format">%1$d credits available</string>
<!-- %1$s = decimal credit cost. -->
<string name="desktop_credits_decimal_format">%1$s credits</string>
<string name="desktop_custom_fonts_desc">Imported fonts for the reader</string>
<string name="desktop_delete_font">Delete font</string>
<!-- %1$s = font display name. -->
<string name="desktop_delete_font_desc">Delete %1$s? Books using it will fall back to the default font.</string>
<!-- %1$s = shelf name. -->
<string name="desktop_delete_shelf_desc">Delete \"%1$s\"? Books stay in your library.</string>
<string name="desktop_delete_summary">Delete summary</string>
<string name="desktop_disabled">Disabled</string>
<string name="desktop_drop_files_to_import">Drop files to import</string>
<string name="desktop_drop_supported_files_to_import">Drop supported files to import</string>
<string name="desktop_email_support_desc">Contact us directly by email for anything else.</string>
<string name="desktop_equals">Equals</string>
<string name="desktop_extras">Extras</string>
<string name="desktop_feedback">Feedback</string>
<string name="desktop_field">Field</string>
<string name="desktop_folder_path">Folder path</string>
<string name="desktop_from_here">From here</string>
<string name="desktop_full_scan">Full scan</string>
<!-- %1$d = number of free uses remaining. -->
<string name="desktop_free_remaining_format">Free, %1$d left</string>
<string name="desktop_generate_recap">Generate recap</string>
<string name="desktop_generate_summary">Generate summary</string>
<string name="desktop_get_in_touch_desc">Report bugs, request features, or contact support directly.</string>
<string name="desktop_github_sponsors">GitHub Sponsors</string>
<string name="desktop_github_sponsors_desc">Support development through GitHub Sponsors.</string>
<string name="desktop_google_sign_in_not_configured">Google sign-in is not configured for this desktop build.</string>
<string name="desktop_greater_than">Greater than</string>
<string name="desktop_help_feedback_desc">Bug reports, feature requests, and support</string>
<string name="desktop_hide">Hide</string>
<string name="desktop_import_files">Import files</string>
<string name="desktop_issues">Issues</string>
<string name="desktop_issues_desc">Open the issue tracker for bugs and feature requests.</string>
<string name="desktop_less_than">Less than</string>
<string name="desktop_library_and_reader">Library and reader</string>
<string name="desktop_match_any">Any</string>
<string name="desktop_no_cached_summaries_book">No cached summaries for this book yet.</string>
<string name="desktop_no_custom_fonts_desc">Import TTF, OTF, or WOFF2 files to use them in books.</string>
<!-- %1$s = search query. -->
<string name="desktop_no_fonts_matching">No fonts found matching \"%1$s\"</string>
<string name="desktop_no_google_account_connected">No Google account is connected.</string>
<string name="desktop_no_summary_cached_section">No summary cached for this section.</string>
<string name="desktop_open_readers">Open readers</string>
<!-- %1$s = book or section title. -->
<string name="desktop_opening_title">Opening %1$s</string>
<string name="desktop_opening_your_library">Opening your library</string>
<string name="desktop_operator">Operator</string>
<string name="desktop_page">Page</string>
<string name="desktop_password_protected_pdf">Password protected PDF</string>
<string name="desktop_patreon">Patreon</string>
<string name="desktop_patreon_desc">Support the project on Patreon.</string>
<string name="desktop_paused">Paused</string>
<!-- %1$s = PDF title. -->
<string name="desktop_pdf_password_required_desc">%1$s requires a password before it can be opened.</string>
<string name="desktop_pdf_password_required_or_incorrect">Password is required or incorrect.</string>
<!-- %1$s = PDF title. -->
<string name="desktop_pdf_password_retry_desc">That password did not open %1$s. Enter the PDF password and try again.</string>
<string name="desktop_percent">Percent</string>
<string name="desktop_preparing_audio">Preparing audio</string>
<string name="desktop_pro">Pro</string>
<string name="desktop_pro_and_credits">Pro and credits</string>
<string name="desktop_pro_not_unlocked_account">Pro is not unlocked for this account.</string>
<string name="desktop_pro_purchase_android_desc">Pro and credits can only be purchased from the Android app. Desktop checks the same signed-in account and uses those credits for cloud TTS, summaries, recaps, and other paid AI features.</string>
<string name="desktop_pro_sign_in_desc">Sign in to check your account status on desktop.</string>
<string name="desktop_pro_unlocked_account">Pro is unlocked for this account.</string>
<string name="desktop_progress">Progress</string>
<string name="desktop_project">Project</string>
<string name="desktop_reader">Reader</string>
<string name="desktop_refresh">Refresh</string>
<string name="desktop_release_add_library">Release to add to your library.</string>
<string name="desktop_secure_key_storage_unavailable">Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted.</string>
<string name="desktop_settings_hub">Settings hub</string>
<string name="desktop_show_ai_in_reader_desc">Matches the Android hide toggle for smart dictionary, summaries, and recaps.</string>
<string name="desktop_signed_in">Signed in</string>
<string name="desktop_source_code">Source code</string>
<string name="desktop_source_code_desc">Browse the project source on GitHub.</string>
<string name="desktop_stop_reading_change_voices">Stop reading to change voices.</string>
<string name="desktop_support">Support</string>
<string name="desktop_support_episteme">Support Episteme</string>
<string name="desktop_support_episteme_desc">Contributions help keep the reader improving across Android and desktop.</string>
<string name="desktop_support_project_desc">Ways to support Episteme development</string>
<string name="desktop_sync_folders">Sync folders</string>
<string name="desktop_sync_metadata">Sync metadata</string>
<string name="desktop_tag_name">Tag name</string>
<string name="desktop_tag_selected_books">Tag selected books</string>
<string name="desktop_title_text">Title text</string>
<string name="desktop_tools">Tools</string>
<string name="desktop_tools_desc">Import, sync, and app settings</string>
<string name="desktop_type_example_pdf">Type, e.g. PDF</string>
<string name="desktop_view">View</string>
<string name="desktop_voice_cache">Voice cache</string>
<string name="desktop_webview_preparing">Preparing embedded webview…</string>
<!-- %1$d = progress percentage. %% is a literal percent sign. -->
<string name="desktop_webview_preparing_progress">Preparing bundled embedded webview %1$d%%</string>
<string name="desktop_webview_restart_required">Embedded webview installed. Restart Episteme to finish setup.</string>
<!-- %1$s = startup error detail. -->
<string name="desktop_webview_start_error">Embedded webview could not start: %1$s</string>
<string name="desktop_working">Working…</string>
<string name="desktop_workspace">Workspace</string>
<!-- Desktop action/dialog title for adding selected books to a manual shelf. -->
<string name="desktop_add_to_shelf">Add to shelf</string>
<string name="desktop_create_shelf_first">Create a shelf first, then add selected books to it.</string>
<string name="desktop_create_theme">Create theme</string>
<!-- %1$s = comma-separated existing tag names. -->
<string name="desktop_existing_tags_format">Existing: %1$s</string>
<string name="desktop_external_link_desc">You clicked an external link.</string>
<string name="desktop_edit_epub_metadata">Edit EPUB metadata</string>
<string name="desktop_less">Less</string>
<string name="desktop_more">…more</string>
<string name="desktop_no_custom_themes_yet">No custom themes yet</string>
<string name="desktop_rename_in_app">Rename in app</string>
<string name="desktop_tags_comma_separated">Tags, comma separated</string>
<string name="unknown">Unknown</string>
<string name="action_define">Define</string>
<string name="desktop_annotation">Annotation</string>
<string name="desktop_annotation_options">Annotation options</string>
<string name="desktop_annotation_tools">Annotation tools</string>
<string name="desktop_assist">Assist</string>
<string name="desktop_choose_pdf_to_save">Choose which PDF to save.</string>
<string name="desktop_clear_jump_history">Clear jump history</string>
<string name="desktop_cloud_tts_failed">Cloud TTS failed.</string>
<string name="desktop_cloud_tts_needs_gemini_key_desc">Add a Gemini key and select Gemini cloud TTS in AI keys and models.</string>
<string name="desktop_cloud_tts_not_configured_desc">Cloud TTS is not configured for this desktop build.</string>
<string name="desktop_cloud_tts_sign_in_required_desc">Sign in with Google to use cloud TTS.</string>
<string name="desktop_cloud_tts_signed_in_credits_required_desc">Cloud TTS needs a signed-in account with credits. Pro and credits can only be purchased from the Android app.</string>
<string name="desktop_color">Color</string>
<string name="desktop_comment_options">Comment options</string>
<string name="desktop_custom_theme_default">Custom</string>
<string name="desktop_delete_annotation_desc">This removes the annotation from this PDF.</string>
<string name="desktop_delete_annotation_title">Delete annotation?</string>
<string name="desktop_document_text">Document text</string>
<string name="desktop_embedded_pdf_comment">Embedded PDF comment</string>
<string name="desktop_failed_render_page">Failed to render page.</string>
<string name="desktop_feature_unavailable">Feature unavailable</string>
<string name="desktop_finished">Finished</string>
<string name="desktop_fountain_pen">Fountain pen</string>
<string name="desktop_hide_search_results">Hide search results</string>
<!-- %1$d = highlighter palette slot number. -->
<string name="desktop_highlight_color_format">Highlight color %1$d</string>
<string name="desktop_highlighter_palette">Highlighter palette</string>
<string name="desktop_interaction">Interaction</string>
<!-- %1$d = indexed page count; %2$d = total page count. -->
<string name="desktop_indexing_pages_format">Indexing %1$d/%2$d pages</string>
<string name="desktop_markup">Markup</string>
<!-- %1$d = number of search matches. -->
<string name="desktop_matches_format">%1$d matches</string>
<!-- %1$d = number of search matches found while indexing is still running. -->
<string name="desktop_matches_so_far_format">%1$d matches so far</string>
<string name="desktop_next_page">Next page</string>
<string name="desktop_next_search_result">Next search result</string>
<string name="desktop_no_annotations_yet">No annotations yet</string>
<string name="desktop_no_bookmarks_yet">No bookmarks yet</string>
<string name="desktop_no_comment">No comment</string>
<string name="desktop_no_matches">No matches</string>
<string name="desktop_no_matches_indexed_pages_yet">No matches in indexed pages yet</string>
<string name="desktop_no_table_of_contents">No table of contents</string>
<string name="desktop_no_text_here_to_read">There is no text here to read.</string>
<string name="desktop_no_text_on_page_to_read">There is no text on this page to read.</string>
<string name="desktop_no_text_to_summarize">There is no text to summarize.</string>
<string name="desktop_open_comment">Open comment</string>
<string name="desktop_out_of_credits_android_purchase_desc">Out of credits. Pro and credits can only be purchased from the Android app.</string>
<string name="desktop_out_of_credits_cloud_tts_desc">Using cloud TTS needs credits on desktop. Pro and credits can only be purchased from the Android app.</string>
<string name="desktop_out_of_credits_generic_feature_desc">Using this feature needs credits on desktop. Pro and credits can only be purchased from the Android app.</string>
<string name="desktop_out_of_credits_recaps_desc">Using recaps needs credits on desktop. Pro and credits can only be purchased from the Android app.</string>
<string name="desktop_out_of_credits_summaries_desc">Using summaries needs credits on desktop. Pro and credits can only be purchased from the Android app.</string>
<string name="desktop_pan">Pan</string>
<string name="desktop_pdf_action_failed">PDF action failed</string>
<string name="desktop_pdf_action_failed_desc">The PDF action could not be completed.</string>
<string name="desktop_pdf_comment">PDF comment</string>
<!-- %1$d = 1-indexed page number. -->
<string name="desktop_pdf_compact_page_number">p. %1$d</string>
<!-- %1$d = 1-indexed page number. -->
<string name="desktop_pdf_page_content_desc">PDF page %1$d</string>
<!-- %1$d = 1-indexed page number; %2$s = comment author. -->
<string name="desktop_pdf_page_author_format">Page %1$d - %2$s</string>
<!-- %1$s = page number or page range; %2$d = total page count. -->
<string name="desktop_pdf_page_of_count">Page %1$s of %2$d</string>
<!-- %1$s = page range; %2$d = total page count. -->
<string name="desktop_pdf_pages_of_count">Pages %1$s of %2$d</string>
<string name="desktop_pdf_saved">PDF saved</string>
<string name="desktop_pdf_tools">PDF tools</string>
<string name="desktop_pencil">Pencil</string>
<string name="desktop_preparing_selection">Preparing selection</string>
<!-- %1$s = read scope, such as page or chapter. -->
<string name="desktop_preparing_scope_format">Preparing %1$s</string>
<string name="desktop_previous_page">Previous page</string>
<string name="desktop_previous_search_result">Previous search result</string>
<string name="desktop_print_dialog_finished">The print dialog has finished.</string>
<string name="desktop_pro_required">Pro required</string>
<string name="desktop_pro_required_generic_feature_desc">This feature requires Pro. Pro can only be purchased from the Android app, then desktop will use the upgraded account after sign-in.</string>
<string name="desktop_pro_required_multi_word_dictionary_desc">Multi-word smart dictionary requires Pro. Pro can only be purchased from the Android app, then desktop will use the upgraded account after sign-in.</string>
<string name="desktop_reader_ai_hidden_desc">Reader AI features are hidden.</string>
<string name="desktop_ai_not_configured_desc">Desktop AI is not configured for this build.</string>
<string name="desktop_remove_gap_between_pages_desc">Applies to vertical reading mode.</string>
<string name="desktop_round_highlighter">Round highlighter</string>
<!-- %1$s = saved file path. -->
<string name="desktop_saved_to_path_format">Saved to %1$s</string>
<string name="desktop_scroll">Scroll</string>
<string name="desktop_search_in_pdf">Search in PDF</string>
<string name="desktop_select_text">Select text</string>
<!-- %1$s = localized annotation type. -->
<string name="desktop_selected_annotation_format">Selected %1$s</string>
<string name="desktop_show_search_results">Show search results</string>
<string name="desktop_sign_in_required_generic_feature_desc">Sign in with Google to use this feature on desktop.</string>
<string name="desktop_sign_in_required_multi_word_dictionary_desc">Sign in with Google to use multi-word smart dictionary on desktop.</string>
<string name="desktop_sign_in_required_recaps_desc">Sign in with Google to use recaps on desktop.</string>
<string name="desktop_sign_in_required_summaries_desc">Sign in with Google to use summaries on desktop.</string>
<string name="desktop_stopped">Stopped</string>
<string name="desktop_text_note">Text note</string>
<string name="desktop_text_note_lowercase">text note</string>
<string name="desktop_text_style">Text style</string>
<!-- %1$s = stroke thickness percentage. -->
<string name="desktop_thickness_format">Thickness %1$s</string>
<string name="desktop_toc">TOC</string>
<string name="desktop_type_to_search_pdf">Type to search this PDF</string>
<string name="desktop_untitled">Untitled</string>
<string name="desktop_view_pro_and_credits">View Pro and credits</string>
<string name="desktop_voice_cache_cleared">Voice cache cleared</string>
<string name="desktop_zoom">Zoom</string>
<string name="desktop_zoom_in">Zoom in</string>
<string name="desktop_zoom_out">Zoom out</string>
<string name="action_choose">Choose</string>
<string name="action_continue_reading">Continue reading</string>
<string name="action_dismiss">Dismiss</string>
<string name="action_down">Down</string>
<string name="action_up">Up</string>
<string name="desktop_ai">AI</string>
<string name="desktop_align_center">Center</string>
<string name="desktop_authors">Authors</string>
<string name="desktop_back_to_library">Back to library</string>
<string name="desktop_book_actions">Book actions</string>
<!-- Small badge shown on a book card when the book comes from a synced/local folder. -->
<string name="desktop_book_badge_folder">Folder</string>
<string name="desktop_browse">Browse</string>
<string name="desktop_categories">Categories</string>
<!-- %1$d = 1-indexed chapter number. -->
<string name="desktop_chapter_short_format">Ch. %1$d</string>
<string name="desktop_chapter_turns">Chapter Turns</string>
<string name="desktop_choose_font">Choose font</string>
<string name="desktop_choose_reader_texture">Choose reader texture</string>
<string name="desktop_clear_file_types">Clear file types</string>
<string name="desktop_clear_page_annotations">Clear page annotations</string>
<string name="desktop_clear_sources">Clear sources</string>
<string name="desktop_clear_status">Clear status</string>
<string name="desktop_clear_tags">Clear tags</string>
<string name="desktop_close_reader">Close reader</string>
<string name="desktop_continuous">Continuous</string>
<string name="desktop_cover_view">Covers</string>
<string name="desktop_custom_colors">Custom colors</string>
<string name="desktop_custom_theme_preview">Custom theme preview</string>
<!-- %1$s = setting label. -->
<string name="desktop_decrease_format">Decrease %1$s</string>
<string name="desktop_define_page">Define page</string>
<string name="desktop_delete_highlight_desc">This removes the highlight and its note.</string>
<string name="desktop_enter_full_screen">Enter full screen</string>
<string name="desktop_exit_full_screen">Exit full screen</string>
<string name="desktop_external_lookup">External lookup</string>
<!-- Standalone file-type filter group label for book formats such as EPUB and MOBI. Do not reuse for counts or sentences. -->
<string name="desktop_file_type_group_books">Books</string>
<string name="desktop_file_type_group_comics">Comics</string>
<string name="desktop_file_type_group_documents">Documents</string>
<string name="desktop_file_type_group_other">Other</string>
<string name="desktop_file_type_group_text_web">Text and web</string>
<string name="desktop_fill">Fill</string>
<string name="desktop_fixed_layout_appearance">Fixed-layout appearance</string>
<string name="desktop_folder_empty">Folder is empty</string>
<string name="desktop_folder_empty_desc">No supported files or subfolders are available here.</string>
<!-- Desktop folder shelf subtitle that joins already-localized count phrases. %1$s = folder count phrase; %2$s = file count phrase. -->
<string name="desktop_folder_subtitle_folder_file_counts">%1$s, %2$s</string>
<!-- %1$s = first label; %2$s = second label. -->
<string name="desktop_label_pair_format">%1$s - %2$s</string>
<string name="desktop_hide_filters">Hide filters</string>
<string name="desktop_hide_reader_tools">Hide reader tools</string>
<string name="desktop_highlight_palette_hint">Tap a slot, then pick a color.</string>
<string name="desktop_home_subtitle">Continue reading and recent books</string>
<string name="desktop_import_books">Import books</string>
<string name="desktop_import_folder">Import folder</string>
<string name="desktop_imported_fonts">Imported fonts</string>
<!-- Desktop import banner that joins two complete status sentences. %1$s = imported-count sentence; %2$s = skipped-count sentence. -->
<string name="desktop_import_result_pair">%1$s %2$s</string>
<!-- %1$s = setting label. -->
<string name="desktop_increase_format">Increase %1$s</string>
<string name="desktop_jump_history">Jump history</string>
<string name="desktop_layout_spacing">Layout and Spacing</string>
<string name="desktop_library_empty_desc">Import files into app storage or add a folder to read files in place.</string>
<string name="desktop_library_subtitle">Browse your collection</string>
<!-- Desktop library tab label with count. %1$d = number of smart shelves. Keep as a full tab phrase, not a reusable noun. -->
<string name="desktop_library_tab_smart_shelves_count">Smart %1$d</string>
<!-- Desktop library tab label with count. %1$d = number of unread books. Keep as a full tab phrase. -->
<string name="desktop_library_tab_unread_count">Unread %1$d</string>
<!-- Desktop library tab label with count. %1$d = number of in-progress books. Keep as a full tab phrase. -->
<string name="desktop_library_tab_in_progress_count">In progress %1$d</string>
<!-- Desktop library tab label with count. %1$d = number of completed books. Keep as a full tab phrase. -->
<string name="desktop_library_tab_completed_count">Complete %1$d</string>
<string name="desktop_list_view">List</string>
<string name="desktop_navigation">Navigation</string>
<string name="desktop_no_book_open">No book open</string>
<string name="desktop_no_folders_desc">Add a folder to read files from that folder in place.</string>
<string name="desktop_no_folders_yet">No folders yet</string>
<string name="desktop_no_navigation_items">No navigation items</string>
<string name="desktop_no_page_content">No page content</string>
<string name="desktop_no_settings_found">No settings found</string>
<string name="desktop_no_shelves_desc">Manual shelves and series collections will appear here.</string>
<string name="desktop_no_shelves_yet">No shelves yet</string>
<string name="desktop_no_smart_shelves_desc">Create smart shelves to collect books by rules.</string>
<string name="desktop_no_smart_shelves_yet">No smart shelves yet</string>
<string name="desktop_no_tags_desc">Tags added to books will appear here.</string>
<string name="desktop_no_tags_yet">No tags yet</string>
<!-- Desktop import result banner shown when every selected file is unsupported. -->
<string name="desktop_no_supported_files_imported">No supported files were imported.</string>
<string name="desktop_opds_catalog">Catalog</string>
<!-- %1$s = catalog title. -->
<string name="desktop_opds_delete_catalog_desc">Delete &quot;%1$s&quot;? Streamed books from this catalog may stop opening if credentials change later.</string>
<string name="desktop_opds_no_catalogs">No catalogs</string>
<string name="desktop_opds_no_catalogs_desc">Add an OPDS catalog to browse remote books.</string>
<string name="desktop_opds_subtitle">Browse catalogs, streams, and downloads</string>
<string name="desktop_open_book">Open Book</string>
<string name="desktop_open_folder">Open folder</string>
<string name="desktop_open_pdf">Open PDF</string>
<string name="desktop_page_and_text_colors">Page and text colors</string>
<string name="desktop_page_info">Page Info</string>
<string name="desktop_page_width">Page width</string>
<string name="desktop_pdf_appearance_defaults_desc">These defaults apply where the platform supports shared PDF appearance. Per-book PDF overrides stay in the PDF reader.</string>
<string name="desktop_pdf_file_actions">PDF file actions</string>
<string name="desktop_pdf_highlighter">PDF highlighter</string>
<string name="desktop_pdf_highlighter_alpha_desc">Saved with reader highlight transparency.</string>
<string name="desktop_pin">Pin</string>
<string name="desktop_reader_managed_pdf_tools">Reader-managed PDF tools</string>
<string name="desktop_reader_managed_pdf_tools_desc">Auto-scroll, OCR, annotation defaults, and PDF-only tool visibility are managed inside the active PDF reader.</string>
<!-- %1$s = page mode; %2$s = page or spread label; %3$d = total pages; %4$d = progress percentage. -->
<string name="desktop_reader_page_info_format">%1$s %2$s of %3$d (%4$d%%)</string>
<string name="desktop_reader_toolbar_managed_in_reader">Reader toolbar defaults are managed from the reader on this platform.</string>
<string name="desktop_reader_tools">Reader tools</string>
<string name="desktop_save_image">Save image</string>
<!-- %1$s = current search query. -->
<string name="desktop_search_filter_format">Search: %1$s</string>
<string name="desktop_search_in_reader">Search in reader</string>
<string name="desktop_search_settings">Search settings</string>
<string name="desktop_selection">Selection</string>
<string name="desktop_selection_end_handle">Selection end handle</string>
<string name="desktop_selection_start_handle">Selection start handle</string>
<!-- Empty-state body for the top-level shelves screen that includes shelves, tags, and folder metadata. -->
<string name="desktop_shelves_overview_empty_desc">Add shelves, tags, or folder metadata to organize your library.</string>
<string name="desktop_shelves_subtitle">Collections, series, tags, and folders</string>
<string name="desktop_show_reader_tools">Show reader tools</string>
<!-- Desktop smart-shelf rule field label for matching a source folder path. -->
<string name="desktop_smart_field_folder">Folder</string>
<string name="desktop_smart_shelves">Smart</string>
<string name="desktop_solid">Solid</string>
<string name="desktop_speed">Speed</string>
<string name="desktop_start_auto_scroll">Start auto scroll</string>
<string name="desktop_stop_auto_scroll">Stop auto scroll</string>
<string name="desktop_stop_read_aloud">Stop read aloud</string>
<string name="desktop_texture_strength">Texture strength</string>
<string name="desktop_type_to_search_book">Type to search this book</string>
<string name="desktop_typography">Typography</string>
<string name="desktop_undo_annotation">Undo annotation</string>
<string name="desktop_unpin">Unpin</string>
<string name="desktop_use_dark_theme">Use dark theme</string>
<string name="desktop_use_light_theme">Use light theme</string>
<string name="font_mono">Mono</string>
<string name="font_sans">Sans</string>
<string name="font_serif">Serif</string>
<string name="library_search_placeholder">Search books, authors, or tags</string>
<string name="toolbar_no_tools">No tools</string>
<string name="toolbar_visible">Visible</string>
<string name="tts_replacements_replace_only_spoken">Replace only what is spoken</string>
<string name="tts_replacements_replace_only_spoken_desc">Reader text, highlights, and locations stay unchanged.</string>
<!-- %1$s = replacement source; %2$s = spoken replacement. -->
<string name="tts_replacements_summary_format">%1$s -&gt; %2$s</string>
</resources> </resources>

View file

@ -123,6 +123,7 @@ class AndroidSettingsHubModelsTest {
uiState = ReaderScreenState( uiState = ReaderScreenState(
isTabsEnabled = true, isTabsEnabled = true,
useStrictFileFilter = true, useStrictFileFilter = true,
usePdfFileNameAsDisplayName = true,
isScreenCaptureProtectionEnabled = true isScreenCaptureProtectionEnabled = true
), ),
isOssBuild = false, isOssBuild = false,
@ -134,6 +135,7 @@ class AndroidSettingsHubModelsTest {
assertTrue(toggles.getValue(SharedSettingsAction.TABS_TOGGLE).checked == true) assertTrue(toggles.getValue(SharedSettingsAction.TABS_TOGGLE).checked == true)
assertTrue(toggles.getValue(SharedSettingsAction.STRICT_FILE_FILTER).checked == true) assertTrue(toggles.getValue(SharedSettingsAction.STRICT_FILE_FILTER).checked == true)
assertTrue(toggles.getValue(SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME).checked == true)
assertTrue(toggles.getValue(SharedSettingsAction.SCREEN_CAPTURE_PROTECTION).checked == true) assertTrue(toggles.getValue(SharedSettingsAction.SCREEN_CAPTURE_PROTECTION).checked == true)
} }
@ -153,6 +155,7 @@ class AndroidSettingsHubModelsTest {
assertTrue(SharedSettingsAction.LANGUAGE in extraActions) assertTrue(SharedSettingsAction.LANGUAGE in extraActions)
assertTrue(SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR in extraActions) assertTrue(SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR in extraActions)
assertTrue(SharedSettingsAction.STRICT_FILE_FILTER in extraActions) assertTrue(SharedSettingsAction.STRICT_FILE_FILTER in extraActions)
assertTrue(SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME in extraActions)
assertTrue(SharedSettingsAction.CLEAR_BOOK_CACHE in extraActions) assertTrue(SharedSettingsAction.CLEAR_BOOK_CACHE in extraActions)
assertTrue(SharedSettingsAction.CLEAR_REFLOW_CACHE in extraActions) assertTrue(SharedSettingsAction.CLEAR_REFLOW_CACHE in extraActions)
assertTrue(SharedSettingsAction.TEST_PANEL_DETECTION in extraActions) assertTrue(SharedSettingsAction.TEST_PANEL_DETECTION in extraActions)

View file

@ -4,6 +4,7 @@ import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.AppAction as SharedAppAction import com.aryan.reader.shared.AppAction as SharedAppAction
import com.aryan.reader.shared.AppFontPreference as SharedAppFontPreference
import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -78,6 +79,19 @@ class AndroidSharedStateBridgeTest {
assertEquals(AppThemeMode.DARK, result.appThemeMode) assertEquals(AppThemeMode.DARK, result.appThemeMode)
} }
@Test
fun `reduceAppAction applies shared app font preference back to Android fields`() {
val preference = SharedAppFontPreference.custom("font")
val result = AndroidSharedStateBridge.reduceAppAction(
current = ReaderScreenState(),
projectedState = ReaderScreenState(),
action = SharedAppAction.AppFontPreferenceChanged(preference)
)
assertEquals(preference, result.appFontPreference)
}
@Test @Test
fun `setTabsEnabled disables shared tabs but preserves Android active reader session`() { fun `setTabsEnabled disables shared tabs but preserves Android active reader session`() {
val result = AndroidSharedStateBridge.setTabsEnabled( val result = AndroidSharedStateBridge.setTabsEnabled(

View file

@ -0,0 +1,143 @@
package com.aryan.reader
import java.io.File
import java.util.Date
import java.util.IllegalFormatException
import java.util.Locale
import javax.xml.parsers.DocumentBuilderFactory
import org.junit.Assert.assertTrue
import org.junit.Test
class AndroidStringFormatResourcesTest {
@Test
fun `localized formatted strings use valid formatter syntax`() {
val resDirectory = findResDirectory()
val baseStrings = readStringResources(File(resDirectory, "values/strings.xml"))
val formattedBaseStrings = baseStrings
.mapValues { (_, value) -> value.formatArguments() }
.filterValues { it.isNotEmpty() }
val failures = resDirectory
.listFiles()
.orEmpty()
.filter { it.isDirectory && it.name.startsWith("values") }
.map { File(it, "strings.xml") }
.filter { it.isFile }
.flatMap { stringsFile ->
val strings = readStringResources(stringsFile)
formattedBaseStrings.mapNotNull { (name, arguments) ->
val value = strings[name] ?: return@mapNotNull null
val sampleArguments = arguments.toSampleArguments()
try {
String.format(Locale.ROOT, value, *sampleArguments)
null
} catch (exception: IllegalFormatException) {
"${stringsFile.invariantSeparatorsPath}:$name -> ${exception.javaClass.simpleName}: ${exception.message}"
}
}
}
assertTrue(failures.joinToString(separator = "\n"), failures.isEmpty())
}
private fun findResDirectory(): File {
return listOf(
File("src/main/res"),
File("app/src/main/res")
).first { it.isDirectory }
}
private fun readStringResources(stringsFile: File): Map<String, String> {
val document = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(stringsFile)
val nodes = document.getElementsByTagName("string")
return buildMap {
for (index in 0 until nodes.length) {
val node = nodes.item(index)
val name = node.attributes
?.getNamedItem("name")
?.nodeValue
?: continue
put(name, node.textContent)
}
}
}
private fun String.formatArguments(): List<FormatArgument> {
val arguments = mutableListOf<FormatArgument>()
var nextImplicitIndex = 0
var previousIndex = -1
for (match in formatterPattern.findAll(this)) {
val conversion = (match.groups[4] ?: match.groups[5])?.value?.singleOrNull() ?: continue
val dateTimePrefix = match.groups[3]?.value
if (conversion == '%' || conversion == 'n') continue
val index = when {
match.groups[2] != null -> previousIndex
match.groups[1] != null -> match.groups[1]!!.value.toInt() - 1
else -> nextImplicitIndex++
}
if (index < 0) continue
previousIndex = index
val argument = FormatArgument(index, conversion.sampleKind(dateTimePrefix != null))
val existingIndex = arguments.indexOfFirst { it.index == index }
if (existingIndex >= 0) {
arguments[existingIndex] = arguments[existingIndex].merge(argument)
} else {
arguments += argument
}
}
return arguments
}
private fun Char.sampleKind(isDateTime: Boolean): SampleKind {
if (isDateTime) return SampleKind.DateTime
return when (lowercaseChar()) {
'd', 'o', 'x' -> SampleKind.Integer
'e', 'f', 'g', 'a' -> SampleKind.Decimal
'c' -> SampleKind.Character
'b' -> SampleKind.Boolean
'h', 's' -> SampleKind.Text
else -> SampleKind.Text
}
}
private fun List<FormatArgument>.toSampleArguments(): Array<Any> {
val maxIndex = maxOf { it.index }
val samples = Array<Any>(maxIndex + 1) { "sample" }
forEach { argument ->
samples[argument.index] = argument.kind.sample
}
return samples
}
private data class FormatArgument(
val index: Int,
val kind: SampleKind
) {
fun merge(other: FormatArgument): FormatArgument {
return if (kind == other.kind) this else copy(kind = SampleKind.Text)
}
}
private enum class SampleKind(val sample: Any) {
Text("sample"),
Integer(7),
Decimal(1.5),
Character('x'),
Boolean(true),
DateTime(Date(0L))
}
private companion object {
private val formatterPattern = Regex(
"%(?:([1-9]\\d*)\\$|(<))?[-#+ 0,(]*\\d*(?:\\.\\d+)?(?:(?:([tT])([a-zA-Z]))|([bBhHsScCdoxXeEfgGaA%n]))"
)
}
}

View file

@ -0,0 +1,34 @@
package com.aryan.reader
import com.aryan.reader.data.CustomFontEntity
import org.junit.Assert.assertNull
import org.junit.Test
class AppFontResolverTest {
@Test
fun `custom app font falls back to system when imported font is missing`() {
val resolved = AppFontPreference.custom("missing")
.toAndroidAppFontFamily(customFonts = emptyList())
assertNull(resolved)
}
@Test
fun `custom app font falls back to system when imported font file is gone`() {
val resolved = AppFontPreference.custom("font")
.toAndroidAppFontFamily(
customFonts = listOf(
CustomFontEntity(
id = "font",
displayName = "Missing",
fileName = "missing.ttf",
fileExtension = "ttf",
path = "build/test-tmp/AppFontResolverTest/missing-${System.nanoTime()}.ttf",
timestamp = 1L
)
)
)
assertNull(resolved)
}
}

View file

@ -6,6 +6,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.w3c.dom.Element
class AppLanguageOptionsTest { class AppLanguageOptionsTest {
@ -79,6 +80,25 @@ class AppLanguageOptionsTest {
assertEquals(readLocaleConfigTags(), supportedAppLanguageOptions.map { it.tag }) assertEquals(readLocaleConfigTags(), supportedAppLanguageOptions.map { it.tag })
} }
@Test
fun `android manifest enables AppCompat language persistence`() {
val manifest = readAndroidManifest()
val service = manifest.getElementsByTagName("service").asElements()
.singleOrNull {
it.androidAttribute("name") == "androidx.appcompat.app.AppLocalesMetadataHolderService"
}
assertTrue(service != null)
assertEquals("false", service!!.androidAttribute("enabled"))
assertEquals("false", service.androidAttribute("exported"))
val autoStoreLocales = service.getElementsByTagName("meta-data").asElements()
.singleOrNull { it.androidAttribute("name") == "autoStoreLocales" }
assertTrue(autoStoreLocales != null)
assertEquals("true", autoStoreLocales!!.androidAttribute("value"))
}
private fun readLocaleConfigTags(): List<String> { private fun readLocaleConfigTags(): List<String> {
val localeConfig = listOf( val localeConfig = listOf(
File("src/main/res/xml/locales_config.xml"), File("src/main/res/xml/locales_config.xml"),
@ -101,4 +121,28 @@ class AppLanguageOptionsTest {
} }
} }
} }
private fun readAndroidManifest(): org.w3c.dom.Document {
val manifest = listOf(
File("src/main/AndroidManifest.xml"),
File("app/src/main/AndroidManifest.xml")
).first { it.isFile }
return DocumentBuilderFactory.newInstance()
.apply { isNamespaceAware = true }
.newDocumentBuilder()
.parse(manifest)
}
private fun org.w3c.dom.NodeList.asElements(): List<Element> =
buildList {
for (index in 0 until length) {
val element = item(index) as? Element
if (element != null) add(element)
}
}
private fun Element.androidAttribute(name: String): String? =
attributes
?.getNamedItemNS("http://schemas.android.com/apk/res/android", name)
?.nodeValue
} }

Some files were not shown because too many files have changed in this diff Show more