Merge remote-tracking branch 'origin/main'
8
.gitignore
vendored
|
|
@ -8,7 +8,7 @@
|
||||||
/.idea/navEditor.xml
|
/.idea/navEditor.xml
|
||||||
/.idea/assetWizardSettings.xml
|
/.idea/assetWizardSettings.xml
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
build/
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
|
|
@ -18,3 +18,9 @@ local.properties
|
||||||
google-services.json
|
google-services.json
|
||||||
.kotlin/
|
.kotlin/
|
||||||
.idea/
|
.idea/
|
||||||
|
.gradle/
|
||||||
|
third_party/pdfium/
|
||||||
|
*.tgz
|
||||||
|
kcef-bundle/
|
||||||
|
cache/
|
||||||
|
worker/
|
||||||
20
.idea/gradle.xml
generated
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
<option value="$PROJECT_DIR$/desktopApp" />
|
||||||
|
<option value="$PROJECT_DIR$/shared" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -24,14 +24,13 @@ kotlin {
|
||||||
android {
|
android {
|
||||||
namespace = "com.aryan.reader"
|
namespace = "com.aryan.reader"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
ndkVersion = "29.0.14206865"
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.aryan.reader"
|
applicationId = "com.aryan.reader"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 46
|
versionCode = 51
|
||||||
versionName = "1.0.46"
|
versionName = "1.0.47"
|
||||||
|
|
||||||
resourceConfigurations += setOf("en", "ar", "de", "tr", "fr", "ru")
|
resourceConfigurations += setOf("en", "ar", "de", "tr", "fr", "ru")
|
||||||
|
|
||||||
|
|
@ -154,6 +153,8 @@ android {
|
||||||
//noinspection UseTomlInstead
|
//noinspection UseTomlInstead
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
|
implementation(project(":shared"))
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
@ -190,7 +191,7 @@ dependencies {
|
||||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
|
|
||||||
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)
|
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)
|
||||||
implementation("androidx.navigation:navigation-compose:2.9.2")
|
implementation("androidx.navigation:navigation-compose:2.9.6")
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
|
|
@ -241,6 +242,11 @@ dependencies {
|
||||||
|
|
||||||
implementation("com.materialkolor:material-kolor:5.0.0-alpha07")
|
implementation("com.materialkolor:material-kolor:5.0.0-alpha07")
|
||||||
|
|
||||||
|
debugImplementation("org.tensorflow:tensorflow-lite:2.17.0")
|
||||||
|
debugImplementation("org.tensorflow:tensorflow-lite-support:0.5.0")
|
||||||
|
debugImplementation("org.tensorflow:tensorflow-lite-gpu:2.17.0")
|
||||||
|
debugImplementation("org.tensorflow:tensorflow-lite-gpu-api:2.17.0")
|
||||||
|
|
||||||
implementation("androidx.core:core-splashscreen:1.2.0")
|
implementation("androidx.core:core-splashscreen:1.2.0")
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@
|
||||||
window.VIEWPORT_PADDING_BOTTOM = bottom || 0;
|
window.VIEWPORT_PADDING_BOTTOM = bottom || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
window.applyReaderTheme = function (isDark, bgHex, textHex, textureBase64) {
|
window.applyReaderTheme = function (isDark, bgHex, textHex, textureBase64, textureAlpha) {
|
||||||
var styleId = "readerThemeStyle";
|
var styleId = "readerThemeStyle";
|
||||||
var themeStyleElement = document.getElementById(styleId);
|
var themeStyleElement = document.getElementById(styleId);
|
||||||
|
|
||||||
|
|
@ -207,8 +207,14 @@
|
||||||
var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF');
|
var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF');
|
||||||
var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000');
|
var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000');
|
||||||
|
|
||||||
|
var effectiveTextureAlpha = Math.max(0, Math.min(1, textureAlpha == null ? 0.55 : textureAlpha));
|
||||||
|
var bgMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(effectiveBg);
|
||||||
|
var bgRgb = bgMatch
|
||||||
|
? `${parseInt(bgMatch[1], 16)}, ${parseInt(bgMatch[2], 16)}, ${parseInt(bgMatch[3], 16)}`
|
||||||
|
: (isDark ? '18, 18, 18' : '255, 255, 255');
|
||||||
|
|
||||||
var textureCss = textureBase64
|
var textureCss = textureBase64
|
||||||
? `background-image: url('${textureBase64}'); background-repeat: repeat; background-blend-mode: multiply;`
|
? `background-image: linear-gradient(rgba(${bgRgb},${1 - effectiveTextureAlpha}), rgba(${bgRgb},${1 - effectiveTextureAlpha})), url('${textureBase64}'); background-repeat: repeat, repeat; background-blend-mode: normal, normal;`
|
||||||
: 'background-image: none;';
|
: 'background-image: none;';
|
||||||
|
|
||||||
var css = `
|
var css = `
|
||||||
|
|
@ -517,7 +523,65 @@
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap, imageSize, horizontalMargin) {
|
function getReaderImageElements() {
|
||||||
|
return Array.prototype.slice.call(document.querySelectorAll("img, svg, video, canvas, image"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberReaderImageAnchors() {
|
||||||
|
getReaderImageElements().forEach(function (image) {
|
||||||
|
if (image.getAttribute("data-reader-image-anchor")) return;
|
||||||
|
|
||||||
|
var parent = image.parentElement || document.body;
|
||||||
|
var imageRect = image.getBoundingClientRect();
|
||||||
|
var parentRect = parent.getBoundingClientRect();
|
||||||
|
var parentWidth = parentRect.width || document.documentElement.clientWidth || window.innerWidth || 0;
|
||||||
|
|
||||||
|
if (!parentWidth || imageRect.width <= 0) {
|
||||||
|
image.setAttribute("data-reader-image-anchor", "center");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageCenter = imageRect.left + imageRect.width / 2;
|
||||||
|
var parentCenter = parentRect.left + parentWidth / 2;
|
||||||
|
var tolerance = Math.max(4, parentWidth * 0.08);
|
||||||
|
var anchor = "center";
|
||||||
|
|
||||||
|
if (Math.abs(imageCenter - parentCenter) <= tolerance || imageRect.width >= parentWidth - 2) {
|
||||||
|
anchor = "center";
|
||||||
|
} else if (imageCenter > parentCenter) {
|
||||||
|
anchor = "right";
|
||||||
|
} else {
|
||||||
|
anchor = "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
image.setAttribute("data-reader-image-anchor", anchor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReaderImageAnchors() {
|
||||||
|
getReaderImageElements().forEach(function (image) {
|
||||||
|
var anchor = image.getAttribute("data-reader-image-anchor") || "center";
|
||||||
|
|
||||||
|
image.style.setProperty("display", "block", "important");
|
||||||
|
image.style.setProperty("height", "auto", "important");
|
||||||
|
image.style.setProperty("object-fit", "contain", "important");
|
||||||
|
|
||||||
|
if (anchor === "right") {
|
||||||
|
image.style.setProperty("float", "none", "important");
|
||||||
|
image.style.setProperty("margin-left", "auto", "important");
|
||||||
|
image.style.setProperty("margin-right", "0", "important");
|
||||||
|
} else if (anchor === "left") {
|
||||||
|
image.style.setProperty("margin-left", "0", "important");
|
||||||
|
image.style.setProperty("margin-right", "auto", "important");
|
||||||
|
} else {
|
||||||
|
image.style.setProperty("float", "none", "important");
|
||||||
|
image.style.setProperty("margin-left", "auto", "important");
|
||||||
|
image.style.setProperty("margin-right", "auto", "important");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap, imageSize, horizontalMargin, verticalMargin) {
|
||||||
var logTag = "ReaderFontDiagnosis";
|
var logTag = "ReaderFontDiagnosis";
|
||||||
console.log(
|
console.log(
|
||||||
logTag +
|
logTag +
|
||||||
|
|
@ -534,7 +598,9 @@
|
||||||
", ImageSize: " +
|
", ImageSize: " +
|
||||||
imageSize +
|
imageSize +
|
||||||
", HorizontalMargin: " +
|
", HorizontalMargin: " +
|
||||||
horizontalMargin
|
horizontalMargin +
|
||||||
|
", VerticalMargin: " +
|
||||||
|
verticalMargin
|
||||||
);
|
);
|
||||||
|
|
||||||
var dynamicStyleId = "dynamicReaderStyles";
|
var dynamicStyleId = "dynamicReaderStyles";
|
||||||
|
|
@ -551,12 +617,16 @@
|
||||||
var newGap = parseFloat(paragraphGap);
|
var newGap = parseFloat(paragraphGap);
|
||||||
var newImageSize = parseFloat(imageSize);
|
var newImageSize = parseFloat(imageSize);
|
||||||
var newHorizontalMargin = parseFloat(horizontalMargin);
|
var newHorizontalMargin = parseFloat(horizontalMargin);
|
||||||
|
var newVerticalMargin = parseFloat(verticalMargin);
|
||||||
|
|
||||||
if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0;
|
if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0;
|
||||||
if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.0;
|
if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.0;
|
||||||
if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0;
|
if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0;
|
||||||
if (isNaN(newImageSize) || newImageSize < 0.5 || newImageSize > 2.0) newImageSize = 1.0;
|
if (isNaN(newImageSize) || newImageSize < 0.5 || newImageSize > 2.0) newImageSize = 1.0;
|
||||||
if (isNaN(newHorizontalMargin) || newHorizontalMargin < 0.0 || newHorizontalMargin > 3.0) newHorizontalMargin = 1.0;
|
if (isNaN(newHorizontalMargin) || newHorizontalMargin < 0.0 || newHorizontalMargin > 3.0) newHorizontalMargin = 1.0;
|
||||||
|
if (isNaN(newVerticalMargin) || newVerticalMargin < 0.0 || newVerticalMargin > 3.0) newVerticalMargin = 1.0;
|
||||||
|
|
||||||
|
rememberReaderImageAnchors();
|
||||||
|
|
||||||
var fontCss = "";
|
var fontCss = "";
|
||||||
if (fontFamily && fontFamily !== "Original" && fontFamily !== "") {
|
if (fontFamily && fontFamily !== "Original" && fontFamily !== "") {
|
||||||
|
|
@ -609,11 +679,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
var horizontalPaddingPx = Math.max(0, 16 * newHorizontalMargin);
|
var horizontalPaddingPx = Math.max(0, 16 * newHorizontalMargin);
|
||||||
|
var verticalPaddingPx = Math.max(0, 16 * newVerticalMargin);
|
||||||
var horizontalMarginCss = `
|
var horizontalMarginCss = `
|
||||||
body {
|
body {
|
||||||
box-sizing: border-box !important;
|
box-sizing: border-box !important;
|
||||||
padding-left: ${horizontalPaddingPx}px !important;
|
padding-left: ${horizontalPaddingPx}px !important;
|
||||||
padding-right: ${horizontalPaddingPx}px !important;
|
padding-right: ${horizontalPaddingPx}px !important;
|
||||||
|
padding-top: ${verticalPaddingPx}px !important;
|
||||||
|
padding-bottom: ${verticalPaddingPx}px !important;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -629,10 +702,22 @@
|
||||||
width: min(100%, calc(100% * var(--reader-image-size))) !important;
|
width: min(100%, calc(100% * var(--reader-image-size))) !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
|
display: block !important;
|
||||||
|
float: none !important;
|
||||||
|
margin-left: auto !important;
|
||||||
|
margin-right: auto !important;
|
||||||
|
object-fit: contain !important;
|
||||||
|
}
|
||||||
|
body p:has(> img:only-child),
|
||||||
|
body div:has(> img:only-child),
|
||||||
|
body figure {
|
||||||
|
text-align: center !important;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n");
|
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n");
|
||||||
|
applyReaderImageAnchors();
|
||||||
|
setTimeout(applyReaderImageAnchors, 80);
|
||||||
|
|
||||||
setTimeout(
|
setTimeout(
|
||||||
function () {
|
function () {
|
||||||
|
|
@ -2170,6 +2255,7 @@
|
||||||
if (window.checkImagesForDiagnosis) {
|
if (window.checkImagesForDiagnosis) {
|
||||||
setTimeout(window.checkImagesForDiagnosis, 100);
|
setTimeout(window.checkImagesForDiagnosis, 100);
|
||||||
}
|
}
|
||||||
|
setTimeout(applyReaderImageAnchors, 80);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
BIN
app/src/main/assets/textures/classy_fabric.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/assets/textures/ep_naturalblack.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
app/src/main/assets/textures/ep_naturalwhite.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/assets/textures/grey_wash_wall.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
app/src/main/assets/textures/light-veneer.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
app/src/main/assets/textures/retina_wood.webp
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
app/src/main/assets/textures/retro_intro.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -52,7 +52,7 @@ static FPDFLink_GetDest_t get_dest_func = nullptr;
|
||||||
static FPDFAction_GetDest_t get_action_dest_func = nullptr;
|
static FPDFAction_GetDest_t get_action_dest_func = nullptr;
|
||||||
static FPDFDest_GetDestPageIndex_t get_dest_page_index_func = nullptr;
|
static FPDFDest_GetDestPageIndex_t get_dest_page_index_func = nullptr;
|
||||||
static FPDFAction_GetFilePath_t get_file_path_func = nullptr;
|
static FPDFAction_GetFilePath_t get_file_path_func = nullptr;
|
||||||
static std::mutex g_pdfium_mutex;
|
static std::recursive_mutex g_pdfium_mutex;
|
||||||
static FPDFLink_GetAnnot_t get_link_annot_func = nullptr;
|
static FPDFLink_GetAnnot_t get_link_annot_func = nullptr;
|
||||||
static FPDFLink_GetAction_t get_link_action_func = nullptr;
|
static FPDFLink_GetAction_t get_link_action_func = nullptr;
|
||||||
static FPDFAction_GetType_t get_action_type_func = nullptr;
|
static FPDFAction_GetType_t get_action_type_func = nullptr;
|
||||||
|
|
@ -169,20 +169,23 @@ static bool init_pdfium() {
|
||||||
|
|
||||||
extern "C" JNIEXPORT jdouble JNICALL
|
extern "C" JNIEXPORT jdouble JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontSize(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
|
||||||
if (!init_pdfium() || !get_font_size_func) return 0.0;
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
|
if (!init_pdfium() || !get_font_size_func || textPagePtr == 0 || index < 0) return 0.0;
|
||||||
return get_font_size_func(reinterpret_cast<void*>(textPagePtr), index);
|
return get_font_size_func(reinterpret_cast<void*>(textPagePtr), index);
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" JNIEXPORT jint JNICALL
|
extern "C" JNIEXPORT jint JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontWeight(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getFontWeight(JNIEnv *env, jclass clazz, jlong textPagePtr, jint index) {
|
||||||
if (!init_pdfium() || !get_font_weight_func) return 0;
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
|
if (!init_pdfium() || !get_font_weight_func || textPagePtr == 0 || index < 0) return 0;
|
||||||
return get_font_weight_func(reinterpret_cast<void*>(textPagePtr), index);
|
return get_font_weight_func(reinterpret_cast<void*>(textPagePtr), index);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk extraction for blazing fast formatting processing
|
// Bulk extraction for blazing fast formatting processing
|
||||||
extern "C" JNIEXPORT jfloatArray JNICALL
|
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
|
||||||
if (!init_pdfium() || !get_font_size_func || count <= 0) return nullptr;
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
|
if (!init_pdfium() || !get_font_size_func || textPagePtr == 0 || count <= 0) return nullptr;
|
||||||
|
|
||||||
jfloatArray result = env->NewFloatArray(count);
|
jfloatArray result = env->NewFloatArray(count);
|
||||||
jfloat *fill = new jfloat[count];
|
jfloat *fill = new jfloat[count];
|
||||||
|
|
@ -196,7 +199,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontSizes(JNIEnv *env, jclas
|
||||||
|
|
||||||
extern "C" JNIEXPORT jintArray JNICALL
|
extern "C" JNIEXPORT jintArray JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
|
||||||
if (!init_pdfium() || !get_font_weight_func || count <= 0) return nullptr;
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
|
if (!init_pdfium() || !get_font_weight_func || textPagePtr == 0 || count <= 0) return nullptr;
|
||||||
|
|
||||||
jintArray result = env->NewIntArray(count);
|
jintArray result = env->NewIntArray(count);
|
||||||
jint *fill = new jint[count];
|
jint *fill = new jint[count];
|
||||||
|
|
@ -210,7 +214,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontWeights(JNIEnv *env, jcl
|
||||||
|
|
||||||
extern "C" JNIEXPORT jintArray JNICALL
|
extern "C" JNIEXPORT jintArray JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
|
||||||
if (!init_pdfium() || !get_font_info_func || count <= 0) return nullptr;
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
|
if (!init_pdfium() || !get_font_info_func || textPagePtr == 0 || count <= 0) return nullptr;
|
||||||
|
|
||||||
jintArray result = env->NewIntArray(count);
|
jintArray result = env->NewIntArray(count);
|
||||||
jint *fill = new jint[count];
|
jint *fill = new jint[count];
|
||||||
|
|
@ -226,7 +231,8 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageFontFlags(JNIEnv *env, jclas
|
||||||
|
|
||||||
extern "C" JNIEXPORT jfloatArray JNICALL
|
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclass clazz, jlong textPagePtr, jint count) {
|
||||||
if (!init_pdfium() || !get_char_box_func || count <= 0) return nullptr;
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
|
if (!init_pdfium() || !get_char_box_func || textPagePtr == 0 || count <= 0) return nullptr;
|
||||||
|
|
||||||
const int stride = 4;
|
const int stride = 4;
|
||||||
jfloatArray result = env->NewFloatArray(count * stride);
|
jfloatArray result = env->NewFloatArray(count * stride);
|
||||||
|
|
@ -247,7 +253,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageCharBoxes(JNIEnv *env, jclas
|
||||||
|
|
||||||
extern "C" JNIEXPORT jstring JNICALL
|
extern "C" JNIEXPORT jstring JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jstring key) {
|
||||||
std::lock_guard<std::mutex> lock(g_pdfium_mutex);
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || !get_annot_func || !get_annot_string_func || pagePtr == 0) return nullptr;
|
if (!init_pdfium() || !get_annot_func || !get_annot_string_func || pagePtr == 0) return nullptr;
|
||||||
|
|
||||||
void* page = reinterpret_cast<void*>(pagePtr);
|
void* page = reinterpret_cast<void*>(pagePtr);
|
||||||
|
|
@ -294,24 +300,23 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotString(JNIEnv *env, jclass
|
||||||
|
|
||||||
extern "C" JNIEXPORT jint JNICALL
|
extern "C" JNIEXPORT jint JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
|
||||||
if (!init_pdfium() || !count_objects_func) return 0;
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
|
if (!init_pdfium() || !count_objects_func || pagePtr == 0) return 0;
|
||||||
return count_objects_func(reinterpret_cast<void*>(pagePtr));
|
return count_objects_func(reinterpret_cast<void*>(pagePtr));
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" JNIEXPORT jint JNICALL
|
extern "C" JNIEXPORT jint JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectType(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectType(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
|
||||||
if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || pagePtr == 0 || index < 0) return 0;
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
|
if (!init_pdfium() || !get_object_func || !get_object_type_func || pagePtr == 0 || index < 0) return 0;
|
||||||
if (index >= object_count) return 0;
|
|
||||||
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
|
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
|
||||||
return obj ? get_object_type_func(obj) : 0;
|
return obj ? get_object_type_func(obj) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" JNIEXPORT jboolean JNICALL
|
extern "C" JNIEXPORT jboolean JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jfloatArray outRect) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jfloatArray outRect) {
|
||||||
if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_bounds_func || pagePtr == 0 || index < 0 || outRect == nullptr) return JNI_FALSE;
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
|
if (!init_pdfium() || !get_object_func || !get_object_bounds_func || pagePtr == 0 || index < 0 || outRect == nullptr) return JNI_FALSE;
|
||||||
if (index >= object_count) return JNI_FALSE;
|
|
||||||
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
|
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
|
||||||
if (!obj) return JNI_FALSE;
|
if (!obj) return JNI_FALSE;
|
||||||
|
|
||||||
|
|
@ -326,9 +331,13 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *en
|
||||||
|
|
||||||
extern "C" JNIEXPORT jintArray JNICALL
|
extern "C" JNIEXPORT jintArray JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jintArray dimens) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jintArray dimens) {
|
||||||
if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || !get_image_bitmap_func || !bitmap_get_buffer_func || pagePtr == 0 || index < 0 || dimens == nullptr) return nullptr;
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
|
if (!init_pdfium() || !get_object_func || !get_object_type_func || !get_image_bitmap_func ||
|
||||||
if (index >= object_count) return nullptr;
|
!bitmap_get_width_func || !bitmap_get_height_func || !bitmap_get_stride_func ||
|
||||||
|
!bitmap_get_buffer_func || !bitmap_destroy_func ||
|
||||||
|
pagePtr == 0 || index < 0 || dimens == nullptr) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
|
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
|
||||||
if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE
|
if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE
|
||||||
|
|
@ -379,6 +388,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jcl
|
||||||
|
|
||||||
extern "C" JNIEXPORT jboolean JNICALL
|
extern "C" JNIEXPORT jboolean JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jclass clazz) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jclass clazz) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
init_pdfium();
|
init_pdfium();
|
||||||
// Return true if we have ANY way to handle actions
|
// Return true if we have ANY way to handle actions
|
||||||
return (do_annot_action_func || get_link_action_func) ? JNI_TRUE : JNI_FALSE;
|
return (do_annot_action_func || get_link_action_func) ? JNI_TRUE : JNI_FALSE;
|
||||||
|
|
@ -386,6 +396,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_checkActionSupport(JNIEnv *env, jcl
|
||||||
|
|
||||||
extern "C" JNIEXPORT jint JNICALL
|
extern "C" JNIEXPORT jint JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return -1;
|
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return -1;
|
||||||
|
|
||||||
void* page = reinterpret_cast<void*>(pagePtr);
|
void* page = reinterpret_cast<void*>(pagePtr);
|
||||||
|
|
@ -412,6 +423,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtypeAtPoint(JNIEnv *env,
|
||||||
|
|
||||||
extern "C" JNIEXPORT jfloatArray JNICALL
|
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || !get_annot_count_func || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
|
if (!init_pdfium() || !get_annot_count_func || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
|
||||||
|
|
||||||
void* page = reinterpret_cast<void*>(pagePtr);
|
void* page = reinterpret_cast<void*>(pagePtr);
|
||||||
|
|
@ -432,13 +444,14 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRectAtPoint(JNIEnv *env, jc
|
||||||
|
|
||||||
extern "C" JNIEXPORT jint JNICALL
|
extern "C" JNIEXPORT jint JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotCount(JNIEnv *env, jclass clazz, jlong pagePtr) {
|
||||||
std::lock_guard<std::mutex> lock(g_pdfium_mutex);
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0;
|
if (!init_pdfium() || !get_annot_count_func || pagePtr == 0) return 0;
|
||||||
return get_annot_count_func(reinterpret_cast<void*>(pagePtr));
|
return get_annot_count_func(reinterpret_cast<void*>(pagePtr));
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" JNIEXPORT jint JNICALL
|
extern "C" JNIEXPORT jint JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func || pagePtr == 0) return 0;
|
if (!init_pdfium() || !get_annot_func || !get_annot_subtype_func || pagePtr == 0) return 0;
|
||||||
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
|
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
|
||||||
return annot ? get_annot_subtype_func(annot) : 0;
|
return annot ? get_annot_subtype_func(annot) : 0;
|
||||||
|
|
@ -446,6 +459,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotSubtype(JNIEnv *env, jclass
|
||||||
|
|
||||||
extern "C" JNIEXPORT jfloatArray JNICALL
|
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
|
||||||
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
|
if (!init_pdfium() || !get_annot_func || !get_annot_rect_func || pagePtr == 0) return nullptr;
|
||||||
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
|
void* annot = get_annot_func(reinterpret_cast<void*>(pagePtr), index);
|
||||||
if (!annot) return nullptr;
|
if (!annot) return nullptr;
|
||||||
|
|
@ -460,7 +474,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getAnnotRect(JNIEnv *env, jclass cl
|
||||||
|
|
||||||
extern "C" JNIEXPORT jboolean JNICALL
|
extern "C" JNIEXPORT jboolean JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass clazz, jlong pagePtr, jdouble x, jdouble y) {
|
||||||
std::lock_guard<std::mutex> lock(g_pdfium_mutex);
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium() || pagePtr == 0) return JNI_FALSE;
|
if (!init_pdfium() || pagePtr == 0) return JNI_FALSE;
|
||||||
|
|
||||||
void* page = reinterpret_cast<void*>(pagePtr);
|
void* page = reinterpret_cast<void*>(pagePtr);
|
||||||
|
|
@ -527,7 +541,7 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_performClick(JNIEnv *env, jclass cl
|
||||||
|
|
||||||
extern "C" JNIEXPORT jstring JNICALL
|
extern "C" JNIEXPORT jstring JNICALL
|
||||||
Java_com_aryan_reader_pdf_NativePdfiumBridge_getLinkInfoAtPoint(JNIEnv *env, jclass clazz, jlong docPtr, jlong pagePtr, jdouble x, jdouble y) {
|
Java_com_aryan_reader_pdf_NativePdfiumBridge_getLinkInfoAtPoint(JNIEnv *env, jclass clazz, jlong docPtr, jlong pagePtr, jdouble x, jdouble y) {
|
||||||
std::lock_guard<std::mutex> lock(g_pdfium_mutex);
|
std::lock_guard<std::recursive_mutex> lock(g_pdfium_mutex);
|
||||||
if (!init_pdfium()) {
|
if (!init_pdfium()) {
|
||||||
LOGE("PdfLinkDiagnostic: init_pdfium failed.");
|
LOGE("PdfLinkDiagnostic: init_pdfium failed.");
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
|
|
||||||
314
app/src/main/java/com/aryan/reader/AiSettingsScreen.kt
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AiSettingsScreen(
|
||||||
|
onBackClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var settings by remember { mutableStateOf(loadAiByokSettings(context)) }
|
||||||
|
var selectedProvider by remember { mutableStateOf("gemini") }
|
||||||
|
var providerMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
var pendingKey by remember { mutableStateOf("") }
|
||||||
|
var showSaveConfirm by remember { mutableStateOf(false) }
|
||||||
|
var providerToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
settings = loadAiByokSettings(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateModels(newSettings: AiByokSettings) {
|
||||||
|
saveAiByokSettings(context, newSettings)
|
||||||
|
settings = loadAiByokSettings(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.statusBarsPadding(),
|
||||||
|
topBar = {
|
||||||
|
CustomTopAppBar(
|
||||||
|
title = { Text("AI keys and models") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBackClick) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
SavedKeyRow("Gemini", maskedAiByokKey(context, "gemini"), onDelete = { providerToDelete = "gemini" })
|
||||||
|
SavedKeyRow("Groq", maskedAiByokKey(context, "groq"), onDelete = { providerToDelete = "groq" })
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = providerMenuExpanded,
|
||||||
|
onExpandedChange = { providerMenuExpanded = it },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedProvider.replaceFirstChar { it.titlecase() },
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Provider") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = providerMenuExpanded) },
|
||||||
|
modifier = Modifier.fillMaxWidth().menuAnchor()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = providerMenuExpanded,
|
||||||
|
onDismissRequest = { providerMenuExpanded = false }
|
||||||
|
) {
|
||||||
|
listOf("gemini", "groq").forEach { provider ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(provider.replaceFirstChar { it.titlecase() }) },
|
||||||
|
onClick = {
|
||||||
|
selectedProvider = provider
|
||||||
|
providerMenuExpanded = false
|
||||||
|
},
|
||||||
|
trailingIcon = if (provider == selectedProvider) {
|
||||||
|
{ Icon(Icons.Default.Check, contentDescription = null) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pendingKey,
|
||||||
|
onValueChange = { pendingKey = it },
|
||||||
|
label = { Text("API key") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { showSaveConfirm = true },
|
||||||
|
enabled = pendingKey.isNotBlank(),
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Text("Save key")
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Use one model for all features", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"When off, each reader AI feature uses its own selected model.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = settings.useOneModel,
|
||||||
|
onCheckedChange = { updateModels(settings.copy(useOneModel = it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.useOneModel) {
|
||||||
|
ModelSelector(
|
||||||
|
title = "All AI features",
|
||||||
|
description = "Smart dictionary, summaries, and recaps all use this model.",
|
||||||
|
selectedId = settings.modelForAll,
|
||||||
|
onSelected = { updateModels(settings.copy(modelForAll = it)) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ModelSelector(
|
||||||
|
title = "Smart dictionary",
|
||||||
|
description = "Used when defining selected words or phrases.",
|
||||||
|
selectedId = settings.defineModel,
|
||||||
|
onSelected = { updateModels(settings.copy(defineModel = it)) }
|
||||||
|
)
|
||||||
|
ModelSelector(
|
||||||
|
title = "Summaries",
|
||||||
|
description = "Used for EPUB summaries and PDF page summaries. PDF/image summaries need Gemini.",
|
||||||
|
selectedId = settings.summarizeModel,
|
||||||
|
onSelected = { updateModels(settings.copy(summarizeModel = it)) }
|
||||||
|
)
|
||||||
|
ModelSelector(
|
||||||
|
title = "Recaps",
|
||||||
|
description = "Used for story recap generation.",
|
||||||
|
selectedId = settings.recapModel,
|
||||||
|
onSelected = { updateModels(settings.copy(recapModel = it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelSelector(
|
||||||
|
title = "Cloud TTS",
|
||||||
|
description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.",
|
||||||
|
selectedId = settings.ttsModel,
|
||||||
|
options = listOf(AiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)),
|
||||||
|
onSelected = { updateModels(settings.copy(ttsModel = it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSaveConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showSaveConfirm = false },
|
||||||
|
title = { Text("Save ${selectedProvider.replaceFirstChar { it.titlecase() }} key?") },
|
||||||
|
text = { Text("After saving, only the first 3 and last 3 characters will be visible. To change it later, replace or delete it.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
saveAiByokKey(context, selectedProvider, pendingKey)
|
||||||
|
pendingKey = ""
|
||||||
|
showSaveConfirm = false
|
||||||
|
refresh()
|
||||||
|
}) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showSaveConfirm = false }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
providerToDelete?.let { provider ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { providerToDelete = null },
|
||||||
|
title = { Text("Delete ${provider.replaceFirstChar { it.titlecase() }} key?") },
|
||||||
|
text = { Text("Features using this provider will stop working until a new key is saved.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
deleteAiByokKey(context, provider)
|
||||||
|
providerToDelete = null
|
||||||
|
refresh()
|
||||||
|
}) { Text("Delete") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { providerToDelete = null }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SavedKeyRow(
|
||||||
|
label: String,
|
||||||
|
maskedKey: String,
|
||||||
|
onDelete: () -> Unit
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(label) },
|
||||||
|
supportingContent = {
|
||||||
|
Text(maskedKey.ifBlank { "No key saved" })
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
IconButton(onClick = onDelete, enabled = maskedKey.isNotBlank()) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = "Delete $label key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ModelSelector(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
selectedId: String,
|
||||||
|
options: List<AiModelOption> = aiByokModelOptions,
|
||||||
|
onSelected: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val selected = options.firstOrNull { it.id == selectedId }
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selected?.label ?: "No model selected",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Model") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier.fillMaxWidth().menuAnchor()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("No model selected") },
|
||||||
|
onClick = {
|
||||||
|
onSelected("")
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
trailingIcon = if (selectedId.isBlank()) {
|
||||||
|
{ Icon(Icons.Default.Check, contentDescription = null) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
options.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(option.label) },
|
||||||
|
onClick = {
|
||||||
|
onSelected(option.id)
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
trailingIcon = if (option.id == selected?.id) {
|
||||||
|
{ Icon(Icons.Default.Check, contentDescription = null) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class AndroidFolderPathResolver : FolderPathResolver {
|
||||||
|
override fun relativeFolderSegments(item: RecentFileItem): List<String> {
|
||||||
|
val documentUriString = item.uriString ?: return emptyList()
|
||||||
|
val rootFolderUriString = item.sourceFolderUri ?: return emptyList()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val documentUri = documentUriString.toUri()
|
||||||
|
val rootFolderUri = rootFolderUriString.toUri()
|
||||||
|
val rootDocId = rootFolderUri.treeDocumentIdOrNull() ?: return emptyList()
|
||||||
|
val documentId = documentUri.documentIdOrNull() ?: return emptyList()
|
||||||
|
|
||||||
|
val rootPath = rootDocId.substringAfter(':', "")
|
||||||
|
val documentPath = documentId.substringAfter(':', "")
|
||||||
|
val relativeDocumentPath = when {
|
||||||
|
rootPath.isBlank() -> documentPath
|
||||||
|
documentPath == rootPath -> ""
|
||||||
|
documentPath.startsWith("$rootPath/") -> documentPath.removePrefix("$rootPath/")
|
||||||
|
else -> documentPath
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeDocumentPath
|
||||||
|
.substringBeforeLast('/', "")
|
||||||
|
.split('/')
|
||||||
|
.map { Uri.decode(it).trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("FolderShelves").w(e, "Failed to derive relative folder path for ${item.displayName}")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Uri.treeDocumentIdOrNull(): String? {
|
||||||
|
val segments = pathSegments
|
||||||
|
val treeIndex = segments.indexOf("tree")
|
||||||
|
return segments.getOrNull(treeIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Uri.documentIdOrNull(): String? {
|
||||||
|
val segments = pathSegments
|
||||||
|
val documentIndex = segments.indexOf("document")
|
||||||
|
if (documentIndex >= 0) {
|
||||||
|
return segments.getOrNull(documentIndex + 1)
|
||||||
|
}
|
||||||
|
return treeDocumentIdOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,13 +43,18 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import com.aryan.reader.epubreader.EpubReaderScreen
|
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.pdf.PdfViewerScreen
|
import com.aryan.reader.pdf.PdfViewerScreen
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
object AppDestinations {
|
object AppDestinations {
|
||||||
const val MAIN_ROUTE = "main"
|
const val MAIN_ROUTE = "main"
|
||||||
|
|
@ -57,19 +62,74 @@ object AppDestinations {
|
||||||
const val EPUB_READER_ROUTE = "epub_reader"
|
const val EPUB_READER_ROUTE = "epub_reader"
|
||||||
const val PRO_SCREEN_ROUTE = "pro_screen"
|
const val PRO_SCREEN_ROUTE = "pro_screen"
|
||||||
const val FEEDBACK_SCREEN_ROUTE = "feedback_screen_route"
|
const val FEEDBACK_SCREEN_ROUTE = "feedback_screen_route"
|
||||||
|
const val SUPPORT_PROJECT_SCREEN_ROUTE = "support_project_screen_route"
|
||||||
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
|
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
|
||||||
|
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NavHostController.isReadyForBackStackChange(): Boolean {
|
||||||
|
return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun NavHostController.awaitReadyForBackStackChange() {
|
||||||
|
while (!isReadyForBackStackChange()) {
|
||||||
|
delay(32)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun NavHostController.navigateSingleTopTo(route: String) {
|
private fun NavHostController.navigateSingleTopTo(route: String) {
|
||||||
|
if (!isReadyForBackStackChange()) {
|
||||||
|
Timber.d("Skipping navigation to $route because the current entry is not resumed yet.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
navigate(route) {
|
navigate(route) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
restoreState = true
|
|
||||||
popUpTo(graph.startDestinationId) {
|
popUpTo(graph.startDestinationId) {
|
||||||
saveState = true
|
saveState = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
Timber.w(e, "Navigation to $route ignored because the back stack is mid-transition.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NavHostController.navigateToMain() {
|
||||||
|
navigateSingleTopTo(AppDestinations.MAIN_ROUTE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NavHostController.navigateIfReady(route: String) {
|
||||||
|
if (currentDestination?.route == route) return
|
||||||
|
navigateSingleTopTo(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NavHostController.popBackStackIfReady(): Boolean {
|
||||||
|
if (!isReadyForBackStackChange()) {
|
||||||
|
Timber.d("Skipping popBackStack because the current entry is not resumed yet.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
popBackStack()
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
Timber.w(e, "popBackStack ignored because the back stack is mid-transition.")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun NavHostController.syncRouteTo(route: String) {
|
||||||
|
awaitReadyForBackStackChange()
|
||||||
|
if (currentDestination?.route != route) {
|
||||||
|
if (route == AppDestinations.MAIN_ROUTE) {
|
||||||
|
navigateToMain()
|
||||||
|
} else {
|
||||||
|
navigateSingleTopTo(route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@androidx.annotation.OptIn(UnstableApi::class)
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -80,35 +140,32 @@ fun AppNavigation(
|
||||||
) {
|
) {
|
||||||
Timber.d("AppNavigation composable invoked.")
|
Timber.d("AppNavigation composable invoked.")
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = currentBackStackEntry?.destination?.route
|
||||||
|
|
||||||
LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
||||||
if (!uiState.isLoading) {
|
if (!uiState.isLoading) {
|
||||||
try {
|
|
||||||
when (uiState.selectedFileType) {
|
when (uiState.selectedFileType) {
|
||||||
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
|
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
|
||||||
if (uiState.selectedPdfUri != null) {
|
if (uiState.selectedPdfUri != null) {
|
||||||
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
|
if (currentRoute != AppDestinations.PDF_VIEWER_ROUTE) {
|
||||||
navController.navigateSingleTopTo(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 -> {
|
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> {
|
||||||
if (uiState.selectedEpubBook != null) {
|
if (uiState.selectedEpubBook != null) {
|
||||||
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
|
if (currentRoute != AppDestinations.EPUB_READER_ROUTE) {
|
||||||
navController.navigateSingleTopTo(AppDestinations.EPUB_READER_ROUTE)
|
navController.syncRouteTo(AppDestinations.EPUB_READER_ROUTE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
null -> {
|
null -> {
|
||||||
val currentRoute = navController.currentBackStackEntry?.destination?.route
|
if (currentRoute == AppDestinations.PDF_VIEWER_ROUTE || currentRoute == AppDestinations.EPUB_READER_ROUTE) {
|
||||||
if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) {
|
navController.syncRouteTo(AppDestinations.MAIN_ROUTE)
|
||||||
navController.navigateSingleTopTo(AppDestinations.MAIN_ROUTE)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
Timber.w(e, "Navigation transition already in progress, ignoring.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,7 +210,7 @@ fun AppNavigation(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNavigateToPro = {
|
onNavigateToPro = {
|
||||||
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
|
navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE)
|
||||||
},
|
},
|
||||||
viewModel = viewModel
|
viewModel = viewModel
|
||||||
)
|
)
|
||||||
|
|
@ -226,7 +283,7 @@ fun AppNavigation(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNavigateToPro = {
|
onNavigateToPro = {
|
||||||
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
|
navController.navigateIfReady(AppDestinations.PRO_SCREEN_ROUTE)
|
||||||
},
|
},
|
||||||
onRenderModeChange = viewModel::setRenderMode,
|
onRenderModeChange = viewModel::setRenderMode,
|
||||||
customFonts = customFonts,
|
customFonts = customFonts,
|
||||||
|
|
@ -276,7 +333,7 @@ fun AppNavigation(
|
||||||
composable(route = AppDestinations.PRO_SCREEN_ROUTE) {
|
composable(route = AppDestinations.PRO_SCREEN_ROUTE) {
|
||||||
ProScreen(
|
ProScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() }
|
onNavigateBack = { navController.popBackStackIfReady() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -286,10 +343,22 @@ fun AppNavigation(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(route = AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) {
|
||||||
|
SupportProjectScreen(
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
composable(route = AppDestinations.FONTS_SCREEN_ROUTE) {
|
composable(route = AppDestinations.FONTS_SCREEN_ROUTE) {
|
||||||
FontsScreen(
|
FontsScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onBackClick = { navController.popBackStack() }
|
onBackClick = { navController.popBackStackIfReady() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(route = AppDestinations.AI_SETTINGS_SCREEN_ROUTE) {
|
||||||
|
AiSettingsScreen(
|
||||||
|
onBackClick = { navController.popBackStackIfReady() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
app/src/main/java/com/aryan/reader/AppUiModels.kt
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import com.aryan.reader.data.TagEntity
|
||||||
|
import com.aryan.reader.epub.CalibreBundleResult
|
||||||
|
import com.aryan.reader.epub.EpubBook
|
||||||
|
import com.aryan.reader.paginatedreader.Locator
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false)
|
||||||
|
|
||||||
|
data class ImportResult(
|
||||||
|
val internalUri: Uri,
|
||||||
|
val bookId: String,
|
||||||
|
val type: FileType,
|
||||||
|
val bundleResult: CalibreBundleResult? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserData(
|
||||||
|
val uid: String,
|
||||||
|
val displayName: String?,
|
||||||
|
val photoUrl: String?,
|
||||||
|
val email: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class NavigationEvent(
|
||||||
|
val route: String,
|
||||||
|
val bookId: String? = null,
|
||||||
|
val uri: Uri? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class AppThemeMode {
|
||||||
|
SYSTEM,
|
||||||
|
LIGHT,
|
||||||
|
DARK
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AppContrastOption(val value: Double) {
|
||||||
|
STANDARD(0.0),
|
||||||
|
MEDIUM(0.5),
|
||||||
|
HIGH(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CustomAppTheme(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val seedColor: androidx.compose.ui.graphics.Color
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?)
|
||||||
|
|
||||||
|
data class DeviceLimitReachedState(
|
||||||
|
val isLimitReached: Boolean = false,
|
||||||
|
val registeredDevices: List<DeviceItem> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReaderScreenState(
|
||||||
|
val selectedPdfUri: Uri? = null,
|
||||||
|
val selectedBookId: String? = null,
|
||||||
|
val selectedEpubBook: EpubBook? = null,
|
||||||
|
val selectedEpubUri: Uri? = null,
|
||||||
|
val selectedFileType: FileType? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val contextualActionItems: Set<RecentFileItem> = emptySet(),
|
||||||
|
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
|
||||||
|
val sortOrder: SortOrder = SortOrder.RECENT,
|
||||||
|
val initialLocator: Locator? = null,
|
||||||
|
val initialCfi: String? = null,
|
||||||
|
val initialBookmarksJson: String? = null,
|
||||||
|
val initialHighlightsJson: String? = null,
|
||||||
|
val initialPageInBook: Int? = null,
|
||||||
|
val shelves: List<Shelf> = emptyList(),
|
||||||
|
val viewingShelfId: String? = null,
|
||||||
|
val isAddingBooksToShelf: Boolean = false,
|
||||||
|
val showCreateShelfDialog: Boolean = false,
|
||||||
|
val mainScreenStartPage: Int = 0,
|
||||||
|
val libraryScreenStartPage: Int = 0,
|
||||||
|
val showRenameShelfDialogFor: String? = null,
|
||||||
|
val showDeleteShelfDialogFor: String? = null,
|
||||||
|
val addBooksSource: AddBooksSource = AddBooksSource.UNSHELVED,
|
||||||
|
val booksSelectedForAdding: Set<String> = emptySet(),
|
||||||
|
val booksAvailableForAdding: List<RecentFileItem> = emptyList(),
|
||||||
|
val contextualActionShelfIds: Set<String> = emptySet(),
|
||||||
|
val currentUser: UserData? = null,
|
||||||
|
val isAuthMenuExpanded: Boolean = false,
|
||||||
|
val isProUser: Boolean = false,
|
||||||
|
val credits: Int = 0,
|
||||||
|
val isSyncEnabled: Boolean = false,
|
||||||
|
val isFolderSyncEnabled: Boolean = false,
|
||||||
|
val bannerMessage: BannerMessage? = null,
|
||||||
|
val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(),
|
||||||
|
val isReplacingDevice: Boolean = false,
|
||||||
|
val isRequestingDrivePermission: Boolean = false,
|
||||||
|
val downloadingBookIds: Set<String> = emptySet(),
|
||||||
|
val uploadingBookIds: Set<String> = emptySet(),
|
||||||
|
val syncedFolders: List<SyncedFolder> = emptyList(),
|
||||||
|
val lastFolderScanTime: Long? = null,
|
||||||
|
val hasUnreadFeedback: Boolean = false,
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val isSearchActive: Boolean = false,
|
||||||
|
val isRefreshing: Boolean = false,
|
||||||
|
val reflowProgress: Float? = null,
|
||||||
|
val recentFiles: List<RecentFileItem> = emptyList(),
|
||||||
|
val allRecentFiles: List<RecentFileItem> = emptyList(),
|
||||||
|
val rawLibraryFiles: List<RecentFileItem> = emptyList(),
|
||||||
|
val pinnedHomeBookIds: Set<String> = emptySet(),
|
||||||
|
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
||||||
|
val libraryFilters: LibraryFilters = LibraryFilters(),
|
||||||
|
val recentFilesLimit: Int = 0,
|
||||||
|
val isTabsEnabled: Boolean = false,
|
||||||
|
val openTabIds: List<String> = emptyList(),
|
||||||
|
val openTabs: List<RecentFileItem> = emptyList(),
|
||||||
|
val activeTabBookId: String? = null,
|
||||||
|
val showExternalFileSavePromptFor: String? = null,
|
||||||
|
val externalFileBehavior: String = "ASK",
|
||||||
|
val useStrictFileFilter: Boolean = false,
|
||||||
|
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
|
||||||
|
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
|
||||||
|
val appTextDimFactorLight: Float = 1.0f,
|
||||||
|
val appTextDimFactorDark: Float = 1.0f,
|
||||||
|
val appSeedColor: androidx.compose.ui.graphics.Color? = null,
|
||||||
|
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||||
|
val allTags: List<TagEntity> = emptyList(),
|
||||||
|
val showTagSelectionDialogFor: Set<String> = emptySet(),
|
||||||
|
)
|
||||||
94
app/src/main/java/com/aryan/reader/FileTypeResolver.kt
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
private val codeOrDataExtensions = setOf(
|
||||||
|
"csv",
|
||||||
|
"tsv",
|
||||||
|
"json",
|
||||||
|
"xml",
|
||||||
|
"log",
|
||||||
|
"java",
|
||||||
|
"kt",
|
||||||
|
"py",
|
||||||
|
"js",
|
||||||
|
"cpp",
|
||||||
|
"c",
|
||||||
|
"cs",
|
||||||
|
"rb",
|
||||||
|
"go"
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
|
||||||
|
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val effectiveName = lowerName.withTransparentTextSuffix()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
effectiveName.endsWith(".cbz") -> FileType.CBZ
|
||||||
|
effectiveName.endsWith(".cbr") -> FileType.CBR
|
||||||
|
effectiveName.endsWith(".cb7") -> FileType.CB7
|
||||||
|
effectiveName.endsWith(".pdf") -> FileType.PDF
|
||||||
|
effectiveName.endsWith(".epub") -> FileType.EPUB
|
||||||
|
effectiveName.endsWith(".mobi") || effectiveName.endsWith(".azw3") || effectiveName.endsWith(".prc") -> FileType.MOBI
|
||||||
|
effectiveName.endsWith(".fb2") || effectiveName.endsWith(".fb2.zip") -> FileType.FB2
|
||||||
|
effectiveName.endsWith(".md") || effectiveName.endsWith(".markdown") -> FileType.MD
|
||||||
|
effectiveName.endsWith(".html") || effectiveName.endsWith(".xhtml") || effectiveName.endsWith(".htm") -> FileType.HTML
|
||||||
|
effectiveName.endsWith(".docx") -> FileType.DOCX
|
||||||
|
effectiveName.endsWith(".odt") -> FileType.ODT
|
||||||
|
effectiveName.endsWith(".fodt") -> FileType.FODT
|
||||||
|
effectiveName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML
|
||||||
|
effectiveName.endsWith(".txt") -> FileType.TXT
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isCodeOrDataFileName(fileName: String): Boolean {
|
||||||
|
return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? {
|
||||||
|
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val effectiveName = lowerName.withTransparentTextSuffix()
|
||||||
|
val effectiveSuffix = when {
|
||||||
|
effectiveName.endsWith(".fb2.zip") -> ".fb2.zip"
|
||||||
|
effectiveName.endsWith(".markdown") -> ".markdown"
|
||||||
|
effectiveName.endsWith(".xhtml") -> ".xhtml"
|
||||||
|
effectiveName.extensionAfterLastDot() != null && resolveFileTypeFromName(effectiveName) != null -> ".${effectiveName.extensionAfterLastDot()}"
|
||||||
|
else -> null
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
return if (effectiveName != lowerName && lowerName.endsWith(".txt")) {
|
||||||
|
"$effectiveSuffix.txt"
|
||||||
|
} else {
|
||||||
|
effectiveSuffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.withTransparentTextSuffix(): String {
|
||||||
|
if (!endsWith(".txt")) return this
|
||||||
|
val innerName = removeSuffix(".txt")
|
||||||
|
if (innerName.isBlank() || !innerName.contains('.')) return this
|
||||||
|
return if (resolveFileTypeFromNameWithoutTransparentText(innerName) != null) innerName else this
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveFileTypeFromNameWithoutTransparentText(fileName: String): FileType? {
|
||||||
|
return when {
|
||||||
|
fileName.endsWith(".cbz") -> FileType.CBZ
|
||||||
|
fileName.endsWith(".cbr") -> FileType.CBR
|
||||||
|
fileName.endsWith(".cb7") -> FileType.CB7
|
||||||
|
fileName.endsWith(".pdf") -> FileType.PDF
|
||||||
|
fileName.endsWith(".epub") -> FileType.EPUB
|
||||||
|
fileName.endsWith(".mobi") || fileName.endsWith(".azw3") || fileName.endsWith(".prc") -> FileType.MOBI
|
||||||
|
fileName.endsWith(".fb2") || fileName.endsWith(".fb2.zip") -> FileType.FB2
|
||||||
|
fileName.endsWith(".md") || fileName.endsWith(".markdown") -> FileType.MD
|
||||||
|
fileName.endsWith(".html") || fileName.endsWith(".xhtml") || fileName.endsWith(".htm") -> FileType.HTML
|
||||||
|
fileName.endsWith(".docx") -> FileType.DOCX
|
||||||
|
fileName.endsWith(".odt") -> FileType.ODT
|
||||||
|
fileName.endsWith(".fodt") -> FileType.FODT
|
||||||
|
fileName.extensionAfterLastDot() in codeOrDataExtensions -> FileType.HTML
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.extensionAfterLastDot(): String? {
|
||||||
|
val dotIndex = lastIndexOf('.')
|
||||||
|
return if (dotIndex in 0..<lastIndex) substring(dotIndex + 1) else null
|
||||||
|
}
|
||||||
|
|
@ -24,11 +24,11 @@ import android.content.Context
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
|
||||||
import androidx.work.ExistingWorkPolicy
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
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 kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -53,11 +53,15 @@ class FolderSyncWorker(
|
||||||
const val WORK_NAME = "FolderSyncWorker"
|
const val WORK_NAME = "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"
|
||||||
|
private const val SCAN_DB_BATCH_SIZE = 600
|
||||||
private val syncMutex = Mutex()
|
private val syncMutex = Mutex()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
|
val workerStart = ReaderPerfLog.nowNanos()
|
||||||
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
|
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
|
||||||
|
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
|
||||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
val 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)
|
||||||
|
|
@ -87,17 +91,31 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (folders.isEmpty()) {
|
if (folders.isEmpty()) {
|
||||||
Timber.tag("FolderSync").w("Worker: No folders linked. Aborting.")
|
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag("FolderSync").d("Worker: processing ${folders.size} folders.")
|
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
|
||||||
|
folders
|
||||||
|
} else {
|
||||||
|
folders.filter { it.first == targetFolderUri }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foldersToProcess.isEmpty()) {
|
||||||
|
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked target=$targetFolderUri")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"FolderSync worker start folders=${foldersToProcess.size}/${folders.size} " +
|
||||||
|
"target=${targetFolderUri ?: "ALL"} metadataOnly=$isMetadataOnly"
|
||||||
|
)
|
||||||
|
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
syncMutex.withLock {
|
syncMutex.withLock {
|
||||||
var allSuccess = true
|
var allSuccess = true
|
||||||
|
|
||||||
for ((uriString, allowedTypes) in folders) {
|
for ((uriString, allowedTypes) in foldersToProcess) {
|
||||||
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
|
val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
|
||||||
if (!success) allSuccess = false
|
if (!success) allSuccess = false
|
||||||
}
|
}
|
||||||
|
|
@ -107,12 +125,21 @@ class FolderSyncWorker(
|
||||||
val array = org.json.JSONArray(jsonString)
|
val array = org.json.JSONArray(jsonString)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
for (i in 0 until array.length()) {
|
for (i in 0 until array.length()) {
|
||||||
array.getJSONObject(i).put("lastScanTime", now)
|
val obj = array.getJSONObject(i)
|
||||||
|
if (targetFolderUri.isNullOrBlank() || obj.optString("uri") == targetFolderUri) {
|
||||||
|
obj.put("lastScanTime", now)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prefs.edit { putString("synced_folders_list_json", array.toString()) }
|
prefs.edit { putString("synced_folders_list_json", array.toString()) }
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val elapsed = ReaderPerfLog.elapsedMs(workerStart)
|
||||||
|
ReaderPerfLog.i(
|
||||||
|
"FolderSync worker finished status=${if (allSuccess) "success" else "failure"} " +
|
||||||
|
"folders=${foldersToProcess.size} elapsed=${elapsed}ms"
|
||||||
|
)
|
||||||
|
|
||||||
if (allSuccess) Result.success() else Result.failure()
|
if (allSuccess) Result.success() else Result.failure()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,8 +148,24 @@ class FolderSyncWorker(
|
||||||
private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
|
private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set<FileType>, metadataOnly: Boolean): Boolean {
|
||||||
if (folderUriString.isBlank()) return true
|
if (folderUriString.isBlank()) return true
|
||||||
val folderUri = folderUriString.toUri()
|
val folderUri = folderUriString.toUri()
|
||||||
|
val folderStart = ReaderPerfLog.nowNanos()
|
||||||
|
var dirsScanned = 0
|
||||||
|
var filesSeen = 0
|
||||||
|
var supportedBooksSeen = 0
|
||||||
|
var newBooks = 0
|
||||||
|
var updatedBooks = 0
|
||||||
|
var unchangedBooks = 0
|
||||||
|
var dbFlushes = 0
|
||||||
|
var scanDbFlushes = 0
|
||||||
|
var sidecarsImported = 0
|
||||||
|
var stoppedForUnlinkedFolder = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!isFolderStillLinked(folderUriString)) {
|
||||||
|
ReaderPerfLog.w("FolderSync folder skipped: no longer linked folder=$folderUriString")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
appContext.contentResolver.takePersistableUriPermission(
|
appContext.contentResolver.takePersistableUriPermission(
|
||||||
folderUri,
|
folderUri,
|
||||||
|
|
@ -137,17 +180,31 @@ class FolderSyncWorker(
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag("FolderSync").d("Phase 0: Migrating legacy root sidecars to subfolder...")
|
ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration skipped")
|
||||||
LocalSyncUtils.migrateLegacySidecarsToSubfolder(appContext, documentTree)
|
|
||||||
|
|
||||||
Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...")
|
val folderMetadataMap = ReaderPerfLog.measureSuspend(
|
||||||
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
|
name = "FolderSync phase metadata-sidecars",
|
||||||
|
minLogMs = 25L,
|
||||||
|
details = { "metadataOnly=$metadataOnly" }
|
||||||
|
) {
|
||||||
|
LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
|
||||||
|
}
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString"
|
||||||
|
)
|
||||||
|
|
||||||
Timber.tag("FolderSync").d("Phase 1.5: Preloading annotation sidecars...")
|
val preloadedSidecars = mutableMapOf<String, Pair<Long, String>>()
|
||||||
val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree).toMutableMap()
|
val existingFolderBooks = ReaderPerfLog.measureSuspend(
|
||||||
|
name = "FolderSync phase load-existing-db",
|
||||||
|
minLogMs = 25L
|
||||||
|
) {
|
||||||
|
recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||||
|
}
|
||||||
|
val existingFolderBooksById = existingFolderBooks.associateBy { it.bookId }
|
||||||
|
val remoteMetadataUpdates = mutableListOf<RecentFileItem>()
|
||||||
|
|
||||||
folderMetadataMap.forEach { (bookId, remoteMeta) ->
|
folderMetadataMap.forEach { (bookId, remoteMeta) ->
|
||||||
val existingItem = recentFilesRepository.getFileByBookId(bookId)
|
val existingItem = existingFolderBooksById[bookId]
|
||||||
|
|
||||||
if (existingItem != null) {
|
if (existingItem != null) {
|
||||||
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
|
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
|
||||||
|
|
@ -166,40 +223,28 @@ class FolderSyncWorker(
|
||||||
isRecent = remoteMeta.isRecent || existingItem.isRecent,
|
isRecent = remoteMeta.isRecent || existingItem.isRecent,
|
||||||
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
|
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
|
||||||
)
|
)
|
||||||
recentFilesRepository.addRecentFile(itemToUpdate)
|
remoteMetadataUpdates.add(itemToUpdate)
|
||||||
} else {
|
} else {
|
||||||
Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}")
|
Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag("FolderAnnotationSync").d("Phase 1.5: Checking annotation sidecars for existing local books...")
|
if (remoteMetadataUpdates.isNotEmpty()) {
|
||||||
val processedBookIds = mutableSetOf<String>()
|
recentFilesRepository.addRecentFiles(remoteMetadataUpdates)
|
||||||
val existingFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
dbFlushes++
|
||||||
|
ReaderPerfLog.d(
|
||||||
for (book in existingFolderBooks) {
|
"FolderSync applied remote metadata updates count=${remoteMetadataUpdates.size} folder=$folderUriString"
|
||||||
processedBookIds.add(book.bookId)
|
|
||||||
|
|
||||||
val sidecarData = preloadedSidecars[book.bookId]
|
|
||||||
|
|
||||||
if (sidecarData != null) {
|
|
||||||
val (remoteTs, jsonPayload) = sidecarData
|
|
||||||
|
|
||||||
val localFiles = listOf(
|
|
||||||
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
|
|
||||||
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
|
|
||||||
File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"),
|
|
||||||
File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json")
|
|
||||||
)
|
)
|
||||||
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
|
}
|
||||||
|
|
||||||
if (remoteTs > (localTs + 1000)) {
|
if (metadataOnly) {
|
||||||
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
|
sidecarsImported += importAnnotationSidecarsForBooks(
|
||||||
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
|
folderUri = folderUri,
|
||||||
} else {
|
folderUriString = folderUriString,
|
||||||
Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.")
|
books = existingFolderBooks,
|
||||||
}
|
phase = "metadata-only"
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metadataOnly) {
|
if (!metadataOnly) {
|
||||||
|
|
@ -207,7 +252,17 @@ class FolderSyncWorker(
|
||||||
val contentResolver = appContext.contentResolver
|
val contentResolver = appContext.contentResolver
|
||||||
val foundBookIds = mutableSetOf<String>()
|
val foundBookIds = mutableSetOf<String>()
|
||||||
val newOrUpdatedItems = mutableListOf<RecentFileItem>()
|
val newOrUpdatedItems = mutableListOf<RecentFileItem>()
|
||||||
val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
|
val existingItemsMap = existingFolderBooksById.toMutableMap()
|
||||||
|
val existingItemsByUri = existingFolderBooks
|
||||||
|
.mapNotNull { item -> item.uriString?.let { uri -> uri to item } }
|
||||||
|
.toMap()
|
||||||
|
val legacyItemsByName = existingFolderBooks
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.bookId.startsWith("local_${it.displayName}_") }
|
||||||
|
.groupBy { it.displayName }
|
||||||
|
.mapValues { entry ->
|
||||||
|
ArrayDeque<RecentFileItem>().apply { addAll(entry.value) }
|
||||||
|
}
|
||||||
|
|
||||||
val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
|
val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
|
||||||
val dirQueue = ArrayDeque<String>()
|
val dirQueue = ArrayDeque<String>()
|
||||||
|
|
@ -223,7 +278,13 @@ class FolderSyncWorker(
|
||||||
|
|
||||||
while (dirQueue.isNotEmpty()) {
|
while (dirQueue.isNotEmpty()) {
|
||||||
if (isStopped) break
|
if (isStopped) break
|
||||||
|
if (!isFolderStillLinked(folderUriString)) {
|
||||||
|
ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString")
|
||||||
|
stoppedForUnlinkedFolder = true
|
||||||
|
break
|
||||||
|
}
|
||||||
val currentDocId = dirQueue.removeFirst()
|
val currentDocId = dirQueue.removeFirst()
|
||||||
|
dirsScanned++
|
||||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
|
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -234,10 +295,17 @@ class FolderSyncWorker(
|
||||||
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
|
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
|
||||||
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
|
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
|
||||||
|
|
||||||
while (cursor.moveToNext() && !isStopped) {
|
while (cursor.moveToNext() && !isStopped && !stoppedForUnlinkedFolder) {
|
||||||
val docId = cursor.getString(idCol)
|
val docId = cursor.getString(idCol)
|
||||||
val name = cursor.getString(nameCol) ?: ""
|
val name = cursor.getString(nameCol) ?: ""
|
||||||
val mimeType = cursor.getString(mimeCol)
|
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 (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||||
if (!name.startsWith(".") && name != "EpistemeSyncData") {
|
if (!name.startsWith(".") && name != "EpistemeSyncData") {
|
||||||
|
|
@ -249,13 +317,15 @@ class FolderSyncWorker(
|
||||||
|
|
||||||
val type = getFileType(name, mimeType)
|
val type = getFileType(name, mimeType)
|
||||||
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
|
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
|
||||||
|
supportedBooksSeen++
|
||||||
val stableId = buildStableBookId(name, rootDocId, docId)
|
val stableId = buildStableBookId(name, rootDocId, docId)
|
||||||
foundBookIds.add(stableId)
|
foundBookIds.add(stableId)
|
||||||
|
|
||||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
|
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
|
||||||
|
val docUriString = docUri.toString()
|
||||||
var existingItem = existingItemsMap[stableId]
|
var existingItem = existingItemsMap[stableId]
|
||||||
|
|
||||||
if (existingItem != null && existingItem.uriString != docUri.toString()) {
|
if (existingItem != null && existingItem.uriString != docUriString) {
|
||||||
val collidedItem = existingItem
|
val collidedItem = existingItem
|
||||||
val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId)
|
val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId)
|
||||||
if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) {
|
if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) {
|
||||||
|
|
@ -273,11 +343,9 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingItem == null) {
|
if (existingItem == null) {
|
||||||
val oldItem = existingItemsMap.values.find {
|
val oldItem = existingItemsByUri[docUriString]?.takeIf { it.bookId != stableId }
|
||||||
it.bookId != stableId && (
|
?: legacyItemsByName[name]?.firstOrNull {
|
||||||
it.uriString == docUri.toString() ||
|
it.bookId != stableId
|
||||||
it.bookId.startsWith("local_${name}_")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (oldItem != null) {
|
if (oldItem != null) {
|
||||||
val oldId = oldItem.bookId
|
val oldId = oldItem.bookId
|
||||||
|
|
@ -291,6 +359,7 @@ class FolderSyncWorker(
|
||||||
preloadedSidecars = preloadedSidecars,
|
preloadedSidecars = preloadedSidecars,
|
||||||
existingItemsMap = existingItemsMap
|
existingItemsMap = existingItemsMap
|
||||||
)
|
)
|
||||||
|
legacyItemsByName[name]?.remove(oldItem)
|
||||||
existingItem = existingItemsMap[stableId]
|
existingItem = existingItemsMap[stableId]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -324,6 +393,7 @@ class FolderSyncWorker(
|
||||||
fileSize = size
|
fileSize = size
|
||||||
)
|
)
|
||||||
newOrUpdatedItems.add(newItem)
|
newOrUpdatedItems.add(newItem)
|
||||||
|
newBooks++
|
||||||
} else {
|
} else {
|
||||||
var needsUpdate = false
|
var needsUpdate = false
|
||||||
var updatedItem = existingItem
|
var updatedItem = existingItem
|
||||||
|
|
@ -331,7 +401,11 @@ class FolderSyncWorker(
|
||||||
if (existingItem.fileSize > 0L && size > 0L && existingItem.fileSize != size) {
|
if (existingItem.fileSize > 0L && size > 0L && existingItem.fileSize != size) {
|
||||||
Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).")
|
Timber.tag("FolderSync").i("File size changed for $name (${existingItem.fileSize} -> $size).")
|
||||||
recentFilesRepository.clearLocalCachesForBook(stableId)
|
recentFilesRepository.clearLocalCachesForBook(stableId)
|
||||||
updatedItem = updatedItem.copy(fileSize = size, lastModifiedTimestamp = lastModified)
|
updatedItem = updatedItem.copy(
|
||||||
|
fileSize = size,
|
||||||
|
lastModifiedTimestamp = lastModified,
|
||||||
|
folderTextMetadataParsed = false
|
||||||
|
)
|
||||||
needsUpdate = true
|
needsUpdate = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,32 +421,26 @@ class FolderSyncWorker(
|
||||||
|
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
newOrUpdatedItems.add(updatedItem)
|
newOrUpdatedItems.add(updatedItem)
|
||||||
|
updatedBooks++
|
||||||
|
} else {
|
||||||
|
unchangedBooks++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newOrUpdatedItems.size >= 50) {
|
val batchLimit = if (scanDbFlushes == 0) 40 else SCAN_DB_BATCH_SIZE
|
||||||
|
if (newOrUpdatedItems.size >= batchLimit) {
|
||||||
|
if (!isFolderStillLinked(folderUriString)) {
|
||||||
|
ReaderPerfLog.w("FolderSync batch dropped: folder unlinked pending=${newOrUpdatedItems.size} folder=$folderUriString")
|
||||||
|
newOrUpdatedItems.clear()
|
||||||
|
stoppedForUnlinkedFolder = true
|
||||||
|
break
|
||||||
|
}
|
||||||
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
|
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
|
||||||
|
dbFlushes++
|
||||||
|
scanDbFlushes++
|
||||||
newOrUpdatedItems.clear()
|
newOrUpdatedItems.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!processedBookIds.contains(stableId)) {
|
|
||||||
val sidecarData = preloadedSidecars[stableId]
|
|
||||||
if (sidecarData != null) {
|
|
||||||
val (remoteTs, jsonPayload) = sidecarData
|
|
||||||
val localFiles = listOf(
|
|
||||||
File(appContext.filesDir, "annotations/annotation_$stableId.json"),
|
|
||||||
File(appContext.filesDir, "pdf_rich_text/text_$stableId.json"),
|
|
||||||
File(appContext.filesDir, "page_layouts/layout_$stableId.json"),
|
|
||||||
File(appContext.filesDir, "pdf_text_boxes/boxes_$stableId.json")
|
|
||||||
)
|
|
||||||
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
|
|
||||||
|
|
||||||
if (remoteTs > (localTs + 1000)) {
|
|
||||||
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for new book $stableId. Importing.")
|
|
||||||
recentFilesRepository.importAnnotationBundle(stableId, jsonPayload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -380,16 +448,19 @@ class FolderSyncWorker(
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
|
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stoppedForUnlinkedFolder) break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newOrUpdatedItems.isNotEmpty()) {
|
if (!stoppedForUnlinkedFolder && newOrUpdatedItems.isNotEmpty()) {
|
||||||
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
|
recentFilesRepository.addRecentFiles(newOrUpdatedItems)
|
||||||
|
dbFlushes++
|
||||||
|
scanDbFlushes++
|
||||||
newOrUpdatedItems.clear()
|
newOrUpdatedItems.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStopped) {
|
if (!isStopped && !stoppedForUnlinkedFolder) {
|
||||||
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
val idsToRemove = existingItemsMap.keys.filter { it !in foundBookIds }
|
||||||
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
|
|
||||||
|
|
||||||
if (idsToRemove.isNotEmpty()) {
|
if (idsToRemove.isNotEmpty()) {
|
||||||
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
|
Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.")
|
||||||
|
|
@ -398,14 +469,48 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStopped) {
|
if (!metadataOnly && !isStopped && !stoppedForUnlinkedFolder) {
|
||||||
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
|
val booksForAnnotationSync = ReaderPerfLog.measureSuspend(
|
||||||
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
|
name = "FolderSync phase load-post-scan-db",
|
||||||
|
minLogMs = 25L
|
||||||
|
) {
|
||||||
|
recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||||
|
}
|
||||||
|
sidecarsImported += importAnnotationSidecarsForBooks(
|
||||||
|
folderUri = folderUri,
|
||||||
|
folderUriString = folderUriString,
|
||||||
|
books = booksForAnnotationSync,
|
||||||
|
phase = "post-scan"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val elapsed = ReaderPerfLog.elapsedMs(folderStart)
|
||||||
|
ReaderPerfLog.i(
|
||||||
|
"FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " +
|
||||||
|
"dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " +
|
||||||
|
"new=$newBooks updated=$updatedBooks unchanged=$unchangedBooks " +
|
||||||
|
"dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " +
|
||||||
|
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
|
||||||
|
if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
|
||||||
|
ReaderPerfLog.i("FolderSync enqueue text metadata extraction folder=$folderUriString")
|
||||||
|
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
|
||||||
|
.setInputData(
|
||||||
|
androidx.work.Data.Builder()
|
||||||
|
.putString(MetadataExtractionWorker.KEY_SOURCE_FOLDER_URI, folderUriString)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
||||||
MetadataExtractionWorker.WORK_NAME,
|
MetadataExtractionWorker.WORK_NAME,
|
||||||
ExistingWorkPolicy.APPEND_OR_REPLACE,
|
ExistingWorkPolicy.REPLACE,
|
||||||
metaRequest
|
metaRequest
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
ReaderPerfLog.d("FolderSync text metadata extraction skipped: no pending books folder=$folderUriString")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -416,23 +521,87 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun importAnnotationSidecarsForBooks(
|
||||||
|
folderUri: android.net.Uri,
|
||||||
|
folderUriString: String,
|
||||||
|
books: List<RecentFileItem>,
|
||||||
|
phase: String
|
||||||
|
): Int {
|
||||||
|
if (books.isEmpty()) {
|
||||||
|
ReaderPerfLog.d("FolderSync phase annotation-sidecars skipped phase=$phase reason=no-books folder=$folderUriString")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val preloadedSidecars = ReaderPerfLog.measureSuspend(
|
||||||
|
name = "FolderSync phase annotation-sidecars",
|
||||||
|
minLogMs = 25L,
|
||||||
|
details = { "phase=$phase" }
|
||||||
|
) {
|
||||||
|
LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"FolderSync annotation-sidecars records=${preloadedSidecars.size} books=${books.size} phase=$phase folder=$folderUriString"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (preloadedSidecars.isEmpty()) return 0
|
||||||
|
|
||||||
|
var imported = 0
|
||||||
|
Timber.tag("FolderAnnotationSync").d("Checking annotation sidecars phase=$phase for ${books.size} books...")
|
||||||
|
for (book in books) {
|
||||||
|
if (isStopped || !isFolderStillLinked(folderUriString)) break
|
||||||
|
|
||||||
|
val sidecarData = preloadedSidecars[book.bookId] ?: continue
|
||||||
|
val (remoteTs, jsonPayload) = sidecarData
|
||||||
|
|
||||||
|
val localFiles = listOf(
|
||||||
|
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
|
||||||
|
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
|
||||||
|
File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"),
|
||||||
|
File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json")
|
||||||
|
)
|
||||||
|
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
|
||||||
|
|
||||||
|
if (remoteTs > (localTs + 1000)) {
|
||||||
|
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
|
||||||
|
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
|
||||||
|
imported++
|
||||||
|
} else {
|
||||||
|
Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReaderPerfLog.i(
|
||||||
|
"FolderSync annotation-sidecars imported=$imported records=${preloadedSidecars.size} phase=$phase folder=$folderUriString"
|
||||||
|
)
|
||||||
|
return imported
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isFolderStillLinked(folderUriString: String): Boolean {
|
||||||
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
val jsonString = prefs.getString("synced_folders_list_json", null)
|
||||||
|
if (jsonString != null) {
|
||||||
|
return try {
|
||||||
|
val array = org.json.JSONArray(jsonString)
|
||||||
|
(0 until array.length()).any { index ->
|
||||||
|
array.getJSONObject(index).optString("uri") == folderUriString
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefs.getString("synced_folder_uri", null) == folderUriString
|
||||||
|
}
|
||||||
|
|
||||||
private fun getFileType(name: String, mimeType: String?): FileType? {
|
private fun getFileType(name: String, mimeType: String?): FileType? {
|
||||||
val lowerName = name.lowercase()
|
return when (mimeType) {
|
||||||
return when {
|
"application/pdf" -> FileType.PDF
|
||||||
mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF
|
"application/epub+zip" -> FileType.EPUB
|
||||||
mimeType == "application/epub+zip" || lowerName.endsWith(".epub") -> FileType.EPUB
|
"application/vnd.oasis.opendocument.text" -> FileType.ODT
|
||||||
mimeType == "application/vnd.oasis.opendocument.text" || lowerName.endsWith(".odt") -> FileType.ODT
|
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
|
||||||
mimeType == "application/x-vnd.oasis.opendocument.text-flat-xml" || lowerName.endsWith(".fodt") -> FileType.FODT
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
|
||||||
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || lowerName.endsWith(".docx") -> FileType.DOCX
|
"text/html", "application/xhtml+xml" -> FileType.HTML
|
||||||
lowerName.endsWith(".mobi") || lowerName.endsWith(".azw3") || lowerName.endsWith(".prc") -> FileType.MOBI
|
else -> resolveFileTypeFromName(name)
|
||||||
lowerName.endsWith(".fb2") || lowerName.endsWith(".fb2.zip") -> FileType.FB2
|
|
||||||
lowerName.endsWith(".cbz") -> FileType.CBZ
|
|
||||||
lowerName.endsWith(".cbr") -> FileType.CBR
|
|
||||||
lowerName.endsWith(".cb7") -> FileType.CB7
|
|
||||||
lowerName.endsWith(".md") || lowerName.endsWith(".markdown") -> FileType.MD
|
|
||||||
lowerName.endsWith(".txt") -> FileType.TXT
|
|
||||||
mimeType == "text/html" || lowerName.endsWith(".html") || lowerName.endsWith(".xhtml") || lowerName.endsWith(".htm") -> FileType.HTML
|
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
import androidx.compose.material.icons.filled.VerifiedUser
|
import androidx.compose.material.icons.filled.VerifiedUser
|
||||||
import androidx.compose.material.icons.outlined.AccountCircle
|
import androidx.compose.material.icons.outlined.AccountCircle
|
||||||
|
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.BadgedBox
|
import androidx.compose.material3.BadgedBox
|
||||||
|
|
@ -134,6 +135,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
|
|
@ -151,11 +153,17 @@ internal fun Context.findActivity(): Activity? = when (this) {
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
|
@androidx.annotation.OptIn(UnstableApi::class)
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
viewModel: MainViewModel, windowSizeClass: WindowSizeClass, navController: NavHostController
|
viewModel: MainViewModel, windowSizeClass: WindowSizeClass, navController: NavHostController
|
||||||
) {
|
) {
|
||||||
|
val compStart = remember { System.currentTimeMillis() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
ReaderPerfLog.d("HomeScreen initial composition ${System.currentTimeMillis() - compStart}ms")
|
||||||
|
}
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val customTabUriHandler = remember { CustomTabUriHandler(context) }
|
val customTabUriHandler = remember { CustomTabUriHandler(context) }
|
||||||
var showCloseAllTabsDialog by remember { mutableStateOf(false) }
|
var showCloseAllTabsDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -163,14 +171,15 @@ fun HomeScreen(
|
||||||
|
|
||||||
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
|
CompositionLocalProvider(LocalUriHandler provides customTabUriHandler) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val recentFilesForHome = uiState.recentFiles.filter { it.isRecent }
|
val screenModel = remember(uiState) { uiState.toHomeScreenModel() }
|
||||||
val openTabs = uiState.openTabs
|
val recentFilesForHome = screenModel.recentFiles
|
||||||
val selectedContextItems = uiState.contextualActionItems
|
val openTabs = screenModel.openTabs
|
||||||
val isContextualModeActive = selectedContextItems.isNotEmpty()
|
val selectedContextItems = screenModel.selectedItems
|
||||||
|
val isContextualModeActive = screenModel.isContextualModeActive
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val deviceLimitState = uiState.deviceLimitState
|
val deviceLimitState = screenModel.deviceLimitState
|
||||||
|
|
||||||
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
||||||
var showClearCloudDataDialog by remember { mutableStateOf(false) }
|
var showClearCloudDataDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -304,6 +313,12 @@ fun HomeScreen(
|
||||||
navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE)
|
navController.navigate(AppDestinations.FONTS_SCREEN_ROUTE)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onAiSettingsClick = {
|
||||||
|
scope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
navController.navigate(AppDestinations.AI_SETTINGS_SCREEN_ROUTE)
|
||||||
|
}
|
||||||
|
},
|
||||||
navController = navController,
|
navController = navController,
|
||||||
onFolderSyncToggle = viewModel::setFolderSyncEnabled
|
onFolderSyncToggle = viewModel::setFolderSyncEnabled
|
||||||
)
|
)
|
||||||
|
|
@ -341,7 +356,10 @@ fun HomeScreen(
|
||||||
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
|
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
|
||||||
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
|
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
|
||||||
onLanguageClick = { showLanguageDialog = true },
|
onLanguageClick = { showLanguageDialog = true },
|
||||||
onExportLogsClick = { viewModel.exportLogsToFile(context) }
|
onExportLogsClick = { viewModel.exportLogsToFile(context) },
|
||||||
|
onToggleHideReaderAi = {
|
||||||
|
saveHideReaderAiFeatures(context, !loadHideReaderAiFeatures(context))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ContextualTopAppBar(
|
ContextualTopAppBar(
|
||||||
|
|
@ -367,8 +385,8 @@ fun HomeScreen(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
if (recentFilesForHome.isEmpty() && (!uiState.isTabsEnabled || openTabs.isEmpty())) {
|
if (screenModel.isEmpty) {
|
||||||
if (uiState.recentFiles.isEmpty()) {
|
if (screenModel.isLibraryEmpty) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = stringResource(R.string.your_library_empty),
|
title = stringResource(R.string.your_library_empty),
|
||||||
message = stringResource(R.string.your_library_empty_desc),
|
message = stringResource(R.string.your_library_empty_desc),
|
||||||
|
|
@ -528,7 +546,8 @@ fun HomeScreen(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onThemeModeChanged = viewModel::setAppThemeMode,
|
onThemeModeChanged = viewModel::setAppThemeMode,
|
||||||
onContrastOptionChanged = viewModel::setAppContrastOption,
|
onContrastOptionChanged = viewModel::setAppContrastOption,
|
||||||
onTextDimFactorChanged = viewModel::setAppTextDimFactor,
|
onTextDimFactorLightChanged = viewModel::setAppTextDimFactorLight,
|
||||||
|
onTextDimFactorDarkChanged = viewModel::setAppTextDimFactorDark,
|
||||||
onSeedColorChanged = viewModel::setAppSeedColor,
|
onSeedColorChanged = viewModel::setAppSeedColor,
|
||||||
onCustomThemeAdded = viewModel::addCustomAppTheme,
|
onCustomThemeAdded = viewModel::addCustomAppTheme,
|
||||||
onCustomThemeDeleted = viewModel::deleteCustomAppTheme,
|
onCustomThemeDeleted = viewModel::deleteCustomAppTheme,
|
||||||
|
|
@ -596,6 +615,9 @@ private fun RecentFilesContent(
|
||||||
hasSyncedFolder: Boolean
|
hasSyncedFolder: Boolean
|
||||||
) {
|
) {
|
||||||
val canRefresh = isSyncEnabled || hasSyncedFolder
|
val canRefresh = isSyncEnabled || hasSyncedFolder
|
||||||
|
val selectedItemUris = remember(selectedContextItems) {
|
||||||
|
selectedContextItems.mapNotNullTo(mutableSetOf()) { it.uriString }
|
||||||
|
}
|
||||||
|
|
||||||
val content = @Composable {
|
val content = @Composable {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|
@ -608,7 +630,7 @@ private fun RecentFilesContent(
|
||||||
isTabsEnabled = isTabsEnabled,
|
isTabsEnabled = isTabsEnabled,
|
||||||
onTabCloseClick = onTabCloseClick,
|
onTabCloseClick = onTabCloseClick,
|
||||||
onCloseAllTabsClick = onCloseAllTabsClick,
|
onCloseAllTabsClick = onCloseAllTabsClick,
|
||||||
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
|
selectedItemUris = selectedItemUris,
|
||||||
pinnedHomeBookIds = pinnedHomeBookIds,
|
pinnedHomeBookIds = pinnedHomeBookIds,
|
||||||
onItemClick = onItemClick,
|
onItemClick = onItemClick,
|
||||||
onItemLongClick = onItemLongClick,
|
onItemLongClick = onItemLongClick,
|
||||||
|
|
@ -996,10 +1018,13 @@ fun DefaultTopAppBar(
|
||||||
onTestPanelDetectionClick: () -> Unit,
|
onTestPanelDetectionClick: () -> Unit,
|
||||||
onTestSpeechBubbleDetectionClick: () -> Unit,
|
onTestSpeechBubbleDetectionClick: () -> Unit,
|
||||||
onLanguageClick: () -> Unit,
|
onLanguageClick: () -> Unit,
|
||||||
onExportLogsClick: () -> Unit
|
onExportLogsClick: () -> Unit,
|
||||||
|
onToggleHideReaderAi: () -> Unit
|
||||||
) {
|
) {
|
||||||
var showOptionsMenu by remember { mutableStateOf(false) }
|
var showOptionsMenu by remember { mutableStateOf(false) }
|
||||||
var showLimitMenu by remember { mutableStateOf(false) }
|
var showLimitMenu by remember { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
var hideReaderAiFeatures by remember { mutableStateOf(loadHideReaderAiFeatures(context)) }
|
||||||
|
|
||||||
CustomTopAppBar(title = { }, navigationIcon = {
|
CustomTopAppBar(title = { }, navigationIcon = {
|
||||||
IconButton(onClick = onDrawerClick) {
|
IconButton(onClick = onDrawerClick) {
|
||||||
|
|
@ -1086,6 +1111,20 @@ fun DefaultTopAppBar(
|
||||||
showOptionsMenu = false
|
showOptionsMenu = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(if (hideReaderAiFeatures) "Show AI in reader" else "Hide AI in reader") },
|
||||||
|
onClick = {
|
||||||
|
onToggleHideReaderAi()
|
||||||
|
hideReaderAiFeatures = !hideReaderAiFeatures
|
||||||
|
showOptionsMenu = false
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (hideReaderAiFeatures) {
|
||||||
|
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
|
DropdownMenuItem(text = { Text(stringResource(R.string.options_clear_book_cache)) }, onClick = {
|
||||||
onClearCache()
|
onClearCache()
|
||||||
|
|
@ -1141,6 +1180,7 @@ private fun AppDrawerContent(
|
||||||
onUpgradeClick: () -> Unit,
|
onUpgradeClick: () -> Unit,
|
||||||
onSyncUpsellClick: () -> Unit,
|
onSyncUpsellClick: () -> Unit,
|
||||||
onFontsClick: () -> Unit,
|
onFontsClick: () -> Unit,
|
||||||
|
onAiSettingsClick: () -> Unit,
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
onFolderSyncToggle: (Boolean) -> Unit
|
onFolderSyncToggle: (Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
@ -1311,6 +1351,26 @@ private fun AppDrawerContent(
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (isOss && !BuildConfig.IS_OFFLINE) {
|
||||||
|
NavigationDrawerItem(
|
||||||
|
icon = { Icon(painterResource(id = R.drawable.ai), contentDescription = null) },
|
||||||
|
label = { Text("AI keys and models") },
|
||||||
|
selected = false,
|
||||||
|
onClick = onAiSettingsClick,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOss) {
|
||||||
|
NavigationDrawerItem(
|
||||||
|
icon = { Icon(Icons.Outlined.FavoriteBorder, contentDescription = null) },
|
||||||
|
label = { Text(stringResource(R.string.drawer_support_project)) },
|
||||||
|
selected = false,
|
||||||
|
onClick = { navController.navigate(AppDestinations.SUPPORT_PROJECT_SCREEN_ROUTE) },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = { Icon(painterResource(id = R.drawable.feedback), contentDescription = null) },
|
icon = { Icon(painterResource(id = R.drawable.feedback), contentDescription = null) },
|
||||||
label = { Text(stringResource(R.string.drawer_help_feedback)) },
|
label = { Text(stringResource(R.string.drawer_help_feedback)) },
|
||||||
|
|
@ -1692,7 +1752,8 @@ fun AppThemeBottomSheet(
|
||||||
uiState: ReaderScreenState,
|
uiState: ReaderScreenState,
|
||||||
onThemeModeChanged: (AppThemeMode) -> Unit,
|
onThemeModeChanged: (AppThemeMode) -> Unit,
|
||||||
onContrastOptionChanged: (AppContrastOption) -> Unit,
|
onContrastOptionChanged: (AppContrastOption) -> Unit,
|
||||||
onTextDimFactorChanged: (Float) -> Unit,
|
onTextDimFactorLightChanged: (Float) -> Unit,
|
||||||
|
onTextDimFactorDarkChanged: (Float) -> Unit,
|
||||||
onSeedColorChanged: (Color?) -> Unit,
|
onSeedColorChanged: (Color?) -> Unit,
|
||||||
onCustomThemeAdded: (CustomAppTheme) -> Unit,
|
onCustomThemeAdded: (CustomAppTheme) -> Unit,
|
||||||
onCustomThemeDeleted: (String) -> Unit,
|
onCustomThemeDeleted: (String) -> Unit,
|
||||||
|
|
@ -1756,6 +1817,49 @@ fun AppThemeBottomSheet(
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
if (uiState.appThemeMode == AppThemeMode.SYSTEM) {
|
||||||
|
Text("${stringResource(R.string.app_theme_text_brightness)} (Light)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp))
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
|
||||||
|
androidx.compose.material3.Slider(
|
||||||
|
value = uiState.appTextDimFactorLight,
|
||||||
|
onValueChange = onTextDimFactorLightChanged,
|
||||||
|
valueRange = 0.3f..1.0f,
|
||||||
|
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text("${stringResource(R.string.app_theme_text_brightness)} (Dark)", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.RoundedCornerShape(24.dp))
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
|
||||||
|
androidx.compose.material3.Slider(
|
||||||
|
value = uiState.appTextDimFactorDark,
|
||||||
|
onValueChange = onTextDimFactorDarkChanged,
|
||||||
|
valueRange = 0.3f..1.0f,
|
||||||
|
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
Text(stringResource(R.string.app_theme_text_brightness), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
Text(stringResource(R.string.app_theme_text_brightness), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -1768,13 +1872,14 @@ fun AppThemeBottomSheet(
|
||||||
) {
|
) {
|
||||||
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
|
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
|
||||||
androidx.compose.material3.Slider(
|
androidx.compose.material3.Slider(
|
||||||
value = uiState.appTextDimFactor,
|
value = if (uiState.appThemeMode == AppThemeMode.DARK) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight,
|
||||||
onValueChange = onTextDimFactorChanged,
|
onValueChange = if (uiState.appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged,
|
||||||
valueRange = 0.3f..1.0f,
|
valueRange = 0.3f..1.0f,
|
||||||
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
|
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
|
Text("A", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 1.0f))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
|
|
||||||
86
app/src/main/java/com/aryan/reader/LibraryModels.kt
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
|
||||||
|
enum class AddBooksSource {
|
||||||
|
UNSHELVED,
|
||||||
|
ALL_BOOKS
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class FileType {
|
||||||
|
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7)
|
||||||
|
|
||||||
|
internal val EPUB_READER_FILE_TYPES = setOf(
|
||||||
|
FileType.EPUB,
|
||||||
|
FileType.MOBI,
|
||||||
|
FileType.MD,
|
||||||
|
FileType.TXT,
|
||||||
|
FileType.HTML,
|
||||||
|
FileType.FB2,
|
||||||
|
FileType.DOCX,
|
||||||
|
FileType.ODT,
|
||||||
|
FileType.FODT
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class RenderMode {
|
||||||
|
VERTICAL_SCROLL, PAGINATED
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SyncedFolder(
|
||||||
|
val uriString: String,
|
||||||
|
val name: String,
|
||||||
|
val lastScanTime: Long,
|
||||||
|
val allowedFileTypes: Set<FileType> = FileType.entries.toSet()
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER }
|
||||||
|
|
||||||
|
data class Shelf(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val type: ShelfType,
|
||||||
|
val books: List<RecentFileItem>,
|
||||||
|
val directBooks: List<RecentFileItem> = books,
|
||||||
|
val parentShelfId: String? = null,
|
||||||
|
val childShelfIds: List<String> = emptyList(),
|
||||||
|
val depth: Int = 0,
|
||||||
|
val sortKey: String = name.lowercase()
|
||||||
|
) {
|
||||||
|
val bookCount: Int get() = books.size
|
||||||
|
val topBook: RecentFileItem? by lazy(LazyThreadSafetyMode.NONE) { books.maxByOrNull { it.timestamp } }
|
||||||
|
val directBookCount: Int get() = directBooks.size
|
||||||
|
val childShelfCount: Int get() = childShelfIds.size
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SortOrder {
|
||||||
|
RECENT,
|
||||||
|
TITLE_ASC,
|
||||||
|
AUTHOR_ASC,
|
||||||
|
PERCENT_ASC,
|
||||||
|
PERCENT_DESC,
|
||||||
|
SIZE_ASC,
|
||||||
|
SIZE_DESC
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ReadStatusFilter {
|
||||||
|
ALL,
|
||||||
|
UNREAD,
|
||||||
|
IN_PROGRESS,
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LibraryFilters(
|
||||||
|
val fileTypes: Set<FileType> = emptySet(),
|
||||||
|
val sourceFolders: Set<String> = emptySet(),
|
||||||
|
val readStatus: ReadStatusFilter = ReadStatusFilter.ALL,
|
||||||
|
val tagIds: Set<String> = emptySet()
|
||||||
|
) {
|
||||||
|
val isActive: Boolean
|
||||||
|
get() = fileTypes.isNotEmpty() ||
|
||||||
|
sourceFolders.isNotEmpty() ||
|
||||||
|
readStatus != ReadStatusFilter.ALL ||
|
||||||
|
tagIds.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,7 @@ import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PagerDefaults
|
||||||
import androidx.compose.foundation.pager.PagerState
|
import androidx.compose.foundation.pager.PagerState
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
|
@ -131,6 +132,7 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
|
@ -141,6 +143,8 @@ 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
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -154,20 +158,26 @@ private fun getBookCountString(count: Int): String {
|
||||||
return pluralStringResource(id = R.plurals.book_count, count, count)
|
return pluralStringResource(id = R.plurals.book_count, count, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
@SuppressLint("LocalContextGetResourceValueCall")
|
@SuppressLint("LocalContextGetResourceValueCall")
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(
|
fun LibraryScreen(
|
||||||
viewModel: MainViewModel,
|
viewModel: MainViewModel,
|
||||||
) {
|
) {
|
||||||
|
val compStart = remember { System.currentTimeMillis() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
ReaderPerfLog.d("LibraryScreen initial composition ${System.currentTimeMillis() - compStart}ms")
|
||||||
|
}
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val selectedItems = uiState.contextualActionItems
|
val screenModel = remember(uiState) { uiState.toLibraryScreenModel() }
|
||||||
val isContextualModeActive = selectedItems.isNotEmpty()
|
val selectedItems = screenModel.selectedItems
|
||||||
val selectedShelves = uiState.contextualActionShelfIds
|
val isContextualModeActive = screenModel.isContextualModeActive
|
||||||
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
val selectedShelves = screenModel.selectedShelves
|
||||||
val sortOrder = uiState.sortOrder
|
val isShelfContextualModeActive = screenModel.isShelfContextualModeActive
|
||||||
val shelves = uiState.shelves
|
val sortOrder = screenModel.sortOrder
|
||||||
val rawLibraryFiles = uiState.rawLibraryFiles
|
val shelves = screenModel.shelves
|
||||||
|
val rawLibraryFiles = screenModel.rawLibraryFiles
|
||||||
val tabTitles = remember {
|
val tabTitles = remember {
|
||||||
buildList {
|
buildList {
|
||||||
add(context.getString(R.string.tab_all_books))
|
add(context.getString(R.string.tab_all_books))
|
||||||
|
|
@ -183,21 +193,13 @@ fun LibraryScreen(
|
||||||
pageCount = { tabTitles.size }
|
pageCount = { tabTitles.size }
|
||||||
)
|
)
|
||||||
|
|
||||||
val containsFolderItems = remember(selectedItems) {
|
val containsFolderItems = screenModel.containsFolderItemsInSelection
|
||||||
selectedItems.any { it.sourceFolderUri != null }
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(uiState.libraryScreenStartPage) {
|
|
||||||
if (pagerState.currentPage != uiState.libraryScreenStartPage) {
|
|
||||||
pagerState.animateScrollToPage(uiState.libraryScreenStartPage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showFilterSheet by remember { mutableStateOf(false) }
|
var showFilterSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val isSearchActive = uiState.isSearchActive
|
val isSearchActive = screenModel.isSearchActive
|
||||||
val searchQuery = uiState.searchQuery
|
val searchQuery = screenModel.searchQuery
|
||||||
|
|
||||||
val pickFolderLauncher = rememberLauncherForActivityResult(
|
val pickFolderLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocumentTree()
|
contract = ActivityResultContracts.OpenDocumentTree()
|
||||||
|
|
@ -250,6 +252,8 @@ fun LibraryScreen(
|
||||||
|
|
||||||
LaunchedEffect(pagerState) {
|
LaunchedEffect(pagerState) {
|
||||||
androidx.compose.runtime.snapshotFlow { pagerState.settledPage }
|
androidx.compose.runtime.snapshotFlow { pagerState.settledPage }
|
||||||
|
.drop(1)
|
||||||
|
.distinctUntilChanged()
|
||||||
.collect { page ->
|
.collect { page ->
|
||||||
viewModel.setLibraryScreenPage(page)
|
viewModel.setLibraryScreenPage(page)
|
||||||
}
|
}
|
||||||
|
|
@ -401,6 +405,7 @@ fun LibraryScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
@Composable
|
@Composable
|
||||||
fun ShelfScreen(
|
fun ShelfScreen(
|
||||||
viewModel: MainViewModel,
|
viewModel: MainViewModel,
|
||||||
|
|
@ -577,6 +582,7 @@ fun LibraryScreenContent(
|
||||||
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
||||||
var showSortMenu by remember { mutableStateOf(false) }
|
var showSortMenu by remember { mutableStateOf(false) }
|
||||||
val searchFocusRequester = remember { FocusRequester() }
|
val searchFocusRequester = remember { FocusRequester() }
|
||||||
|
val selectedBookIds = remember(selectedItems) { selectedItems.mapTo(mutableSetOf()) { it.bookId } }
|
||||||
|
|
||||||
var textFieldValue by remember(isSearchActive) {
|
var textFieldValue by remember(isSearchActive) {
|
||||||
mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
|
mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
|
||||||
|
|
@ -712,7 +718,16 @@ fun LibraryScreenContent(
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == index,
|
selected = pagerState.currentPage == index,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch { pagerState.animateScrollToPage(index) }
|
ReaderPerfLog.d("LibraryPager click page=$index title=$title")
|
||||||
|
if (pagerState.currentPage != index) {
|
||||||
|
scope.launch {
|
||||||
|
val start = ReaderPerfLog.nowNanos()
|
||||||
|
pagerState.animateScrollToPage(index)
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"LibraryPager settled page=$index elapsed=${ReaderPerfLog.elapsedMs(start)}ms"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
text = { Text(title) }
|
text = { Text(title) }
|
||||||
)
|
)
|
||||||
|
|
@ -798,6 +813,11 @@ fun LibraryScreenContent(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues),
|
.padding(paddingValues),
|
||||||
|
flingBehavior = PagerDefaults.flingBehavior(
|
||||||
|
state = pagerState,
|
||||||
|
snapPositionalThreshold = 0.25f
|
||||||
|
),
|
||||||
|
beyondViewportPageCount = 0,
|
||||||
key = { it }
|
key = { it }
|
||||||
) { page ->
|
) { page ->
|
||||||
when (page) {
|
when (page) {
|
||||||
|
|
@ -822,7 +842,7 @@ fun LibraryScreenContent(
|
||||||
items(recentFiles, key = { it.bookId }) { item ->
|
items(recentFiles, key = { it.bookId }) { item ->
|
||||||
LibraryListItem(
|
LibraryListItem(
|
||||||
item = item,
|
item = item,
|
||||||
isSelected = selectedItems.any { it.bookId == item.bookId },
|
isSelected = item.bookId in selectedBookIds,
|
||||||
isPinned = item.bookId in pinnedLibraryBookIds,
|
isPinned = item.bookId in pinnedLibraryBookIds,
|
||||||
onItemClick = { onItemClick(item) },
|
onItemClick = { onItemClick(item) },
|
||||||
onItemLongClick = { onItemLongClick(item) },
|
onItemLongClick = { onItemLongClick(item) },
|
||||||
|
|
@ -1876,6 +1896,18 @@ private fun FolderSyncScreen(
|
||||||
isLoading: Boolean
|
isLoading: Boolean
|
||||||
) {
|
) {
|
||||||
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
|
var editingFolder by remember { mutableStateOf<SyncedFolder?>(null) }
|
||||||
|
val folderStatsByUri = remember(allRecentFiles) {
|
||||||
|
allRecentFiles
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.sourceFolderUri != null }
|
||||||
|
.groupBy { it.sourceFolderUri!! }
|
||||||
|
.mapValues { (_, files) ->
|
||||||
|
FolderFileStats(
|
||||||
|
totalBooks = files.size,
|
||||||
|
countsByType = files.groupingBy { it.type }.eachCount()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
|
|
@ -1943,7 +1975,7 @@ private fun FolderSyncScreen(
|
||||||
items(syncedFolders, key = { it.uriString }) { folder ->
|
items(syncedFolders, key = { it.uriString }) { folder ->
|
||||||
FolderCard(
|
FolderCard(
|
||||||
folder = folder,
|
folder = folder,
|
||||||
allRecentFiles = allRecentFiles,
|
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
|
||||||
onRemoveClick = onRemoveFolderClick,
|
onRemoveClick = onRemoveFolderClick,
|
||||||
onEditFiltersClick = { editingFolder = folder }
|
onEditFiltersClick = { editingFolder = folder }
|
||||||
)
|
)
|
||||||
|
|
@ -1952,11 +1984,11 @@ private fun FolderSyncScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingFolder != null) {
|
editingFolder?.let { folder ->
|
||||||
EditFolderFiltersDialog(
|
EditFolderFiltersDialog(
|
||||||
folder = editingFolder!!,
|
folder = folder,
|
||||||
onConfirm = { newFilters ->
|
onConfirm = { newFilters ->
|
||||||
onEditFolderFiltersClick(editingFolder!!, newFilters)
|
onEditFolderFiltersClick(folder, newFilters)
|
||||||
editingFolder = null
|
editingFolder = null
|
||||||
},
|
},
|
||||||
onDismiss = { editingFolder = null }
|
onDismiss = { editingFolder = null }
|
||||||
|
|
@ -1964,11 +1996,20 @@ private fun FolderSyncScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class FolderFileStats(
|
||||||
|
val totalBooks: Int,
|
||||||
|
val countsByType: Map<FileType, Int>
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val Empty = FolderFileStats(totalBooks = 0, countsByType = emptyMap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
|
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun FolderCard(
|
private fun FolderCard(
|
||||||
folder: SyncedFolder,
|
folder: SyncedFolder,
|
||||||
allRecentFiles: List<RecentFileItem>,
|
stats: FolderFileStats,
|
||||||
onRemoveClick: (SyncedFolder) -> Unit,
|
onRemoveClick: (SyncedFolder) -> Unit,
|
||||||
onEditFiltersClick: (SyncedFolder) -> Unit
|
onEditFiltersClick: (SyncedFolder) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
@ -1976,14 +2017,6 @@ private fun FolderCard(
|
||||||
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
|
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
|
||||||
val lastScanText = if (folder.lastScanTime == 0L) stringResource(R.string.never) else dateFormat.format(Date(folder.lastScanTime))
|
val lastScanText = if (folder.lastScanTime == 0L) stringResource(R.string.never) else dateFormat.format(Date(folder.lastScanTime))
|
||||||
|
|
||||||
val folderFiles = remember(allRecentFiles, folder.uriString) {
|
|
||||||
allRecentFiles.filter { it.sourceFolderUri == folder.uriString }
|
|
||||||
}
|
|
||||||
val totalBooks = folderFiles.size
|
|
||||||
val countsByType = remember(folderFiles) {
|
|
||||||
folderFiles.groupBy { it.type }.mapValues { it.value.size }
|
|
||||||
}
|
|
||||||
|
|
||||||
androidx.compose.material3.ElevatedCard(
|
androidx.compose.material3.ElevatedCard(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
||||||
|
|
@ -2058,18 +2091,18 @@ private fun FolderCard(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
Text(text = totalBooks.toString(), style = MaterialTheme.typography.bodyMedium)
|
Text(text = stats.totalBooks.toString(), style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (countsByType.isNotEmpty()) {
|
if (stats.countsByType.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
androidx.compose.foundation.layout.FlowRow(
|
androidx.compose.foundation.layout.FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
countsByType.forEach { (type, count) ->
|
stats.countsByType.forEach { (type, count) ->
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = { },
|
onClick = { },
|
||||||
label = { Text(stringResource(R.string.folder_filter_count, type.name, count)) }
|
label = { Text(stringResource(R.string.folder_filter_count, type.name, count)) }
|
||||||
|
|
|
||||||
435
app/src/main/java/com/aryan/reader/LibraryStateProjector.kt
Normal file
|
|
@ -0,0 +1,435 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import com.aryan.reader.data.BookShelfCrossRef
|
||||||
|
import com.aryan.reader.data.BookTagCrossRef
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import com.aryan.reader.data.ShelfEntity
|
||||||
|
import com.aryan.reader.data.SmartCollectionEngine
|
||||||
|
import com.aryan.reader.data.TagEntity
|
||||||
|
|
||||||
|
fun interface FolderPathResolver {
|
||||||
|
fun relativeFolderSegments(item: RecentFileItem): List<String>
|
||||||
|
}
|
||||||
|
|
||||||
|
object EmptyFolderPathResolver : FolderPathResolver {
|
||||||
|
override fun relativeFolderSegments(item: RecentFileItem): List<String> = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LibraryProjectionInput(
|
||||||
|
val state: ReaderScreenState,
|
||||||
|
val recentFilesFromDb: List<RecentFileItem>,
|
||||||
|
val dbShelves: List<ShelfEntity>,
|
||||||
|
val shelfRefs: List<BookShelfCrossRef>,
|
||||||
|
val dbTags: List<TagEntity>,
|
||||||
|
val tagRefs: List<BookTagCrossRef>
|
||||||
|
)
|
||||||
|
|
||||||
|
class LibraryStateProjector(
|
||||||
|
private val folderPathResolver: FolderPathResolver = EmptyFolderPathResolver
|
||||||
|
) {
|
||||||
|
private var cachedProjection: CachedProjection? = null
|
||||||
|
|
||||||
|
fun project(input: LibraryProjectionInput): ReaderScreenState {
|
||||||
|
val start = ReaderPerfLog.nowNanos()
|
||||||
|
val internalState = input.state
|
||||||
|
val cacheKey = ProjectionCacheKey(
|
||||||
|
recentFilesFromDb = input.recentFilesFromDb,
|
||||||
|
dbShelves = input.dbShelves,
|
||||||
|
shelfRefs = input.shelfRefs,
|
||||||
|
dbTags = input.dbTags,
|
||||||
|
tagRefs = input.tagRefs,
|
||||||
|
folderKeys = internalState.syncedFolders.map { SyncedFolderProjectionKey(it.uriString, it.name) },
|
||||||
|
sortOrder = internalState.sortOrder,
|
||||||
|
searchQuery = internalState.searchQuery,
|
||||||
|
libraryFilters = internalState.libraryFilters,
|
||||||
|
recentFilesLimit = internalState.recentFilesLimit
|
||||||
|
)
|
||||||
|
|
||||||
|
cachedProjection?.takeIf { it.key == cacheKey }?.let { cache ->
|
||||||
|
val result = buildStateFromCache(internalState, cache)
|
||||||
|
val elapsed = ReaderPerfLog.elapsedMs(start)
|
||||||
|
if (elapsed >= 8L) {
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"LibraryProject cache-hit took ${elapsed}ms books=${cache.allLibraryFiles.size} shelves=${cache.shelfProjection.shelves.size}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
val tagsById = input.dbTags.associateBy { it.id }
|
||||||
|
val bookTagsMap = input.tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
||||||
|
entry.value.mapNotNull { tagsById[it.tagId] }
|
||||||
|
}
|
||||||
|
|
||||||
|
val allLibraryFiles = input.recentFilesFromDb
|
||||||
|
.filterNot { it.bookId.endsWith("_reflow") }
|
||||||
|
.map { item ->
|
||||||
|
item.copy(tags = bookTagsMap[item.bookId] ?: emptyList())
|
||||||
|
}
|
||||||
|
val allLibraryFilesById = allLibraryFiles.associateBy { it.bookId }
|
||||||
|
|
||||||
|
val rawFilteredByQuery = filterBySearch(allLibraryFiles, internalState.searchQuery)
|
||||||
|
val libraryFiltered = applyLibraryFilters(rawFilteredByQuery, internalState.libraryFilters)
|
||||||
|
val sortedLibraryFiles = if (internalState.sortOrder == SortOrder.RECENT) {
|
||||||
|
libraryFiltered
|
||||||
|
} else {
|
||||||
|
sortFiles(libraryFiltered, internalState.sortOrder)
|
||||||
|
}
|
||||||
|
val recentLimit = if (internalState.recentFilesLimit > 0) internalState.recentFilesLimit else Int.MAX_VALUE
|
||||||
|
val visibleRecentFiles = if (internalState.sortOrder == SortOrder.RECENT) {
|
||||||
|
allLibraryFiles
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.isRecent }
|
||||||
|
.take(recentLimit)
|
||||||
|
.toList()
|
||||||
|
} else {
|
||||||
|
sortFiles(
|
||||||
|
allLibraryFiles.filter { it.isRecent },
|
||||||
|
internalState.sortOrder
|
||||||
|
).take(recentLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
val shelfProjection = buildShelves(
|
||||||
|
allLibraryFiles = allLibraryFiles,
|
||||||
|
dbShelves = input.dbShelves,
|
||||||
|
shelfRefs = input.shelfRefs,
|
||||||
|
dbTags = input.dbTags,
|
||||||
|
sortOrder = internalState.sortOrder,
|
||||||
|
syncedFolders = internalState.syncedFolders
|
||||||
|
)
|
||||||
|
|
||||||
|
val cache = CachedProjection(
|
||||||
|
key = cacheKey,
|
||||||
|
allLibraryFiles = allLibraryFiles,
|
||||||
|
allLibraryFilesById = allLibraryFilesById,
|
||||||
|
sortedLibraryFiles = sortedLibraryFiles,
|
||||||
|
visibleRecentFiles = visibleRecentFiles,
|
||||||
|
shelfProjection = shelfProjection,
|
||||||
|
validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id },
|
||||||
|
dbTags = input.dbTags
|
||||||
|
)
|
||||||
|
cachedProjection = cache
|
||||||
|
|
||||||
|
val elapsed = ReaderPerfLog.elapsedMs(start)
|
||||||
|
if (elapsed >= 16L || allLibraryFiles.size >= 500) {
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"LibraryProject recompute took ${elapsed}ms books=${allLibraryFiles.size} " +
|
||||||
|
"visible=${sortedLibraryFiles.size} shelves=${shelfProjection.shelves.size} " +
|
||||||
|
"tags=${input.dbTags.size} shelfRefs=${input.shelfRefs.size} tagRefs=${input.tagRefs.size}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildStateFromCache(internalState, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStateFromCache(
|
||||||
|
internalState: ReaderScreenState,
|
||||||
|
cache: CachedProjection
|
||||||
|
): ReaderScreenState {
|
||||||
|
val viewingShelfId = internalState.viewingShelfId?.takeIf { it in cache.validShelfIds }
|
||||||
|
val selectedShelfIds = internalState.contextualActionShelfIds.filterTo(mutableSetOf()) { it in cache.validShelfIds }
|
||||||
|
val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && viewingShelfId != null) {
|
||||||
|
val currentShelfBookIds = cache.shelfProjection.shelves
|
||||||
|
.find { it.id == viewingShelfId }
|
||||||
|
?.books
|
||||||
|
?.mapTo(mutableSetOf()) { it.bookId }
|
||||||
|
?: emptySet()
|
||||||
|
when (internalState.addBooksSource) {
|
||||||
|
AddBooksSource.UNSHELVED -> cache.shelfProjection.unshelvedBooks
|
||||||
|
AddBooksSource.ALL_BOOKS -> cache.allLibraryFiles.filter { it.bookId !in currentShelfBookIds }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return internalState.copy(
|
||||||
|
recentFiles = cache.visibleRecentFiles,
|
||||||
|
allRecentFiles = cache.sortedLibraryFiles,
|
||||||
|
rawLibraryFiles = cache.allLibraryFiles,
|
||||||
|
viewingShelfId = viewingShelfId,
|
||||||
|
isAddingBooksToShelf = internalState.isAddingBooksToShelf && viewingShelfId != null,
|
||||||
|
contextualActionShelfIds = selectedShelfIds,
|
||||||
|
contextualActionItems = internalState.contextualActionItems
|
||||||
|
.mapNotNull { ctx -> cache.allLibraryFilesById[ctx.bookId] }
|
||||||
|
.toSet(),
|
||||||
|
shelves = cache.shelfProjection.shelves,
|
||||||
|
openTabs = internalState.openTabIds.mapNotNull { tabId -> cache.allLibraryFilesById[tabId] },
|
||||||
|
booksAvailableForAdding = booksAvailableForAdding,
|
||||||
|
allTags = cache.dbTags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildShelves(
|
||||||
|
allLibraryFiles: List<RecentFileItem>,
|
||||||
|
dbShelves: List<ShelfEntity>,
|
||||||
|
shelfRefs: List<BookShelfCrossRef>,
|
||||||
|
dbTags: List<TagEntity>,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
syncedFolders: List<SyncedFolder>
|
||||||
|
): ShelfProjection {
|
||||||
|
val allShelves = mutableListOf<Shelf>()
|
||||||
|
val shelvedBookIds = mutableSetOf<String>()
|
||||||
|
val baseFilesMap = allLibraryFiles.associateBy { it.bookId }
|
||||||
|
val shelfRefsByShelfId = shelfRefs.groupBy { it.shelfId }
|
||||||
|
val taggedBookIdsByTagId = mutableMapOf<String, MutableList<String>>()
|
||||||
|
|
||||||
|
allLibraryFiles.forEach { item ->
|
||||||
|
item.tags.forEach { tag ->
|
||||||
|
taggedBookIdsByTagId.getOrPut(tag.id) { mutableListOf() }.add(item.bookId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbShelves.forEach { shelfEntity ->
|
||||||
|
if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) {
|
||||||
|
val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson)
|
||||||
|
if (rules != null) {
|
||||||
|
val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it, rules) }
|
||||||
|
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks, sortOrder)))
|
||||||
|
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val bookIdsInShelf = shelfRefsByShelfId[shelfEntity.id].orEmpty()
|
||||||
|
.sortedBy { it.addedAt }
|
||||||
|
.map { it.bookId }
|
||||||
|
val booksInShelf = bookIdsInShelf.mapNotNull { baseFilesMap[it] }
|
||||||
|
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.MANUAL, sortFiles(booksInShelf, sortOrder)))
|
||||||
|
shelvedBookIds.addAll(bookIdsInShelf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val tagShelves = dbTags.mapNotNull { tag ->
|
||||||
|
val taggedBooks = taggedBookIdsByTagId[tag.id].orEmpty().mapNotNull { baseFilesMap[it] }
|
||||||
|
if (taggedBooks.isEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortFiles(taggedBooks, sortOrder))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allShelves.addAll(tagShelves)
|
||||||
|
|
||||||
|
val seriesShelves = allLibraryFiles
|
||||||
|
.filter { !it.seriesName.isNullOrBlank() }
|
||||||
|
.groupBy { it.seriesName!! }
|
||||||
|
.filter { it.value.size >= 2 }
|
||||||
|
.map { (series, books) ->
|
||||||
|
val sortedSeries = books.sortedBy { it.seriesIndex ?: 999.0 }
|
||||||
|
shelvedBookIds.addAll(books.map { it.bookId })
|
||||||
|
Shelf("series_$series", series, ShelfType.SERIES, sortedSeries)
|
||||||
|
}
|
||||||
|
allShelves.addAll(seriesShelves)
|
||||||
|
|
||||||
|
val folderShelves = buildFolderShelves(
|
||||||
|
allLibraryFiles = allLibraryFiles,
|
||||||
|
syncedFolders = syncedFolders,
|
||||||
|
sortOrder = sortOrder
|
||||||
|
).also { shelves ->
|
||||||
|
shelves.forEach { shelf ->
|
||||||
|
shelvedBookIds.addAll(shelf.books.map { it.bookId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allShelves.addAll(folderShelves)
|
||||||
|
|
||||||
|
val unshelvedBooks = allLibraryFiles.filter { it.bookId !in shelvedBookIds }
|
||||||
|
allShelves.add(Shelf("unshelved", "Unshelved", ShelfType.MANUAL, sortFiles(unshelvedBooks, sortOrder)))
|
||||||
|
|
||||||
|
allShelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey }))
|
||||||
|
return ShelfProjection(shelves = allShelves, unshelvedBooks = unshelvedBooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildFolderShelves(
|
||||||
|
allLibraryFiles: List<RecentFileItem>,
|
||||||
|
syncedFolders: List<SyncedFolder>,
|
||||||
|
sortOrder: SortOrder
|
||||||
|
): List<Shelf> {
|
||||||
|
val folderNamesByUri = syncedFolders.associate { it.uriString to it.name }
|
||||||
|
val folderSegmentsByBookId = allLibraryFiles
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.sourceFolderUri != null }
|
||||||
|
.associate { it.bookId to folderPathResolver.relativeFolderSegments(it) }
|
||||||
|
|
||||||
|
return allLibraryFiles
|
||||||
|
.filter { it.sourceFolderUri != null }
|
||||||
|
.groupBy { it.sourceFolderUri!! }
|
||||||
|
.flatMap { (folderUri, books) ->
|
||||||
|
val rootName = folderNamesByUri[folderUri] ?: "Local Folder"
|
||||||
|
val rootShelfId = "folder_$folderUri"
|
||||||
|
val rootAccumulator = FolderShelfAccumulator(
|
||||||
|
id = rootShelfId,
|
||||||
|
name = rootName,
|
||||||
|
depth = 0,
|
||||||
|
parentShelfId = null,
|
||||||
|
sortPath = ""
|
||||||
|
)
|
||||||
|
val rootShelf = Shelf(
|
||||||
|
id = rootShelfId,
|
||||||
|
name = rootName,
|
||||||
|
type = ShelfType.FOLDER,
|
||||||
|
books = sortFiles(books, sortOrder),
|
||||||
|
directBooks = emptyList(),
|
||||||
|
childShelfIds = emptyList(),
|
||||||
|
depth = 0,
|
||||||
|
sortKey = "folder:${rootName.lowercase()}:"
|
||||||
|
)
|
||||||
|
|
||||||
|
val nestedShelves = linkedMapOf<String, FolderShelfAccumulator>()
|
||||||
|
val nestedShelvesById = mutableMapOf<String, FolderShelfAccumulator>()
|
||||||
|
books.forEach { book ->
|
||||||
|
rootAccumulator.books.add(book)
|
||||||
|
val segments = folderSegmentsByBookId[book.bookId].orEmpty()
|
||||||
|
if (segments.isEmpty()) {
|
||||||
|
rootAccumulator.directBooks.add(book)
|
||||||
|
}
|
||||||
|
var currentPath = ""
|
||||||
|
var parentShelfId = rootShelfId
|
||||||
|
segments.forEachIndexed { index, segment ->
|
||||||
|
currentPath = if (currentPath.isEmpty()) segment else "$currentPath/$segment"
|
||||||
|
val shelfId = "folder_$folderUri::$currentPath"
|
||||||
|
val accumulator = nestedShelves.getOrPut(currentPath) {
|
||||||
|
val newShelf = FolderShelfAccumulator(
|
||||||
|
id = shelfId,
|
||||||
|
name = segment,
|
||||||
|
depth = index + 1,
|
||||||
|
parentShelfId = parentShelfId,
|
||||||
|
sortPath = currentPath.lowercase()
|
||||||
|
)
|
||||||
|
if (parentShelfId == rootShelfId) {
|
||||||
|
rootAccumulator.childShelfIds.add(shelfId)
|
||||||
|
} else {
|
||||||
|
nestedShelvesById[parentShelfId]?.childShelfIds?.add(shelfId)
|
||||||
|
}
|
||||||
|
nestedShelvesById[shelfId] = newShelf
|
||||||
|
newShelf
|
||||||
|
}
|
||||||
|
accumulator.books.add(book)
|
||||||
|
if (index == segments.lastIndex) {
|
||||||
|
accumulator.directBooks.add(book)
|
||||||
|
}
|
||||||
|
parentShelfId = shelfId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sortedNestedShelves = nestedShelves
|
||||||
|
.values
|
||||||
|
.sortedBy { it.sortPath }
|
||||||
|
.map { shelf ->
|
||||||
|
Shelf(
|
||||||
|
id = shelf.id,
|
||||||
|
name = shelf.name,
|
||||||
|
type = ShelfType.FOLDER,
|
||||||
|
books = sortFiles(shelf.books, sortOrder),
|
||||||
|
directBooks = sortFiles(shelf.directBooks, sortOrder),
|
||||||
|
parentShelfId = shelf.parentShelfId,
|
||||||
|
childShelfIds = shelf.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() },
|
||||||
|
depth = shelf.depth,
|
||||||
|
sortKey = "folder:${rootName.lowercase()}:${shelf.sortPath}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
rootShelf.copy(
|
||||||
|
directBooks = sortFiles(rootAccumulator.directBooks, sortOrder),
|
||||||
|
childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() }
|
||||||
|
)
|
||||||
|
) + sortedNestedShelves
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class FolderShelfAccumulator(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val depth: Int,
|
||||||
|
val parentShelfId: String?,
|
||||||
|
val sortPath: String,
|
||||||
|
val books: MutableList<RecentFileItem> = mutableListOf(),
|
||||||
|
val directBooks: MutableList<RecentFileItem> = mutableListOf(),
|
||||||
|
val childShelfIds: MutableList<String> = mutableListOf()
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ShelfProjection(
|
||||||
|
val shelves: List<Shelf>,
|
||||||
|
val unshelvedBooks: List<RecentFileItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ProjectionCacheKey(
|
||||||
|
val recentFilesFromDb: List<RecentFileItem>,
|
||||||
|
val dbShelves: List<ShelfEntity>,
|
||||||
|
val shelfRefs: List<BookShelfCrossRef>,
|
||||||
|
val dbTags: List<TagEntity>,
|
||||||
|
val tagRefs: List<BookTagCrossRef>,
|
||||||
|
val folderKeys: List<SyncedFolderProjectionKey>,
|
||||||
|
val sortOrder: SortOrder,
|
||||||
|
val searchQuery: String,
|
||||||
|
val libraryFilters: LibraryFilters,
|
||||||
|
val recentFilesLimit: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class SyncedFolderProjectionKey(
|
||||||
|
val uriString: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class CachedProjection(
|
||||||
|
val key: ProjectionCacheKey,
|
||||||
|
val allLibraryFiles: List<RecentFileItem>,
|
||||||
|
val allLibraryFilesById: Map<String, RecentFileItem>,
|
||||||
|
val sortedLibraryFiles: List<RecentFileItem>,
|
||||||
|
val visibleRecentFiles: List<RecentFileItem>,
|
||||||
|
val shelfProjection: ShelfProjection,
|
||||||
|
val validShelfIds: Set<String>,
|
||||||
|
val dbTags: List<TagEntity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun filterBySearch(files: List<RecentFileItem>, searchQuery: String): List<RecentFileItem> {
|
||||||
|
val query = searchQuery.trim()
|
||||||
|
return if (query.isBlank()) {
|
||||||
|
files
|
||||||
|
} else {
|
||||||
|
files.filter { item ->
|
||||||
|
item.displayName.contains(query, ignoreCase = true) ||
|
||||||
|
item.title?.contains(query, ignoreCase = true) == true ||
|
||||||
|
item.author?.contains(query, ignoreCase = true) == true ||
|
||||||
|
item.tags.any { tag -> tag.name.contains(query, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyLibraryFilters(files: List<RecentFileItem>, filters: LibraryFilters): List<RecentFileItem> {
|
||||||
|
return files.filter { item ->
|
||||||
|
val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true
|
||||||
|
val matchFolder = if (filters.sourceFolders.isNotEmpty()) {
|
||||||
|
val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") &&
|
||||||
|
item.sourceFolderUri == null &&
|
||||||
|
item.uriString?.startsWith("opds-pse") != true
|
||||||
|
val matchesSynced = item.sourceFolderUri in filters.sourceFolders
|
||||||
|
matchesInApp || matchesSynced
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
val progress = item.progressPercentage ?: 0f
|
||||||
|
val matchStatus = when (filters.readStatus) {
|
||||||
|
ReadStatusFilter.ALL -> true
|
||||||
|
ReadStatusFilter.UNREAD -> progress == 0f
|
||||||
|
ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f
|
||||||
|
ReadStatusFilter.COMPLETED -> progress >= 100f
|
||||||
|
}
|
||||||
|
val matchTags = if (filters.tagIds.isNotEmpty()) {
|
||||||
|
item.tags.any { it.id in filters.tagIds }
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
matchType && matchFolder && matchStatus && matchTags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sortFiles(files: List<RecentFileItem>, sortOrder: SortOrder): List<RecentFileItem> {
|
||||||
|
return when (sortOrder) {
|
||||||
|
SortOrder.RECENT -> files.sortedByDescending { it.timestamp }
|
||||||
|
SortOrder.TITLE_ASC -> files.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
|
||||||
|
SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
|
||||||
|
SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f }
|
||||||
|
SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f }
|
||||||
|
SortOrder.SIZE_ASC -> files.sortedBy { it.fileSize }
|
||||||
|
SortOrder.SIZE_DESC -> files.sortedByDescending { it.fileSize }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,7 +45,9 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.getValue
|
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
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private val viewModel: MainViewModel by viewModels()
|
private val viewModel: MainViewModel by viewModels()
|
||||||
|
|
@ -91,12 +93,14 @@ class MainActivity : AppCompatActivity() {
|
||||||
AppThemeMode.SYSTEM -> isSystemInDarkTheme()
|
AppThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val textDimFactor = if (darkTheme) uiState.appTextDimFactorDark else uiState.appTextDimFactorLight
|
||||||
|
|
||||||
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 = uiState.appTextDimFactor
|
textDimFactor = textDimFactor
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
|
||||||
14
app/src/main/java/com/aryan/reader/MainPreferenceKeys.kt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
internal const val KEY_RENDER_MODE = "render_mode"
|
||||||
|
internal const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
|
||||||
|
internal const val KEY_MAIN_SCREEN_START_PAGE = "main_screen_start_page"
|
||||||
|
internal const val KEY_LIBRARY_SCREEN_START_PAGE = "library_screen_start_page"
|
||||||
|
internal const val KEY_LAST_VIEWING_SHELF_ID = "last_viewing_shelf_id"
|
||||||
|
internal const val KEY_LAST_ADDING_BOOKS_TO_SHELF = "last_adding_books_to_shelf"
|
||||||
|
|
||||||
|
internal const val KEY_FILTER_FILE_TYPES = "filter_file_types"
|
||||||
|
internal const val KEY_FILTER_FOLDERS = "filter_folders"
|
||||||
|
internal const val KEY_FILTER_READ_STATUS = "filter_read_status"
|
||||||
|
internal const val KEY_FILTER_TAG_IDS = "filter_tag_ids"
|
||||||
|
internal const val KEY_DEFAULT_TAGS_SEEDED = "default_tags_seeded"
|
||||||
|
|
@ -22,10 +22,9 @@ package com.aryan.reader
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
|
@ -33,17 +32,15 @@ import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
sealed class BottomBarScreen(val route: String, val stringResId: Int, val iconResId: Int) {
|
sealed class BottomBarScreen(val route: String, val stringResId: Int, val iconResId: Int) {
|
||||||
object Home : BottomBarScreen("home", R.string.nav_home, R.drawable.home)
|
object Home : BottomBarScreen("home", R.string.nav_home, R.drawable.home)
|
||||||
|
|
@ -55,6 +52,7 @@ private val bottomBarItems = listOf(
|
||||||
BottomBarScreen.Library,
|
BottomBarScreen.Library,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
viewModel: MainViewModel,
|
viewModel: MainViewModel,
|
||||||
|
|
@ -75,21 +73,7 @@ fun MainScreen(
|
||||||
if (viewingShelfName != null) {
|
if (viewingShelfName != null) {
|
||||||
ShelfScreen(viewModel = viewModel)
|
ShelfScreen(viewModel = viewModel)
|
||||||
} else {
|
} else {
|
||||||
val pagerState = rememberPagerState(
|
val selectedPage = uiState.mainScreenStartPage.coerceIn(0, bottomBarItems.lastIndex)
|
||||||
initialPage = uiState.mainScreenStartPage,
|
|
||||||
pageCount = { bottomBarItems.size }
|
|
||||||
)
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
LaunchedEffect(uiState.mainScreenStartPage) {
|
|
||||||
if (pagerState.currentPage != uiState.mainScreenStartPage) {
|
|
||||||
pagerState.animateScrollToPage(uiState.mainScreenStartPage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(pagerState.currentPage) {
|
|
||||||
viewModel.setMainScreenPage(pagerState.currentPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||||
|
|
@ -99,23 +83,28 @@ fun MainScreen(
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) },
|
icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) },
|
||||||
label = { Text(stringResource(screen.stringResId)) },
|
label = { Text(stringResource(screen.stringResId)) },
|
||||||
selected = pagerState.currentPage == index,
|
selected = selectedPage == index,
|
||||||
onClick = { scope.launch { pagerState.animateScrollToPage(index) } }
|
onClick = {
|
||||||
|
ReaderPerfLog.d("MainPager click page=$index route=${screen.route}")
|
||||||
|
if (selectedPage != index) {
|
||||||
|
val animStart = ReaderPerfLog.nowNanos()
|
||||||
|
viewModel.setMainScreenPage(index)
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"MainPager settled page=$index elapsed=${ReaderPerfLog.elapsedMs(animStart)}ms"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
HorizontalPager(
|
androidx.compose.foundation.layout.Box(
|
||||||
state = pagerState,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding)
|
||||||
key = { bottomBarItems[it].route },
|
) {
|
||||||
beyondViewportPageCount = 1,
|
when (selectedPage) {
|
||||||
userScrollEnabled = false
|
|
||||||
) { page ->
|
|
||||||
when (page) {
|
|
||||||
0 -> HomeScreen(
|
0 -> HomeScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
windowSizeClass = windowSizeClass,
|
windowSizeClass = windowSizeClass,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
import com.aryan.reader.paginatedreader.semanticBlockModule
|
import com.aryan.reader.paginatedreader.semanticBlockModule
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.graphics.createBitmap
|
import androidx.core.graphics.createBitmap
|
||||||
|
|
@ -48,6 +48,7 @@ import androidx.credentials.exceptions.NoCredentialException
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
|
|
@ -64,8 +65,8 @@ import com.aryan.reader.data.RecentFileItem
|
||||||
import com.aryan.reader.data.RecentFilesRepository
|
import com.aryan.reader.data.RecentFilesRepository
|
||||||
import com.aryan.reader.data.RemoteConfigRepository
|
import com.aryan.reader.data.RemoteConfigRepository
|
||||||
import com.aryan.reader.data.ShelfMetadata
|
import com.aryan.reader.data.ShelfMetadata
|
||||||
import com.aryan.reader.data.SmartCollectionEngine
|
|
||||||
import com.aryan.reader.data.TagEntity
|
import com.aryan.reader.data.TagEntity
|
||||||
|
import com.aryan.reader.data.getUri
|
||||||
import com.aryan.reader.data.toBookMetadata
|
import com.aryan.reader.data.toBookMetadata
|
||||||
import com.aryan.reader.data.toRecentFileItem
|
import com.aryan.reader.data.toRecentFileItem
|
||||||
import com.aryan.reader.epub.CalibreBundleExtractor
|
import com.aryan.reader.epub.CalibreBundleExtractor
|
||||||
|
|
@ -75,6 +76,7 @@ import com.aryan.reader.epub.EpubParser
|
||||||
import com.aryan.reader.epub.ImportedFileCache
|
import com.aryan.reader.epub.ImportedFileCache
|
||||||
import com.aryan.reader.epub.MobiParser
|
import com.aryan.reader.epub.MobiParser
|
||||||
import com.aryan.reader.epub.SingleFileImporter
|
import com.aryan.reader.epub.SingleFileImporter
|
||||||
|
import com.aryan.reader.epub.hasReadableExtractedContent
|
||||||
import com.aryan.reader.ml.ISpeechBubbleDetector
|
import com.aryan.reader.ml.ISpeechBubbleDetector
|
||||||
import com.aryan.reader.ml.SpeechBubble
|
import com.aryan.reader.ml.SpeechBubble
|
||||||
import com.aryan.reader.paginatedreader.Locator
|
import com.aryan.reader.paginatedreader.Locator
|
||||||
|
|
@ -120,60 +122,18 @@ import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.util.Date
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.CancellationException
|
import java.util.concurrent.CancellationException
|
||||||
import java.util.concurrent.Executors.newSingleThreadExecutor
|
import java.util.concurrent.Executors.newSingleThreadExecutor
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
private const val KEY_RENDER_MODE = "render_mode"
|
|
||||||
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
|
|
||||||
private const val KEY_MAIN_SCREEN_START_PAGE = "main_screen_start_page"
|
|
||||||
private const val KEY_LIBRARY_SCREEN_START_PAGE = "library_screen_start_page"
|
|
||||||
private const val KEY_LAST_VIEWING_SHELF_ID = "last_viewing_shelf_id"
|
|
||||||
private const val KEY_LAST_ADDING_BOOKS_TO_SHELF = "last_adding_books_to_shelf"
|
|
||||||
|
|
||||||
private const val KEY_FILTER_FILE_TYPES = "filter_file_types"
|
|
||||||
private const val KEY_FILTER_FOLDERS = "filter_folders"
|
|
||||||
private const val KEY_FILTER_READ_STATUS = "filter_read_status"
|
|
||||||
private const val KEY_FILTER_TAG_IDS = "filter_tag_ids"
|
|
||||||
private const val KEY_DEFAULT_TAGS_SEEDED = "default_tags_seeded"
|
|
||||||
private val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7)
|
|
||||||
private val EPUB_READER_FILE_TYPES = setOf(
|
|
||||||
FileType.EPUB,
|
|
||||||
FileType.MOBI,
|
|
||||||
FileType.MD,
|
|
||||||
FileType.TXT,
|
|
||||||
FileType.HTML,
|
|
||||||
FileType.FB2,
|
|
||||||
FileType.DOCX,
|
|
||||||
FileType.ODT,
|
|
||||||
FileType.FODT
|
|
||||||
)
|
|
||||||
|
|
||||||
data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false)
|
|
||||||
|
|
||||||
data class ImportResult(
|
|
||||||
val internalUri: Uri,
|
|
||||||
val bookId: String,
|
|
||||||
val type: FileType,
|
|
||||||
val bundleResult: CalibreBundleResult? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UserData(
|
|
||||||
val uid: String, val displayName: String?, val photoUrl: String?, val email: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class NavigationEvent(
|
|
||||||
val route: String, val bookId: String? = null, val uri: Uri? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class SpeechBubbleCacheKey(
|
private data class SpeechBubbleCacheKey(
|
||||||
val documentId: String,
|
val documentId: String,
|
||||||
val pageIndex: Int
|
val pageIndex: Int
|
||||||
|
|
@ -187,166 +147,8 @@ private data class CachedSpeechBubble(
|
||||||
val maskBitmap: Bitmap?
|
val maskBitmap: Bitmap?
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class AddBooksSource(@StringRes val labelRes: Int) {
|
@kotlin.OptIn(ExperimentalSerializationApi::class)
|
||||||
UNSHELVED(R.string.add_books_source_unshelved),
|
@UnstableApi
|
||||||
ALL_BOOKS(R.string.add_books_source_all_books)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class AppThemeMode(@StringRes val labelRes: Int) {
|
|
||||||
SYSTEM(R.string.app_theme_mode_system),
|
|
||||||
LIGHT(R.string.app_theme_mode_light),
|
|
||||||
DARK(R.string.app_theme_mode_dark)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class AppContrastOption(@StringRes val labelRes: Int, val value: Double) {
|
|
||||||
STANDARD(R.string.app_contrast_standard, 0.0),
|
|
||||||
MEDIUM(R.string.app_contrast_medium, 0.5),
|
|
||||||
HIGH(R.string.app_contrast_high, 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CustomAppTheme(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val seedColor: androidx.compose.ui.graphics.Color
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class FileType {
|
|
||||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class RenderMode {
|
|
||||||
VERTICAL_SCROLL, PAGINATED
|
|
||||||
}
|
|
||||||
|
|
||||||
data class DeviceItem(val deviceId: String, val deviceName: String, val lastSeen: Date?)
|
|
||||||
|
|
||||||
data class DeviceLimitReachedState(
|
|
||||||
val isLimitReached: Boolean = false, val registeredDevices: List<DeviceItem> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SyncedFolder(
|
|
||||||
val uriString: String, val name: String, val lastScanTime: Long, val allowedFileTypes: Set<FileType> = FileType.entries.toSet()
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER }
|
|
||||||
|
|
||||||
data class Shelf(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val type: ShelfType,
|
|
||||||
val books: List<RecentFileItem>,
|
|
||||||
val directBooks: List<RecentFileItem> = books,
|
|
||||||
val parentShelfId: String? = null,
|
|
||||||
val childShelfIds: List<String> = emptyList(),
|
|
||||||
val depth: Int = 0,
|
|
||||||
val sortKey: String = name.lowercase()
|
|
||||||
) {
|
|
||||||
val bookCount: Int get() = books.size
|
|
||||||
val topBook: RecentFileItem? get() = books.maxByOrNull { it.timestamp }
|
|
||||||
val directBookCount: Int get() = directBooks.size
|
|
||||||
val childShelfCount: Int get() = childShelfIds.size
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class SortOrder(@StringRes val labelRes: Int) {
|
|
||||||
RECENT(R.string.sort_recent),
|
|
||||||
TITLE_ASC(R.string.sort_title_az),
|
|
||||||
AUTHOR_ASC(R.string.sort_author_az),
|
|
||||||
PERCENT_ASC(R.string.sort_percent_asc),
|
|
||||||
PERCENT_DESC(R.string.sort_percent_desc),
|
|
||||||
SIZE_ASC(R.string.sort_size_smallest),
|
|
||||||
SIZE_DESC(R.string.sort_size_biggest)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class ReadStatusFilter(@StringRes val labelRes: Int) {
|
|
||||||
ALL(R.string.read_status_all),
|
|
||||||
UNREAD(R.string.read_status_unread),
|
|
||||||
IN_PROGRESS(R.string.read_status_in_progress),
|
|
||||||
COMPLETED(R.string.read_status_completed)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class LibraryFilters(
|
|
||||||
val fileTypes: Set<FileType> = emptySet(),
|
|
||||||
val sourceFolders: Set<String> = emptySet(),
|
|
||||||
val readStatus: ReadStatusFilter = ReadStatusFilter.ALL,
|
|
||||||
val tagIds: Set<String> = emptySet()
|
|
||||||
) {
|
|
||||||
val isActive: Boolean
|
|
||||||
get() = fileTypes.isNotEmpty() ||
|
|
||||||
sourceFolders.isNotEmpty() ||
|
|
||||||
readStatus != ReadStatusFilter.ALL ||
|
|
||||||
tagIds.isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ReaderScreenState(
|
|
||||||
val selectedPdfUri: Uri? = null,
|
|
||||||
val selectedBookId: String? = null,
|
|
||||||
val selectedEpubBook: EpubBook? = null,
|
|
||||||
val selectedEpubUri: Uri? = null,
|
|
||||||
val selectedFileType: FileType? = null,
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val errorMessage: String? = null,
|
|
||||||
val contextualActionItems: Set<RecentFileItem> = emptySet(),
|
|
||||||
val renderMode: RenderMode = RenderMode.VERTICAL_SCROLL,
|
|
||||||
val sortOrder: SortOrder = SortOrder.RECENT,
|
|
||||||
val initialLocator: Locator? = null,
|
|
||||||
val initialCfi: String? = null,
|
|
||||||
val initialBookmarksJson: String? = null,
|
|
||||||
val initialHighlightsJson: String? = null,
|
|
||||||
val initialPageInBook: Int? = null,
|
|
||||||
val shelves: List<Shelf> = emptyList(),
|
|
||||||
val viewingShelfId: String? = null,
|
|
||||||
val isAddingBooksToShelf: Boolean = false,
|
|
||||||
val showCreateShelfDialog: Boolean = false,
|
|
||||||
val mainScreenStartPage: Int = 0,
|
|
||||||
val libraryScreenStartPage: Int = 0,
|
|
||||||
val showRenameShelfDialogFor: String? = null,
|
|
||||||
val showDeleteShelfDialogFor: String? = null,
|
|
||||||
val addBooksSource: AddBooksSource = AddBooksSource.UNSHELVED,
|
|
||||||
val booksSelectedForAdding: Set<String> = emptySet(),
|
|
||||||
val booksAvailableForAdding: List<RecentFileItem> = emptyList(),
|
|
||||||
val contextualActionShelfIds: Set<String> = emptySet(),
|
|
||||||
val currentUser: UserData? = null,
|
|
||||||
val isAuthMenuExpanded: Boolean = false,
|
|
||||||
val isProUser: Boolean = false,
|
|
||||||
val credits: Int = 0,
|
|
||||||
val isSyncEnabled: Boolean = false,
|
|
||||||
val isFolderSyncEnabled: Boolean = false,
|
|
||||||
val bannerMessage: BannerMessage? = null,
|
|
||||||
val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(),
|
|
||||||
val isReplacingDevice: Boolean = false,
|
|
||||||
val isRequestingDrivePermission: Boolean = false,
|
|
||||||
val downloadingBookIds: Set<String> = emptySet(),
|
|
||||||
val uploadingBookIds: Set<String> = emptySet(),
|
|
||||||
val syncedFolders: List<SyncedFolder> = emptyList(),
|
|
||||||
val lastFolderScanTime: Long? = null,
|
|
||||||
val hasUnreadFeedback: Boolean = false,
|
|
||||||
val searchQuery: String = "",
|
|
||||||
val isSearchActive: Boolean = false,
|
|
||||||
val isRefreshing: Boolean = false,
|
|
||||||
val reflowProgress: Float? = null,
|
|
||||||
val recentFiles: List<RecentFileItem> = emptyList(),
|
|
||||||
val allRecentFiles: List<RecentFileItem> = emptyList(),
|
|
||||||
val rawLibraryFiles: List<RecentFileItem> = emptyList(),
|
|
||||||
val pinnedHomeBookIds: Set<String> = emptySet(),
|
|
||||||
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
|
||||||
val libraryFilters: LibraryFilters = LibraryFilters(),
|
|
||||||
val recentFilesLimit: Int = 0,
|
|
||||||
val isTabsEnabled: Boolean = false,
|
|
||||||
val openTabIds: List<String> = emptyList(),
|
|
||||||
val openTabs: List<RecentFileItem> = emptyList(),
|
|
||||||
val activeTabBookId: String? = null,
|
|
||||||
val showExternalFileSavePromptFor: String? = null,
|
|
||||||
val externalFileBehavior: String = "ASK",
|
|
||||||
val useStrictFileFilter: Boolean = false,
|
|
||||||
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
|
|
||||||
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
|
|
||||||
val appTextDimFactor: Float = 1.0f,
|
|
||||||
val appSeedColor: androidx.compose.ui.graphics.Color? = null,
|
|
||||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
|
||||||
val allTags: List<TagEntity> = emptyList(),
|
|
||||||
val showTagSelectionDialogFor: Set<String> = emptySet(),
|
|
||||||
)
|
|
||||||
|
|
||||||
open class MainViewModel(application: Application) : AndroidViewModel(application) {
|
open class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val appContext: Context = application.applicationContext
|
private val appContext: Context = application.applicationContext
|
||||||
private val authRepository = AuthRepository(appContext)
|
private val authRepository = AuthRepository(appContext)
|
||||||
|
|
@ -380,6 +182,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
private val _prefsUpdateFlow = MutableStateFlow(0L)
|
private val _prefsUpdateFlow = MutableStateFlow(0L)
|
||||||
private val prefsListener: SharedPreferences.OnSharedPreferenceChangeListener
|
private val prefsListener: SharedPreferences.OnSharedPreferenceChangeListener
|
||||||
private val feedbackRepository = FeedbackRepository(appContext)
|
private val feedbackRepository = FeedbackRepository(appContext)
|
||||||
|
private val libraryStateProjector = LibraryStateProjector(AndroidFolderPathResolver())
|
||||||
private var feedbackListener: Any? = null
|
private var feedbackListener: Any? = null
|
||||||
private val importMutex = Mutex()
|
private val importMutex = Mutex()
|
||||||
private val epubRecoveryMutex = Mutex()
|
private val epubRecoveryMutex = Mutex()
|
||||||
|
|
@ -611,7 +414,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val totalFileLength = if (contentLength != -1L) downloadedBytes + contentLength else -1L
|
val totalFileLength = if (contentLength != -1L) downloadedBytes + contentLength else -1L
|
||||||
|
|
||||||
val input = connection.inputStream
|
val input = connection.inputStream
|
||||||
val output = java.io.FileOutputStream(tempFile, isPartial)
|
val output = FileOutputStream(tempFile, isPartial)
|
||||||
val data = ByteArray(16 * 1024)
|
val data = ByteArray(16 * 1024)
|
||||||
var count: Int
|
var count: Int
|
||||||
|
|
||||||
|
|
@ -744,7 +547,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
appContrastOption = try {
|
appContrastOption = try {
|
||||||
AppContrastOption.valueOf(prefs.getString(KEY_APP_CONTRAST_OPTION, AppContrastOption.STANDARD.name) ?: AppContrastOption.STANDARD.name)
|
AppContrastOption.valueOf(prefs.getString(KEY_APP_CONTRAST_OPTION, AppContrastOption.STANDARD.name) ?: AppContrastOption.STANDARD.name)
|
||||||
} catch (_: Exception) { AppContrastOption.STANDARD },
|
} catch (_: Exception) { AppContrastOption.STANDARD },
|
||||||
appTextDimFactor = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f),
|
appTextDimFactorLight = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f)),
|
||||||
|
appTextDimFactorDark = prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, prefs.getFloat(KEY_APP_TEXT_DIM_FACTOR, 1.0f)),
|
||||||
appSeedColor = if (prefs.contains(KEY_APP_SEED_COLOR)) androidx.compose.ui.graphics.Color(prefs.getInt(KEY_APP_SEED_COLOR, 0)) else null,
|
appSeedColor = if (prefs.contains(KEY_APP_SEED_COLOR)) androidx.compose.ui.graphics.Color(prefs.getInt(KEY_APP_SEED_COLOR, 0)) else null,
|
||||||
customAppThemes = loadCustomAppThemes(prefs)
|
customAppThemes = loadCustomAppThemes(prefs)
|
||||||
)
|
)
|
||||||
|
|
@ -808,299 +612,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
open val uiState: StateFlow<ReaderScreenState> = combine(
|
open val uiState: StateFlow<ReaderScreenState> = combine(
|
||||||
_internalState, libraryFlow, tagFlow
|
_internalState, libraryFlow, tagFlow
|
||||||
) { internalState, (recentFilesFromDb, dbShelves, shelfRefs), (dbTags, tagRefs) ->
|
) { internalState, (recentFilesFromDb, dbShelves, shelfRefs), (dbTags, tagRefs) ->
|
||||||
val tagsById = dbTags.associateBy { it.id }
|
withContext(Dispatchers.Default) {
|
||||||
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
libraryStateProjector.project(
|
||||||
entry.value.mapNotNull { tagsById[it.tagId] }
|
LibraryProjectionInput(
|
||||||
}
|
state = internalState,
|
||||||
|
recentFilesFromDb = recentFilesFromDb,
|
||||||
val allLibraryFiles = recentFilesFromDb
|
dbShelves = dbShelves,
|
||||||
.filterNot { it.bookId.endsWith("_reflow") }
|
shelfRefs = shelfRefs,
|
||||||
.map { item ->
|
dbTags = dbTags,
|
||||||
item.copy(tags = bookTagsMap[item.bookId] ?: emptyList())
|
tagRefs = tagRefs
|
||||||
}
|
|
||||||
|
|
||||||
val query = internalState.searchQuery.trim()
|
|
||||||
val rawFilteredByQuery = if (query.isBlank()) {
|
|
||||||
allLibraryFiles
|
|
||||||
} else {
|
|
||||||
allLibraryFiles.filter { item ->
|
|
||||||
item.displayName.contains(query, ignoreCase = true) ||
|
|
||||||
item.title?.contains(query, ignoreCase = true) == true ||
|
|
||||||
item.author?.contains(query, ignoreCase = true) == true ||
|
|
||||||
item.tags.any { tag -> tag.name.contains(query, ignoreCase = true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val filters = internalState.libraryFilters
|
|
||||||
val libraryFiltered = rawFilteredByQuery.filter { item ->
|
|
||||||
val matchType = if (filters.fileTypes.isNotEmpty()) item.type in filters.fileTypes else true
|
|
||||||
val matchFolder = if (filters.sourceFolders.isNotEmpty()) {
|
|
||||||
val matchesInApp = filters.sourceFolders.contains("IN_APP_STORAGE") && item.sourceFolderUri == null && item.uriString?.startsWith("opds-pse") != true
|
|
||||||
val matchesSynced = item.sourceFolderUri in filters.sourceFolders
|
|
||||||
matchesInApp || matchesSynced
|
|
||||||
} else true
|
|
||||||
val progress = item.progressPercentage ?: 0f
|
|
||||||
val matchStatus = when (filters.readStatus) {
|
|
||||||
ReadStatusFilter.ALL -> true
|
|
||||||
ReadStatusFilter.UNREAD -> progress == 0f
|
|
||||||
ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f
|
|
||||||
ReadStatusFilter.COMPLETED -> progress >= 100f
|
|
||||||
}
|
|
||||||
val matchTags = if (filters.tagIds.isNotEmpty()) {
|
|
||||||
item.tags.any { it.id in filters.tagIds }
|
|
||||||
} else true
|
|
||||||
matchType && matchFolder && matchStatus && matchTags
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sortFiles(files: List<RecentFileItem>): List<RecentFileItem> {
|
|
||||||
return when (internalState.sortOrder) {
|
|
||||||
SortOrder.RECENT -> files.sortedByDescending { it.timestamp }
|
|
||||||
SortOrder.TITLE_ASC -> files.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
|
|
||||||
SortOrder.AUTHOR_ASC -> files.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
|
|
||||||
SortOrder.PERCENT_ASC -> files.sortedBy { it.progressPercentage ?: 0f }
|
|
||||||
SortOrder.PERCENT_DESC -> files.sortedByDescending { it.progressPercentage ?: 0f }
|
|
||||||
SortOrder.SIZE_ASC -> files.sortedBy { it.fileSize }
|
|
||||||
SortOrder.SIZE_DESC -> files.sortedByDescending { it.fileSize }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val sortedLibraryFiles = sortFiles(libraryFiltered)
|
|
||||||
val visibleRecentFiles = sortFiles(allLibraryFiles.filter { it.isRecent }).take(
|
|
||||||
if (internalState.recentFilesLimit > 0) internalState.recentFilesLimit else Int.MAX_VALUE
|
|
||||||
)
|
)
|
||||||
val openTabsList = internalState.openTabIds.mapNotNull { tabId -> allLibraryFiles.find { it.bookId == tabId } }
|
|
||||||
val allShelves = mutableListOf<Shelf>()
|
|
||||||
val shelvedBookIds = mutableSetOf<String>()
|
|
||||||
val baseFilesMap = allLibraryFiles.associateBy { it.bookId }
|
|
||||||
|
|
||||||
dbShelves.forEach { shelfEntity ->
|
|
||||||
if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) {
|
|
||||||
val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson)
|
|
||||||
if (rules != null) {
|
|
||||||
val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it, rules) }
|
|
||||||
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks)))
|
|
||||||
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val bookIdsInShelf = shelfRefs.filter { it.shelfId == shelfEntity.id }.sortedBy { it.addedAt }.map { it.bookId }
|
|
||||||
val booksInShelf = bookIdsInShelf.mapNotNull { baseFilesMap[it] }
|
|
||||||
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.MANUAL, sortFiles(booksInShelf)))
|
|
||||||
shelvedBookIds.addAll(bookIdsInShelf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val tagShelves = dbTags.mapNotNull { tag ->
|
|
||||||
val taggedBooks = allLibraryFiles.filter { item -> item.tags.any { it.id == tag.id } }
|
|
||||||
if (taggedBooks.isEmpty()) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
Shelf("tag_${tag.id}", tag.name, ShelfType.TAG, sortFiles(taggedBooks))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allShelves.addAll(tagShelves)
|
|
||||||
|
|
||||||
val seriesShelves = allLibraryFiles
|
|
||||||
.filter { !it.seriesName.isNullOrBlank() }
|
|
||||||
.groupBy { it.seriesName!! }
|
|
||||||
.filter { it.value.size >= 2 }
|
|
||||||
.map { (series, books) ->
|
|
||||||
val sortedSeries = books.sortedBy { it.seriesIndex ?: 999.0 }
|
|
||||||
shelvedBookIds.addAll(books.map { it.bookId })
|
|
||||||
Shelf("series_$series", series, ShelfType.SERIES, sortedSeries)
|
|
||||||
}
|
|
||||||
allShelves.addAll(seriesShelves)
|
|
||||||
|
|
||||||
val folderShelves = buildFolderShelves(
|
|
||||||
allLibraryFiles = allLibraryFiles,
|
|
||||||
syncedFolders = internalState.syncedFolders,
|
|
||||||
sortFiles = ::sortFiles
|
|
||||||
).also { shelves ->
|
|
||||||
shelves.forEach { shelf ->
|
|
||||||
shelvedBookIds.addAll(shelf.books.map { it.bookId })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allShelves.addAll(folderShelves)
|
|
||||||
|
|
||||||
val unshelvedBooks = allLibraryFiles.filter { it.bookId !in shelvedBookIds }
|
|
||||||
allShelves.add(Shelf("unshelved", "Unshelved", ShelfType.MANUAL, sortFiles(unshelvedBooks)))
|
|
||||||
|
|
||||||
allShelves.sortWith(compareBy({ it.type.ordinal }, { it.sortKey }))
|
|
||||||
|
|
||||||
val validShelfIds = allShelves.mapTo(mutableSetOf()) { it.id }
|
|
||||||
val viewingShelfId = internalState.viewingShelfId?.takeIf { it in validShelfIds }
|
|
||||||
val selectedShelfIds = internalState.contextualActionShelfIds.filterTo(mutableSetOf()) { it in validShelfIds }
|
|
||||||
|
|
||||||
val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && viewingShelfId != null) {
|
|
||||||
val currentShelfBookIds = allShelves
|
|
||||||
.find { it.id == viewingShelfId }
|
|
||||||
?.books
|
|
||||||
?.map { it.bookId }
|
|
||||||
?.toSet()
|
|
||||||
?: emptySet()
|
|
||||||
when (internalState.addBooksSource) {
|
|
||||||
AddBooksSource.UNSHELVED -> unshelvedBooks
|
|
||||||
AddBooksSource.ALL_BOOKS -> allLibraryFiles.filter { it.bookId !in currentShelfBookIds }
|
|
||||||
}
|
|
||||||
} else emptyList()
|
|
||||||
|
|
||||||
internalState.copy(
|
|
||||||
recentFiles = visibleRecentFiles,
|
|
||||||
allRecentFiles = sortedLibraryFiles,
|
|
||||||
rawLibraryFiles = allLibraryFiles,
|
|
||||||
viewingShelfId = viewingShelfId,
|
|
||||||
isAddingBooksToShelf = internalState.isAddingBooksToShelf && viewingShelfId != null,
|
|
||||||
contextualActionShelfIds = selectedShelfIds,
|
|
||||||
contextualActionItems = internalState.contextualActionItems.mapNotNull { ctx -> allLibraryFiles.find { it.bookId == ctx.bookId } }.toSet(),
|
|
||||||
shelves = allShelves,
|
|
||||||
openTabs = openTabsList,
|
|
||||||
booksAvailableForAdding = booksAvailableForAdding,
|
|
||||||
allTags = dbTags
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
initialValue = _internalState.value
|
initialValue = _internalState.value
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class FolderShelfAccumulator(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val depth: Int,
|
|
||||||
val parentShelfId: String?,
|
|
||||||
val sortPath: String,
|
|
||||||
val books: MutableList<RecentFileItem> = mutableListOf(),
|
|
||||||
val directBooks: MutableList<RecentFileItem> = mutableListOf(),
|
|
||||||
val childShelfIds: MutableList<String> = mutableListOf()
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun buildFolderShelves(
|
|
||||||
allLibraryFiles: List<RecentFileItem>,
|
|
||||||
syncedFolders: List<SyncedFolder>,
|
|
||||||
sortFiles: (List<RecentFileItem>) -> List<RecentFileItem>
|
|
||||||
): List<Shelf> {
|
|
||||||
val folderNamesByUri = syncedFolders.associate { it.uriString to it.name }
|
|
||||||
|
|
||||||
return allLibraryFiles
|
|
||||||
.filter { it.sourceFolderUri != null }
|
|
||||||
.groupBy { it.sourceFolderUri!! }
|
|
||||||
.flatMap { (folderUri, books) ->
|
|
||||||
val rootName = folderNamesByUri[folderUri] ?: "Local Folder"
|
|
||||||
val rootShelfId = "folder_$folderUri"
|
|
||||||
val rootAccumulator = FolderShelfAccumulator(
|
|
||||||
id = rootShelfId,
|
|
||||||
name = rootName,
|
|
||||||
depth = 0,
|
|
||||||
parentShelfId = null,
|
|
||||||
sortPath = ""
|
|
||||||
)
|
|
||||||
val rootShelf = Shelf(
|
|
||||||
id = rootShelfId,
|
|
||||||
name = rootName,
|
|
||||||
type = ShelfType.FOLDER,
|
|
||||||
books = sortFiles(books),
|
|
||||||
directBooks = mutableListOf<RecentFileItem>().also { direct ->
|
|
||||||
direct.addAll(books.filter { getRelativeFolderSegments(it).isEmpty() })
|
|
||||||
},
|
|
||||||
childShelfIds = emptyList(),
|
|
||||||
depth = 0,
|
|
||||||
sortKey = "folder:${rootName.lowercase()}:"
|
|
||||||
)
|
|
||||||
|
|
||||||
val nestedShelves = linkedMapOf<String, FolderShelfAccumulator>()
|
|
||||||
books.forEach { book ->
|
|
||||||
rootAccumulator.books.add(book)
|
|
||||||
val segments = getRelativeFolderSegments(book)
|
|
||||||
if (segments.isEmpty()) {
|
|
||||||
rootAccumulator.directBooks.add(book)
|
|
||||||
}
|
|
||||||
var currentPath = ""
|
|
||||||
var parentShelfId = rootShelfId
|
|
||||||
segments.forEachIndexed { index, segment ->
|
|
||||||
currentPath = if (currentPath.isEmpty()) segment else "$currentPath/$segment"
|
|
||||||
val shelfId = "folder_$folderUri::$currentPath"
|
|
||||||
val accumulator = nestedShelves.getOrPut(currentPath) {
|
|
||||||
val newShelf = FolderShelfAccumulator(
|
|
||||||
id = shelfId,
|
|
||||||
name = segment,
|
|
||||||
depth = index + 1,
|
|
||||||
parentShelfId = parentShelfId,
|
|
||||||
sortPath = currentPath.lowercase()
|
|
||||||
)
|
|
||||||
if (parentShelfId == rootShelfId) {
|
|
||||||
rootAccumulator.childShelfIds.add(shelfId)
|
|
||||||
} else {
|
|
||||||
nestedShelves.values.find { it.id == parentShelfId }?.childShelfIds?.add(shelfId)
|
|
||||||
}
|
|
||||||
newShelf
|
|
||||||
}
|
|
||||||
accumulator.books.add(book)
|
|
||||||
if (index == segments.lastIndex) {
|
|
||||||
accumulator.directBooks.add(book)
|
|
||||||
}
|
|
||||||
parentShelfId = shelfId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val sortedNestedShelves = nestedShelves
|
|
||||||
.values
|
|
||||||
.sortedBy { it.sortPath }
|
|
||||||
.map { shelf ->
|
|
||||||
Shelf(
|
|
||||||
id = shelf.id,
|
|
||||||
name = shelf.name,
|
|
||||||
type = ShelfType.FOLDER,
|
|
||||||
books = sortFiles(shelf.books),
|
|
||||||
directBooks = sortFiles(shelf.directBooks),
|
|
||||||
parentShelfId = shelf.parentShelfId,
|
|
||||||
childShelfIds = shelf.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() },
|
|
||||||
depth = shelf.depth,
|
|
||||||
sortKey = "folder:${rootName.lowercase()}:${shelf.sortPath}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
listOf(
|
|
||||||
rootShelf.copy(
|
|
||||||
directBooks = sortFiles(rootAccumulator.directBooks),
|
|
||||||
childShelfIds = rootAccumulator.childShelfIds.sortedBy { it.substringAfterLast("::").lowercase() }
|
|
||||||
)
|
|
||||||
) + sortedNestedShelves
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getRelativeFolderSegments(item: RecentFileItem): List<String> {
|
|
||||||
val documentUriString = item.uriString ?: return emptyList()
|
|
||||||
val rootFolderUriString = item.sourceFolderUri ?: return emptyList()
|
|
||||||
|
|
||||||
return try {
|
|
||||||
val documentUri = documentUriString.toUri()
|
|
||||||
val rootFolderUri = rootFolderUriString.toUri()
|
|
||||||
val rootDocId = DocumentsContract.getTreeDocumentId(rootFolderUri)
|
|
||||||
val documentId = when {
|
|
||||||
DocumentsContract.isDocumentUri(appContext, documentUri) -> DocumentsContract.getDocumentId(documentUri)
|
|
||||||
DocumentsContract.isTreeUri(documentUri) -> DocumentsContract.getTreeDocumentId(documentUri)
|
|
||||||
else -> return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
val rootPath = rootDocId.substringAfter(':', "")
|
|
||||||
val documentPath = documentId.substringAfter(':', "")
|
|
||||||
val relativeDocumentPath = when {
|
|
||||||
rootPath.isBlank() -> documentPath
|
|
||||||
documentPath == rootPath -> ""
|
|
||||||
documentPath.startsWith("$rootPath/") -> documentPath.removePrefix("$rootPath/")
|
|
||||||
else -> documentPath
|
|
||||||
}
|
|
||||||
|
|
||||||
relativeDocumentPath
|
|
||||||
.substringBeforeLast('/', "")
|
|
||||||
.split('/')
|
|
||||||
.map { Uri.decode(it).trim() }
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.tag("FolderShelves").w(e, "Failed to derive relative folder path for ${item.displayName}")
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTabsEnabled(enabled: Boolean) {
|
fun setTabsEnabled(enabled: Boolean) {
|
||||||
prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) }
|
prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) }
|
||||||
_internalState.update { it.copy(isTabsEnabled = enabled) }
|
_internalState.update { it.copy(isTabsEnabled = enabled) }
|
||||||
|
|
@ -1529,7 +1058,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
Timber.d("ViewModel instance created.")
|
Timber.d("ViewModel instance created.")
|
||||||
WorkManager.getInstance(application).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
WorkManager.getInstance(application).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
||||||
|
|
||||||
// --- ADD THIS BLOCK ---
|
|
||||||
val locatorConverter = LocatorConverter(
|
val locatorConverter = LocatorConverter(
|
||||||
bookCacheDao,
|
bookCacheDao,
|
||||||
ProtoBuf { serializersModule = semanticBlockModule },
|
ProtoBuf { serializersModule = semanticBlockModule },
|
||||||
|
|
@ -1814,7 +1342,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
inputStream = inputStream,
|
inputStream = inputStream,
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
originalBookNameHint = displayName
|
originalBookNameHint = displayName
|
||||||
) ?: throw Exception("MobiParser returned null. The file might be DRM-protected or invalid.")
|
) ?: throw Exception(
|
||||||
|
if (MobiParser.isNativeParserAvailable) {
|
||||||
|
"MobiParser returned null. The file might be DRM-protected or invalid."
|
||||||
|
} else {
|
||||||
|
MobiParser.nativeParserUnavailableMessage()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
FileType.FB2 -> fb2Parser.createFb2Book(
|
FileType.FB2 -> fb2Parser.createFb2Book(
|
||||||
inputStream = inputStream,
|
inputStream = inputStream,
|
||||||
|
|
@ -1860,10 +1394,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
if (latestState.selectedBookId != bookId || latestState.selectedEpubUri != uri) {
|
if (latestState.selectedBookId != bookId || latestState.selectedEpubUri != uri) {
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
if (latestState.selectedEpubBook?.extractionBasePath?.let { path ->
|
if (latestState.selectedEpubBook?.hasReadableExtractedContent() == true) {
|
||||||
path.isNotBlank() && File(path).exists()
|
|
||||||
} == true
|
|
||||||
) {
|
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2822,7 +2353,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
scanSyncedFolder()
|
triggerFolderSyncWorker(
|
||||||
|
metadataOnly = false,
|
||||||
|
showFeedback = true,
|
||||||
|
targetFolderUriString = newFolder.uriString
|
||||||
|
)
|
||||||
|
|
||||||
showBanner(appContext.getString(R.string.banner_folder_added, name))
|
showBanner(appContext.getString(R.string.banner_folder_added, name))
|
||||||
|
|
||||||
|
|
@ -2835,6 +2370,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
fun removeSyncedFolder(folder: SyncedFolder) {
|
fun removeSyncedFolder(folder: SyncedFolder) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val workManager = WorkManager.getInstance(appContext)
|
||||||
|
ReaderPerfLog.d("FolderRemove request folder=${folder.uriString}")
|
||||||
|
workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME)
|
||||||
|
workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
|
||||||
|
|
||||||
val currentFolders = _internalState.value.syncedFolders.toMutableList()
|
val currentFolders = _internalState.value.syncedFolders.toMutableList()
|
||||||
currentFolders.removeAll { it.uriString == folder.uriString }
|
currentFolders.removeAll { it.uriString == folder.uriString }
|
||||||
|
|
||||||
|
|
@ -2842,9 +2382,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
_internalState.update { it.copy(syncedFolders = currentFolders) }
|
_internalState.update { it.copy(syncedFolders = currentFolders) }
|
||||||
|
|
||||||
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
|
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
|
||||||
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
|
|
||||||
|
|
||||||
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
|
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
|
||||||
|
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
|
||||||
try {
|
try {
|
||||||
appContext.contentResolver.releasePersistableUriPermission(
|
appContext.contentResolver.releasePersistableUriPermission(
|
||||||
folder.uriString.toUri(),
|
folder.uriString.toUri(),
|
||||||
|
|
@ -2855,7 +2394,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentFolders.isEmpty()) {
|
if (currentFolders.isEmpty()) {
|
||||||
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
showBanner(appContext.getString(R.string.banner_folder_removed))
|
showBanner(appContext.getString(R.string.banner_folder_removed))
|
||||||
|
|
@ -2870,16 +2409,33 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
triggerFolderSyncWorker(metadataOnly = false, showFeedback = true)
|
triggerFolderSyncWorker(metadataOnly = false, showFeedback = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun triggerFolderSyncWorker(metadataOnly: Boolean, showFeedback: Boolean) {
|
private fun triggerFolderSyncWorker(
|
||||||
|
metadataOnly: Boolean,
|
||||||
|
showFeedback: Boolean,
|
||||||
|
targetFolderUriString: String? = null
|
||||||
|
) {
|
||||||
val folders = _internalState.value.syncedFolders
|
val folders = _internalState.value.syncedFolders
|
||||||
if (folders.isEmpty()) return
|
if (folders.isEmpty()) return
|
||||||
|
|
||||||
Timber.tag("FolderSync")
|
val targetFolderName = targetFolderUriString
|
||||||
.d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly, feedback=$showFeedback)")
|
?.let { target -> folders.firstOrNull { it.uriString == target }?.name ?: target }
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"FolderSync request folders=${folders.size} target=${targetFolderName ?: "ALL"} " +
|
||||||
|
"metadataOnly=$metadataOnly feedback=$showFeedback"
|
||||||
|
)
|
||||||
|
|
||||||
val workManager = WorkManager.getInstance(appContext)
|
val workManager = WorkManager.getInstance(appContext)
|
||||||
|
if (!metadataOnly) {
|
||||||
|
workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
|
||||||
|
}
|
||||||
val data = androidx.work.Data.Builder()
|
val data = androidx.work.Data.Builder()
|
||||||
.putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly).build()
|
.putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly)
|
||||||
|
.apply {
|
||||||
|
if (!targetFolderUriString.isNullOrBlank()) {
|
||||||
|
putString(FolderSyncWorker.KEY_TARGET_FOLDER_URI, targetFolderUriString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
val request = OneTimeWorkRequestBuilder<FolderSyncWorker>().setInputData(data).build()
|
val request = OneTimeWorkRequestBuilder<FolderSyncWorker>().setInputData(data).build()
|
||||||
|
|
||||||
|
|
@ -2958,7 +2514,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
scanSyncedFolder()
|
triggerFolderSyncWorker(
|
||||||
|
metadataOnly = false,
|
||||||
|
showFeedback = true,
|
||||||
|
targetFolderUriString = folder.uriString
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2966,12 +2526,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
fun disconnectAllSyncedFolders() {
|
fun disconnectAllSyncedFolders() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val workManager = WorkManager.getInstance(appContext)
|
||||||
|
ReaderPerfLog.d("FolderRemove disconnect all folders=${_internalState.value.syncedFolders.size}")
|
||||||
|
workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME)
|
||||||
|
workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
||||||
|
workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
|
||||||
|
|
||||||
val folders = _internalState.value.syncedFolders
|
val folders = _internalState.value.syncedFolders
|
||||||
folders.forEach { folder ->
|
folders.forEach { folder ->
|
||||||
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
|
val filesToRemove = recentFilesRepository.getFilesBySourceFolder(folder.uriString)
|
||||||
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
|
|
||||||
|
|
||||||
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
|
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
|
||||||
|
filesToRemove.forEach { cleanupBookDataLocally(it.bookId) }
|
||||||
try {
|
try {
|
||||||
appContext.contentResolver.releasePersistableUriPermission(
|
appContext.contentResolver.releasePersistableUriPermission(
|
||||||
folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION
|
folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
|
@ -2985,8 +2550,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
remove(KEY_SYNCED_FOLDER_URI)
|
remove(KEY_SYNCED_FOLDER_URI)
|
||||||
}
|
}
|
||||||
_internalState.update { it.copy(syncedFolders = emptyList()) }
|
_internalState.update { it.copy(syncedFolders = emptyList()) }
|
||||||
|
|
||||||
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3869,6 +3432,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
val oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)
|
val oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)
|
||||||
val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet()
|
val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet()
|
||||||
val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet()
|
val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet()
|
||||||
|
val validActiveBookCacheDirs = allDbIds.mapTo(mutableSetOf()) {
|
||||||
|
ImportedFileCache.activeBookDirName(it)
|
||||||
|
}
|
||||||
ImportedFileCache.deleteStaleTemporaryBookDirs(appContext, TimeUnit.HOURS.toMillis(1))
|
ImportedFileCache.deleteStaleTemporaryBookDirs(appContext, TimeUnit.HOURS.toMillis(1))
|
||||||
|
|
||||||
cacheDir.listFiles()?.forEach { file ->
|
cacheDir.listFiles()?.forEach { file ->
|
||||||
|
|
@ -3879,10 +3445,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
if (deleted) Timber.d("Sweeper cleaned old temp file: $name")
|
if (deleted) Timber.d("Sweeper cleaned old temp file: $name")
|
||||||
}
|
}
|
||||||
} else if (ImportedFileCache.isActiveBookDir(name)) {
|
} else if (ImportedFileCache.isActiveBookDir(name)) {
|
||||||
val bookId = name.removePrefix("imported_file_")
|
val legacyBookId = name.removePrefix("imported_file_")
|
||||||
if (bookId !in allDbIds) {
|
if (name !in validActiveBookCacheDirs && legacyBookId !in allDbIds && file.lastModified() < oneHourAgo) {
|
||||||
val deleted = file.deleteRecursively()
|
val deleted = file.deleteRecursively()
|
||||||
if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache for: $bookId")
|
if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache: $name")
|
||||||
}
|
}
|
||||||
} else if (name.startsWith("opds_stream_")) {
|
} else if (name.startsWith("opds_stream_")) {
|
||||||
val bookIdHash = name.removePrefix("opds_stream_")
|
val bookIdHash = name.removePrefix("opds_stream_")
|
||||||
|
|
@ -4274,6 +3840,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null
|
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null
|
||||||
) {
|
) {
|
||||||
val openBookStartTime = System.currentTimeMillis()
|
val openBookStartTime = System.currentTimeMillis()
|
||||||
|
ReaderPerfLog.d("FileOpen start bookId=$bookId type=$type")
|
||||||
Timber.tag("FileOpenPerf")
|
Timber.tag("FileOpenPerf")
|
||||||
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
|
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
|
||||||
|
|
||||||
|
|
@ -4341,12 +3908,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
||||||
|
|
||||||
if (recentItem?.sourceFolderUri != null) {
|
|
||||||
launch(Dispatchers.IO) {
|
|
||||||
recentFilesRepository.syncLocalMetadataToFolder(bookId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.tag("FileOpenPerf")
|
Timber.tag("FileOpenPerf")
|
||||||
.d("[$bookId] Branch: PDF | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
.d("[$bookId] Branch: PDF | elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
||||||
_internalState.update {
|
_internalState.update {
|
||||||
|
|
@ -4357,6 +3918,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
||||||
persistReaderSession(bookId, type)
|
persistReaderSession(bookId, type)
|
||||||
addFileToRecent(
|
addFileToRecent(
|
||||||
uri,
|
uri,
|
||||||
|
|
@ -4378,11 +3940,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) {
|
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
val recentItem = recentFilesRepository.getFileByBookId(bookId)
|
||||||
if (recentItem?.sourceFolderUri != null) {
|
|
||||||
launch(Dispatchers.IO) {
|
|
||||||
recentFilesRepository.syncLocalMetadataToFolder(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 =
|
||||||
|
|
@ -4405,6 +3962,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
initialHighlightsJson = recentItem?.highlightsJson,
|
initialHighlightsJson = recentItem?.highlightsJson,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
ReaderPerfLog.d("FileOpen ready bookId=$bookId type=$type elapsed=${System.currentTimeMillis() - openBookStartTime}ms")
|
||||||
persistReaderSession(bookId, type)
|
persistReaderSession(bookId, type)
|
||||||
|
|
||||||
if (!suppressNavigation) {
|
if (!suppressNavigation) {
|
||||||
|
|
@ -4617,87 +4175,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
"text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML
|
"text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML
|
||||||
|
|
||||||
"text/plain" -> {
|
"text/plain" -> {
|
||||||
if (fileName?.endsWith(".md", ignoreCase = true) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true) {
|
resolveFileTypeFromName(fileName) ?: FileType.TXT
|
||||||
FileType.MD
|
|
||||||
} else if (fileName?.let {
|
|
||||||
it.endsWith(".csv", ignoreCase = true) || it.endsWith(".tsv", ignoreCase = true) ||
|
|
||||||
it.endsWith(".json", ignoreCase = true) || it.endsWith(".xml", ignoreCase = true) ||
|
|
||||||
it.endsWith(".log", ignoreCase = true) || it.endsWith(".java", ignoreCase = true) ||
|
|
||||||
it.endsWith(".kt", ignoreCase = true) || it.endsWith(".py", ignoreCase = true) ||
|
|
||||||
it.endsWith(".js", ignoreCase = true) || it.endsWith(".cpp", ignoreCase = true) ||
|
|
||||||
it.endsWith(".c", ignoreCase = true) || it.endsWith(".cs", ignoreCase = true) ||
|
|
||||||
it.endsWith(".rb", ignoreCase = true) || it.endsWith(".go", ignoreCase = true)
|
|
||||||
} == true) {
|
|
||||||
FileType.HTML
|
|
||||||
} else {
|
|
||||||
FileType.TXT
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
when {
|
resolveFileTypeFromName(fileName)
|
||||||
fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ
|
|
||||||
fileName?.endsWith(".cbr", ignoreCase = true) == true -> FileType.CBR
|
|
||||||
fileName?.endsWith(".cb7", ignoreCase = true) == true -> FileType.CB7
|
|
||||||
fileName?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF
|
|
||||||
fileName?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB
|
|
||||||
fileName?.endsWith(
|
|
||||||
".mobi",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true || fileName?.endsWith(
|
|
||||||
".azw3",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true || fileName?.endsWith(
|
|
||||||
".prc",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true -> FileType.MOBI
|
|
||||||
|
|
||||||
fileName?.endsWith(
|
|
||||||
".md",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true || fileName?.endsWith(
|
|
||||||
".markdown",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true -> FileType.MD
|
|
||||||
|
|
||||||
fileName?.endsWith(".txt", ignoreCase = true) == true -> FileType.TXT
|
|
||||||
fileName?.endsWith(
|
|
||||||
".fb2",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true || fileName?.endsWith(
|
|
||||||
".fb2.zip",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true -> FileType.FB2
|
|
||||||
fileName?.endsWith(
|
|
||||||
".html",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true || fileName?.endsWith(
|
|
||||||
".xhtml",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true || fileName?.endsWith(
|
|
||||||
".htm",
|
|
||||||
ignoreCase = true
|
|
||||||
) == true -> FileType.HTML
|
|
||||||
fileName?.endsWith(".docx", ignoreCase = true) == true -> FileType.DOCX
|
|
||||||
fileName?.endsWith(".odt", ignoreCase = true) == true -> FileType.ODT
|
|
||||||
fileName?.endsWith(".fodt", ignoreCase = true) == true -> FileType.FODT
|
|
||||||
fileName?.endsWith(".csv", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".tsv", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".json", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".xml", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".log", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".java", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".kt", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".py", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".js", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".cpp", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".c", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".cs", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".rb", ignoreCase = true) == true ||
|
|
||||||
fileName?.endsWith(".go", ignoreCase = true) == true -> FileType.HTML
|
|
||||||
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4739,7 +4221,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
|
if (MobiParser.isNativeParserAvailable) {
|
||||||
"MobiParser returned null. The file might be DRM-protected or invalid."
|
"MobiParser returned null. The file might be DRM-protected or invalid."
|
||||||
|
} else {
|
||||||
|
MobiParser.nativeParserUnavailableMessage()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -4945,6 +4431,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onRecentFileClicked(item: RecentFileItem) {
|
fun onRecentFileClicked(item: RecentFileItem) {
|
||||||
|
ReaderPerfLog.d("FileOpen click bookId=${item.bookId} name=${item.displayName}")
|
||||||
val currentSelection = _internalState.value.contextualActionItems
|
val currentSelection = _internalState.value.contextualActionItems
|
||||||
if (currentSelection.isNotEmpty()) {
|
if (currentSelection.isNotEmpty()) {
|
||||||
Timber.d("Toggling selection for: ${item.displayName}")
|
Timber.d("Toggling selection for: ${item.displayName}")
|
||||||
|
|
@ -5125,6 +4612,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
fun setMainScreenPage(page: Int) {
|
fun setMainScreenPage(page: Int) {
|
||||||
val sanitizedPage = page.coerceIn(0, 1)
|
val sanitizedPage = page.coerceIn(0, 1)
|
||||||
|
if (_internalState.value.mainScreenStartPage == sanitizedPage) return
|
||||||
_internalState.update { it.copy(mainScreenStartPage = sanitizedPage) }
|
_internalState.update { it.copy(mainScreenStartPage = sanitizedPage) }
|
||||||
persistLibraryLandingState()
|
persistLibraryLandingState()
|
||||||
}
|
}
|
||||||
|
|
@ -5132,6 +4620,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
fun setLibraryScreenPage(page: Int) {
|
fun setLibraryScreenPage(page: Int) {
|
||||||
val maxLibraryPage = if (BuildConfig.IS_OFFLINE) 2 else 3
|
val maxLibraryPage = if (BuildConfig.IS_OFFLINE) 2 else 3
|
||||||
val sanitizedPage = page.coerceIn(0, maxLibraryPage)
|
val sanitizedPage = page.coerceIn(0, maxLibraryPage)
|
||||||
|
if (_internalState.value.libraryScreenStartPage == sanitizedPage) return
|
||||||
_internalState.update {
|
_internalState.update {
|
||||||
it.copy(libraryScreenStartPage = sanitizedPage)
|
it.copy(libraryScreenStartPage = sanitizedPage)
|
||||||
}
|
}
|
||||||
|
|
@ -5701,9 +5190,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) }
|
prefs.edit { putString(KEY_APP_CONTRAST_OPTION, option.name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAppTextDimFactor(factor: Float) {
|
fun setAppTextDimFactorLight(factor: Float) {
|
||||||
_internalState.update { it.copy(appTextDimFactor = factor) }
|
_internalState.update { it.copy(appTextDimFactorLight = factor) }
|
||||||
prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR, factor) }
|
prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_LIGHT, factor) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAppTextDimFactorDark(factor: Float) {
|
||||||
|
_internalState.update { it.copy(appTextDimFactorDark = factor) }
|
||||||
|
prefs.edit { putFloat(KEY_APP_TEXT_DIM_FACTOR_DARK, factor) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAppSeedColor(color: androidx.compose.ui.graphics.Color?) {
|
fun setAppSeedColor(color: androidx.compose.ui.graphics.Color?) {
|
||||||
|
|
@ -5948,6 +5442,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option"
|
private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option"
|
||||||
private const val KEY_APP_SEED_COLOR = "app_seed_color"
|
private const val KEY_APP_SEED_COLOR = "app_seed_color"
|
||||||
private const val KEY_APP_TEXT_DIM_FACTOR = "app_text_dim_factor"
|
private const val KEY_APP_TEXT_DIM_FACTOR = "app_text_dim_factor"
|
||||||
|
private const val KEY_APP_TEXT_DIM_FACTOR_LIGHT = "app_text_dim_factor_light"
|
||||||
|
private const val KEY_APP_TEXT_DIM_FACTOR_DARK = "app_text_dim_factor_dark"
|
||||||
private const val KEY_CUSTOM_APP_THEMES = "custom_app_themes"
|
private const val KEY_CUSTOM_APP_THEMES = "custom_app_themes"
|
||||||
|
|
||||||
val SUPPORTED_MIME_TYPES = arrayOf(
|
val SUPPORTED_MIME_TYPES = arrayOf(
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,20 @@
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import android.util.Xml
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import com.aryan.reader.data.RecentFilesRepository
|
import com.aryan.reader.data.RecentFilesRepository
|
||||||
import com.aryan.reader.epub.EpubParser
|
|
||||||
import com.aryan.reader.epub.ImportedFileCache
|
|
||||||
import com.aryan.reader.epub.MobiParser
|
|
||||||
import com.aryan.reader.pdf.PdfCoverGenerator
|
|
||||||
import io.legere.pdfiumandroid.PdfiumCore
|
import io.legere.pdfiumandroid.PdfiumCore
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
class MetadataExtractionWorker(
|
class MetadataExtractionWorker(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
|
|
@ -22,179 +23,271 @@ class MetadataExtractionWorker(
|
||||||
) : CoroutineWorker(appContext, workerParams) {
|
) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
|
||||||
private val recentFilesRepository = RecentFilesRepository(appContext)
|
private val recentFilesRepository = RecentFilesRepository(appContext)
|
||||||
private val epubParser = EpubParser(appContext)
|
|
||||||
private val mobiParser = MobiParser(appContext)
|
|
||||||
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
|
|
||||||
private val odtParser = com.aryan.reader.epub.OdtParser(appContext)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val WORK_NAME = "MetadataExtractionWorker"
|
const val WORK_NAME = "MetadataExtractionWorker"
|
||||||
|
const val KEY_SOURCE_FOLDER_URI = "key_source_folder_uri"
|
||||||
|
private const val METADATA_DB_BATCH_SIZE = 100
|
||||||
|
private const val METADATA_PROGRESS_LOG_EVERY = 250
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||||
|
val workerStart = ReaderPerfLog.nowNanos()
|
||||||
|
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
|
||||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
val hasLegacy = prefs.contains("synced_folder_uri")
|
val hasLegacy = prefs.contains("synced_folder_uri")
|
||||||
val hasNew = prefs.contains("synced_folders_list_json")
|
val hasNew = prefs.contains("synced_folders_list_json")
|
||||||
|
|
||||||
if (!hasLegacy && !hasNew) {
|
if (!hasLegacy && !hasNew) {
|
||||||
Timber.tag("MetadataWorker").w("No folders linked. Stopping.")
|
ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
|
||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val filesToProcess = recentFilesRepository.getFolderBooksWithoutCovers()
|
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(sourceFolderUri)
|
||||||
|
|
||||||
if (filesToProcess.isEmpty()) {
|
if (filesToProcess.isEmpty()) {
|
||||||
|
ReaderPerfLog.d("MetadataWorker skipped: no text metadata pending folder=${sourceFolderUri ?: "ALL"}")
|
||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag("MetadataWorker").i("Starting background metadata extraction for ${filesToProcess.size} books.")
|
ReaderPerfLog.i(
|
||||||
|
"MetadataWorker start mode=text-only books=${filesToProcess.size} folder=${sourceFolderUri ?: "ALL"}"
|
||||||
|
)
|
||||||
|
|
||||||
|
val pendingUpdates = mutableListOf<RecentFileItem>()
|
||||||
|
var processed = 0
|
||||||
|
var updated = 0
|
||||||
|
var failed = 0
|
||||||
|
|
||||||
|
suspend fun flushUpdates() {
|
||||||
|
if (pendingUpdates.isEmpty()) return
|
||||||
|
val flushStart = ReaderPerfLog.nowNanos()
|
||||||
|
recentFilesRepository.updateExtractedMetadata(pendingUpdates)
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"MetadataWorker DB flush rows=${pendingUpdates.size} elapsed=${ReaderPerfLog.elapsedMs(flushStart)}ms"
|
||||||
|
)
|
||||||
|
pendingUpdates.clear()
|
||||||
|
}
|
||||||
|
|
||||||
filesToProcess.forEach { item ->
|
filesToProcess.forEach { item ->
|
||||||
if (isStopped) return@forEach
|
if (isStopped) return@forEach
|
||||||
|
|
||||||
if (item.sourceFolderUri == null) return@forEach
|
if (item.sourceFolderUri == null) return@forEach
|
||||||
|
|
||||||
val tempExtractionDir =
|
|
||||||
if (item.type == FileType.EPUB || item.type == FileType.MOBI || item.type == FileType.ODT || item.type == FileType.FODT) {
|
|
||||||
ImportedFileCache.createTemporaryBookDir(appContext, item.bookId, "metadata")
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val uri = item.uriString?.toUri() ?: return@forEach
|
val uri = item.uriString?.toUri() ?: return@forEach
|
||||||
val type = item.type
|
val fileSize = item.fileSize.takeIf { it > 0L } ?: queryFileSize(uri)
|
||||||
|
val metadata = when (item.type) {
|
||||||
|
FileType.EPUB -> parseEpubTextMetadata(uri)
|
||||||
|
FileType.PDF -> parsePdfTextMetadata(uri)
|
||||||
|
FileType.ODT -> parseZipTextMetadata(uri, "meta.xml")
|
||||||
|
FileType.FODT -> parseFlatXmlTextMetadata(uri)
|
||||||
|
FileType.DOCX -> parseZipTextMetadata(uri, "docProps/core.xml")
|
||||||
|
else -> TextMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
var coverPath: String? = null
|
val title = sanitizeTitle(metadata.title)
|
||||||
var title: String? = null
|
val author = sanitizeAuthor(metadata.author)
|
||||||
var author: String? = null
|
val sizeChanged = fileSize > 0L && fileSize != item.fileSize
|
||||||
|
val titleChanged = title != null && title != item.title
|
||||||
|
val authorChanged = author != null && author != item.author
|
||||||
|
|
||||||
val fileSize = try {
|
if (!item.folderTextMetadataParsed || sizeChanged || titleChanged || authorChanged) {
|
||||||
|
pendingUpdates.add(
|
||||||
|
item.copy(
|
||||||
|
title = title ?: item.title ?: item.displayName,
|
||||||
|
author = author ?: item.author,
|
||||||
|
fileSize = if (fileSize > 0L) fileSize else item.fileSize,
|
||||||
|
folderTextMetadataParsed = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (sizeChanged || titleChanged || authorChanged) {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
if (pendingUpdates.size >= METADATA_DB_BATCH_SIZE) {
|
||||||
|
flushUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processed++
|
||||||
|
if (processed % METADATA_PROGRESS_LOG_EVERY == 0) {
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"MetadataWorker progress mode=text-only processed=$processed updated=$updated failed=$failed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
failed++
|
||||||
|
Timber.tag("MetadataWorker").e(e, "Failed text metadata extraction for ${item.displayName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushUpdates()
|
||||||
|
|
||||||
|
ReaderPerfLog.i(
|
||||||
|
"MetadataWorker finished mode=text-only processed=$processed updated=$updated failed=$failed " +
|
||||||
|
"elapsed=${ReaderPerfLog.elapsedMs(workerStart)}ms folder=${sourceFolderUri ?: "ALL"}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return@withContext Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("MetadataWorker").e(e, "Text metadata extraction failed")
|
||||||
|
return@withContext Result.failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryFileSize(uri: android.net.Uri): Long {
|
||||||
|
return try {
|
||||||
if (uri.scheme == "file") {
|
if (uri.scheme == "file") {
|
||||||
uri.path?.let { File(it).length() } ?: 0L
|
uri.path?.let { File(it).length() } ?: 0L
|
||||||
} else {
|
} else {
|
||||||
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
if (cursor.moveToFirst()) {
|
if (cursor.moveToFirst()) {
|
||||||
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
if (sizeIndex != -1) cursor.getLong(sizeIndex) else 0L
|
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) cursor.getLong(sizeIndex) else 0L
|
||||||
} else 0L
|
} else {
|
||||||
|
0L
|
||||||
|
}
|
||||||
} ?: 0L
|
} ?: 0L
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("MetadataWorker").e(e, "Failed to get file size for ${item.displayName}")
|
Timber.tag("MetadataWorker").e(e, "Failed to query file size for $uri")
|
||||||
0L
|
0L
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
|
private fun parseEpubTextMetadata(uri: android.net.Uri): TextMetadata {
|
||||||
when (type) {
|
val opfEntries = linkedMapOf<String, String>()
|
||||||
FileType.EPUB -> {
|
var containerXml: String? = null
|
||||||
val book = epubParser.createEpubBook(
|
|
||||||
inputStream = inputStream,
|
|
||||||
bookId = item.bookId,
|
|
||||||
originalBookNameHint = item.displayName,
|
|
||||||
parseContent = false,
|
|
||||||
extractionDirOverride = tempExtractionDir
|
|
||||||
)
|
|
||||||
title = book.title.takeIf { it.isNotBlank() && it != "content" }
|
|
||||||
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
|
||||||
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
|
|
||||||
}
|
|
||||||
FileType.MOBI -> {
|
|
||||||
val book = mobiParser.createMobiBook(
|
|
||||||
inputStream = inputStream,
|
|
||||||
bookId = item.bookId,
|
|
||||||
originalBookNameHint = item.displayName,
|
|
||||||
parseContent = false,
|
|
||||||
extractionDirOverride = tempExtractionDir
|
|
||||||
)
|
|
||||||
book?.let {
|
|
||||||
title = it.title.takeIf { t -> t.isNotBlank() && t != "content" }
|
|
||||||
author = it.author.takeIf { a -> a.isNotBlank() && !a.equals("Unknown", ignoreCase = true) }
|
|
||||||
it.coverImage?.let { img -> coverPath = recentFilesRepository.saveCoverToCache(img, uri) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FileType.PDF -> {
|
|
||||||
pdfCoverGenerator.generateCover(uri)?.let {
|
|
||||||
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
|
|
||||||
}
|
|
||||||
title = item.displayName
|
|
||||||
|
|
||||||
try {
|
appContext.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
ZipInputStream(input.buffered()).use { zip ->
|
||||||
|
while (true) {
|
||||||
|
val entry = zip.nextEntry ?: break
|
||||||
|
if (entry.isDirectory) continue
|
||||||
|
val name = entry.name
|
||||||
|
when {
|
||||||
|
name == "META-INF/container.xml" -> containerXml = zip.readTextEntry()
|
||||||
|
name.endsWith(".opf", ignoreCase = true) -> opfEntries[name] = zip.readTextEntry()
|
||||||
|
}
|
||||||
|
zip.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val opfPath = containerXml?.let { parseEpubRootfilePath(it) }
|
||||||
|
val opfXml = opfPath?.let { opfEntries[it] } ?: opfEntries.values.firstOrNull()
|
||||||
|
return opfXml?.let { parseXmlTextMetadata(it) } ?: TextMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseZipTextMetadata(uri: android.net.Uri, targetEntryName: String): TextMetadata {
|
||||||
|
appContext.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
ZipInputStream(input.buffered()).use { zip ->
|
||||||
|
while (true) {
|
||||||
|
val entry = zip.nextEntry ?: break
|
||||||
|
if (!entry.isDirectory && entry.name == targetEntryName) {
|
||||||
|
val xml = zip.readTextEntry()
|
||||||
|
return parseXmlTextMetadata(xml)
|
||||||
|
}
|
||||||
|
zip.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TextMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseFlatXmlTextMetadata(uri: android.net.Uri): TextMetadata {
|
||||||
|
val xml = appContext.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
input.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||||
|
} ?: return TextMetadata()
|
||||||
|
return parseXmlTextMetadata(xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parsePdfTextMetadata(uri: android.net.Uri): TextMetadata {
|
||||||
|
return try {
|
||||||
val pdfiumCore = PdfiumCore(appContext)
|
val pdfiumCore = PdfiumCore(appContext)
|
||||||
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
appContext.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||||
val pdfDocument = pdfiumCore.newDocument(pfd)
|
val pdfDocument = pdfiumCore.newDocument(pfd)
|
||||||
|
try {
|
||||||
val meta = pdfiumCore.getDocumentMeta(pdfDocument)
|
val meta = pdfiumCore.getDocumentMeta(pdfDocument)
|
||||||
|
TextMetadata(title = meta.title, author = meta.author)
|
||||||
val extractedTitle = meta.title
|
} finally {
|
||||||
if (!extractedTitle.isNullOrBlank()) {
|
|
||||||
title = extractedTitle
|
|
||||||
}
|
|
||||||
|
|
||||||
val extractedAuthor = meta.author
|
|
||||||
if (!extractedAuthor.isNullOrBlank()) {
|
|
||||||
author = extractedAuthor
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfiumCore.closeDocument(pdfDocument)
|
pdfiumCore.closeDocument(pdfDocument)
|
||||||
}
|
}
|
||||||
|
} ?: TextMetadata()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("MetadataWorker").e(e, "Failed to extract PDF metadata using PdfiumCore")
|
Timber.tag("MetadataWorker").e(e, "Failed to extract PDF text metadata")
|
||||||
|
TextMetadata()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FileType.ODT, FileType.FODT -> {
|
|
||||||
val book = odtParser.createOdtBook(
|
private fun parseEpubRootfilePath(containerXml: String): String? {
|
||||||
inputStream = inputStream,
|
val parser = Xml.newPullParser()
|
||||||
bookId = item.bookId,
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||||
originalBookNameHint = item.displayName,
|
parser.setInput(containerXml.reader())
|
||||||
isFlat = type == FileType.FODT,
|
|
||||||
parseContent = false,
|
var event = parser.eventType
|
||||||
extractionDirOverride = tempExtractionDir
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
if (event == XmlPullParser.START_TAG && parser.name.equals("rootfile", ignoreCase = true)) {
|
||||||
|
return parser.getAttributeValue(null, "full-path")?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseXmlTextMetadata(xml: String): TextMetadata {
|
||||||
|
val parser = Xml.newPullParser()
|
||||||
|
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||||
|
parser.setInput(xml.reader())
|
||||||
|
|
||||||
|
var title: String? = null
|
||||||
|
var author: String? = null
|
||||||
|
var event = parser.eventType
|
||||||
|
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
if (event == XmlPullParser.START_TAG) {
|
||||||
|
val name = parser.name.substringAfter(':').lowercase()
|
||||||
|
when {
|
||||||
|
title == null && name == "title" -> title = parser.nextTextOrNull()
|
||||||
|
author == null && (name == "creator" || name == "initial-creator") -> {
|
||||||
|
author = parser.nextTextOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextMetadata(title = title, author = author)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun XmlPullParser.nextTextOrNull(): String? {
|
||||||
|
return try {
|
||||||
|
nextText()?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipInputStream.readTextEntry(): String {
|
||||||
|
return String(readBytes(), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeTitle(value: String?): String? {
|
||||||
|
return value
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() && !it.equals("content", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeAuthor(value: String?): String? {
|
||||||
|
return value
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class TextMetadata(
|
||||||
|
val title: String? = null,
|
||||||
|
val author: String? = null
|
||||||
)
|
)
|
||||||
title = book.title.takeIf { it.isNotBlank() && it != "content" }
|
|
||||||
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
|
||||||
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
title = item.displayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (coverPath != null || title != null || author != null || fileSize > 0L) {
|
|
||||||
val updatedItem = item.copy(
|
|
||||||
coverImagePath = coverPath ?: item.coverImagePath,
|
|
||||||
title = title ?: item.title ?: item.displayName,
|
|
||||||
author = author ?: item.author,
|
|
||||||
fileSize = if (fileSize > 0L) fileSize else item.fileSize
|
|
||||||
)
|
|
||||||
recentFilesRepository.addRecentFile(updatedItem)
|
|
||||||
Timber.tag("MetadataWorker").d("Updated local metadata/size for: ${item.displayName} ($fileSize bytes)")
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}")
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
if (tempExtractionDir?.exists() == true) {
|
|
||||||
val deleted = tempExtractionDir.deleteRecursively()
|
|
||||||
if (deleted) {
|
|
||||||
Timber.tag("MetadataWorker")
|
|
||||||
.d("Cleaned up temporary extraction cache for ${item.bookId}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.tag("MetadataWorker")
|
|
||||||
.e(e, "Failed to clean up temporary extraction cache for ${item.bookId}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return@withContext Result.success()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.tag("MetadataWorker").e(e, "Metadata extraction failed")
|
|
||||||
return@withContext Result.failure()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
app/src/main/java/com/aryan/reader/NonReaderScreenModels.kt
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
|
||||||
|
data class HomeScreenModel(
|
||||||
|
val recentFiles: List<RecentFileItem>,
|
||||||
|
val openTabs: List<RecentFileItem>,
|
||||||
|
val selectedItems: Set<RecentFileItem>,
|
||||||
|
val isContextualModeActive: Boolean,
|
||||||
|
val deviceLimitState: DeviceLimitReachedState,
|
||||||
|
val isEmpty: Boolean,
|
||||||
|
val isLibraryEmpty: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ReaderScreenState.toHomeScreenModel(): HomeScreenModel {
|
||||||
|
val homeRecentFiles = recentFiles
|
||||||
|
return HomeScreenModel(
|
||||||
|
recentFiles = homeRecentFiles,
|
||||||
|
openTabs = openTabs,
|
||||||
|
selectedItems = contextualActionItems,
|
||||||
|
isContextualModeActive = contextualActionItems.isNotEmpty(),
|
||||||
|
deviceLimitState = deviceLimitState,
|
||||||
|
isEmpty = homeRecentFiles.isEmpty() && (!isTabsEnabled || openTabs.isEmpty()),
|
||||||
|
isLibraryEmpty = recentFiles.isEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LibraryScreenModel(
|
||||||
|
val selectedItems: Set<RecentFileItem>,
|
||||||
|
val isContextualModeActive: Boolean,
|
||||||
|
val selectedShelves: Set<String>,
|
||||||
|
val isShelfContextualModeActive: Boolean,
|
||||||
|
val sortOrder: SortOrder,
|
||||||
|
val shelves: List<Shelf>,
|
||||||
|
val rawLibraryFiles: List<RecentFileItem>,
|
||||||
|
val containsFolderItemsInSelection: Boolean,
|
||||||
|
val isSearchActive: Boolean,
|
||||||
|
val searchQuery: String
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ReaderScreenState.toLibraryScreenModel(): LibraryScreenModel {
|
||||||
|
return LibraryScreenModel(
|
||||||
|
selectedItems = contextualActionItems,
|
||||||
|
isContextualModeActive = contextualActionItems.isNotEmpty(),
|
||||||
|
selectedShelves = contextualActionShelfIds,
|
||||||
|
isShelfContextualModeActive = contextualActionShelfIds.isNotEmpty(),
|
||||||
|
sortOrder = sortOrder,
|
||||||
|
shelves = shelves,
|
||||||
|
rawLibraryFiles = rawLibraryFiles,
|
||||||
|
containsFolderItemsInSelection = contextualActionItems.any { it.sourceFolderUri != null },
|
||||||
|
isSearchActive = isSearchActive,
|
||||||
|
searchQuery = searchQuery
|
||||||
|
)
|
||||||
|
}
|
||||||
59
app/src/main/java/com/aryan/reader/ReaderPerfLog.kt
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
object ReaderPerfLog {
|
||||||
|
const val TAG = "ReaderPerf"
|
||||||
|
|
||||||
|
fun nowNanos(): Long = System.nanoTime()
|
||||||
|
|
||||||
|
fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000L
|
||||||
|
|
||||||
|
fun d(message: String) {
|
||||||
|
Timber.tag(TAG).d(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun i(message: String) {
|
||||||
|
Timber.tag(TAG).i(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun w(message: String) {
|
||||||
|
Timber.tag(TAG).w(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> measure(
|
||||||
|
name: String,
|
||||||
|
minLogMs: Long = 16L,
|
||||||
|
details: () -> String = { "" },
|
||||||
|
block: () -> T
|
||||||
|
): T {
|
||||||
|
val start = nowNanos()
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} finally {
|
||||||
|
val elapsed = elapsedMs(start)
|
||||||
|
if (elapsed >= minLogMs) {
|
||||||
|
val extra = details().takeIf { it.isNotBlank() }?.let { " $it" }.orEmpty()
|
||||||
|
d("$name took ${elapsed}ms$extra")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <T> measureSuspend(
|
||||||
|
name: String,
|
||||||
|
minLogMs: Long = 16L,
|
||||||
|
details: () -> String = { "" },
|
||||||
|
crossinline block: suspend () -> T
|
||||||
|
): T {
|
||||||
|
val start = nowNanos()
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} finally {
|
||||||
|
val elapsed = elapsedMs(start)
|
||||||
|
if (elapsed >= minLogMs) {
|
||||||
|
val extra = details().takeIf { it.isNotBlank() }?.let { " $it" }.orEmpty()
|
||||||
|
d("$name took ${elapsed}ms$extra")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
app/src/main/java/com/aryan/reader/SharedModelMappers.kt
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import com.aryan.reader.data.BookShelfCrossRef
|
||||||
|
import com.aryan.reader.data.BookTagCrossRef
|
||||||
|
import com.aryan.reader.data.RecentFileItem
|
||||||
|
import com.aryan.reader.data.ShelfEntity
|
||||||
|
import com.aryan.reader.data.TagEntity
|
||||||
|
import com.aryan.reader.shared.AddBooksSource as SharedAddBooksSource
|
||||||
|
import com.aryan.reader.shared.AppContrastOption as SharedAppContrastOption
|
||||||
|
import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode
|
||||||
|
import com.aryan.reader.shared.BannerMessage as SharedBannerMessage
|
||||||
|
import com.aryan.reader.shared.BookItem as SharedBookItem
|
||||||
|
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
|
||||||
|
import com.aryan.reader.shared.CustomAppTheme as SharedCustomAppTheme
|
||||||
|
import com.aryan.reader.shared.FileType as SharedFileType
|
||||||
|
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
|
||||||
|
import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter
|
||||||
|
import com.aryan.reader.shared.RenderMode as SharedRenderMode
|
||||||
|
import com.aryan.reader.shared.SharedLibraryProjectionInput
|
||||||
|
import com.aryan.reader.shared.SharedReaderScreenState
|
||||||
|
import com.aryan.reader.shared.ShelfRecord
|
||||||
|
import com.aryan.reader.shared.SortOrder as SharedSortOrder
|
||||||
|
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
|
||||||
|
import com.aryan.reader.shared.Tag as SharedTag
|
||||||
|
|
||||||
|
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
|
||||||
|
return SharedBookItem(
|
||||||
|
id = bookId,
|
||||||
|
path = uriString,
|
||||||
|
type = type.toSharedFileType(),
|
||||||
|
displayName = customName ?: displayName,
|
||||||
|
timestamp = timestamp,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
progressPercentage = progressPercentage,
|
||||||
|
isRecent = isRecent,
|
||||||
|
fileSize = fileSize,
|
||||||
|
sourceFolder = sourceFolderUri,
|
||||||
|
seriesName = seriesName,
|
||||||
|
seriesIndex = seriesIndex,
|
||||||
|
tags = tags.map { it.toSharedTag() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun TagEntity.toSharedTag(): SharedTag {
|
||||||
|
return SharedTag(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ShelfEntity.toSharedShelfRecord(): ShelfRecord {
|
||||||
|
return ShelfRecord(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
isSmart = isSmart,
|
||||||
|
smartRulesJson = smartRulesJson
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
|
||||||
|
return SharedBookShelfRef(
|
||||||
|
bookId = bookId,
|
||||||
|
shelfId = shelfId,
|
||||||
|
addedAt = addedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ReaderScreenState.toSharedReaderScreenState(
|
||||||
|
rawBooks: List<RecentFileItem> = rawLibraryFiles,
|
||||||
|
dbTags: List<TagEntity> = allTags
|
||||||
|
): SharedReaderScreenState {
|
||||||
|
return SharedReaderScreenState(
|
||||||
|
selectedBookId = selectedBookId,
|
||||||
|
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
|
||||||
|
selectedFileType = selectedFileType?.toSharedFileType(),
|
||||||
|
isLoading = isLoading,
|
||||||
|
errorMessage = errorMessage,
|
||||||
|
renderMode = renderMode.toSharedRenderMode(),
|
||||||
|
sortOrder = sortOrder.toSharedSortOrder(),
|
||||||
|
viewingShelfId = viewingShelfId,
|
||||||
|
isAddingBooksToShelf = isAddingBooksToShelf,
|
||||||
|
showCreateShelfDialog = showCreateShelfDialog,
|
||||||
|
mainScreenStartPage = mainScreenStartPage,
|
||||||
|
libraryScreenStartPage = libraryScreenStartPage,
|
||||||
|
showRenameShelfDialogFor = showRenameShelfDialogFor,
|
||||||
|
showDeleteShelfDialogFor = showDeleteShelfDialogFor,
|
||||||
|
addBooksSource = addBooksSource.toSharedAddBooksSource(),
|
||||||
|
booksSelectedForAdding = booksSelectedForAdding,
|
||||||
|
selectedBookIds = contextualActionItems.mapTo(mutableSetOf()) { it.bookId },
|
||||||
|
selectedShelfIds = contextualActionShelfIds,
|
||||||
|
isProUser = isProUser,
|
||||||
|
credits = credits,
|
||||||
|
isSyncEnabled = isSyncEnabled,
|
||||||
|
isFolderSyncEnabled = isFolderSyncEnabled,
|
||||||
|
bannerMessage = bannerMessage?.toSharedBannerMessage(),
|
||||||
|
downloadingBookIds = downloadingBookIds,
|
||||||
|
uploadingBookIds = uploadingBookIds,
|
||||||
|
syncedFolders = syncedFolders.map { it.toSharedSyncedFolder() },
|
||||||
|
lastFolderScanTime = lastFolderScanTime,
|
||||||
|
hasUnreadFeedback = hasUnreadFeedback,
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isSearchActive = isSearchActive,
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
reflowProgress = reflowProgress,
|
||||||
|
recentBooks = recentFiles.map { it.toSharedBookItem() },
|
||||||
|
libraryBooks = allRecentFiles.map { it.toSharedBookItem() },
|
||||||
|
rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
|
||||||
|
pinnedHomeBookIds = pinnedHomeBookIds,
|
||||||
|
pinnedLibraryBookIds = pinnedLibraryBookIds,
|
||||||
|
libraryFilters = libraryFilters.toSharedLibraryFilters(),
|
||||||
|
recentFilesLimit = recentFilesLimit,
|
||||||
|
isTabsEnabled = isTabsEnabled,
|
||||||
|
openTabIds = openTabIds,
|
||||||
|
openTabs = openTabs.map { it.toSharedBookItem() },
|
||||||
|
activeTabBookId = activeTabBookId,
|
||||||
|
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
|
||||||
|
externalFileBehavior = externalFileBehavior,
|
||||||
|
useStrictFileFilter = useStrictFileFilter,
|
||||||
|
appThemeMode = appThemeMode.toSharedAppThemeMode(),
|
||||||
|
appContrastOption = appContrastOption.toSharedAppContrastOption(),
|
||||||
|
appTextDimFactorLight = appTextDimFactorLight,
|
||||||
|
appTextDimFactorDark = appTextDimFactorDark,
|
||||||
|
appSeedColor = appSeedColor,
|
||||||
|
customAppThemes = customAppThemes.map { it.toSharedCustomAppTheme() },
|
||||||
|
allTags = dbTags.map { it.toSharedTag() },
|
||||||
|
showTagSelectionDialogFor = showTagSelectionDialogFor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ReaderScreenState.toSharedLibraryProjectionInput(
|
||||||
|
recentFilesFromDb: List<RecentFileItem>,
|
||||||
|
dbShelves: List<ShelfEntity>,
|
||||||
|
shelfRefs: List<BookShelfCrossRef>,
|
||||||
|
dbTags: List<TagEntity>,
|
||||||
|
tagRefs: List<BookTagCrossRef>
|
||||||
|
): SharedLibraryProjectionInput {
|
||||||
|
val tagsById = dbTags.associateBy { it.id }
|
||||||
|
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
|
||||||
|
entry.value.mapNotNull { tagsById[it.tagId] }
|
||||||
|
}
|
||||||
|
val taggedBooks = recentFilesFromDb.map { item ->
|
||||||
|
item.copy(tags = bookTagsMap[item.bookId].orEmpty())
|
||||||
|
}
|
||||||
|
return SharedLibraryProjectionInput(
|
||||||
|
state = toSharedReaderScreenState(
|
||||||
|
rawBooks = taggedBooks,
|
||||||
|
dbTags = dbTags
|
||||||
|
),
|
||||||
|
booksFromStore = taggedBooks
|
||||||
|
.filterNot { it.bookId.endsWith("_reflow") }
|
||||||
|
.map { it.toSharedBookItem() },
|
||||||
|
shelfRecords = dbShelves.map { it.toSharedShelfRecord() },
|
||||||
|
shelfRefs = shelfRefs.map { it.toSharedBookShelfRef() },
|
||||||
|
tags = dbTags.map { it.toSharedTag() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun FileType.toSharedFileType(): SharedFileType {
|
||||||
|
return runCatching { SharedFileType.valueOf(name) }.getOrDefault(SharedFileType.UNKNOWN)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RenderMode.toSharedRenderMode(): SharedRenderMode {
|
||||||
|
return SharedRenderMode.valueOf(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AddBooksSource.toSharedAddBooksSource(): SharedAddBooksSource {
|
||||||
|
return SharedAddBooksSource.valueOf(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SortOrder.toSharedSortOrder(): SharedSortOrder {
|
||||||
|
return SharedSortOrder.valueOf(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReadStatusFilter.toSharedReadStatusFilter(): SharedReadStatusFilter {
|
||||||
|
return SharedReadStatusFilter.valueOf(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LibraryFilters.toSharedLibraryFilters(): SharedLibraryFilters {
|
||||||
|
return SharedLibraryFilters(
|
||||||
|
fileTypes = fileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() },
|
||||||
|
sourceFolders = sourceFolders,
|
||||||
|
readStatus = readStatus.toSharedReadStatusFilter(),
|
||||||
|
tagIds = tagIds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder {
|
||||||
|
return SharedSyncedFolder(
|
||||||
|
uriString = uriString,
|
||||||
|
name = name,
|
||||||
|
lastScanTime = lastScanTime,
|
||||||
|
allowedFileTypes = allowedFileTypes.mapTo(mutableSetOf()) { it.toSharedFileType() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BannerMessage.toSharedBannerMessage(): SharedBannerMessage {
|
||||||
|
return SharedBannerMessage(
|
||||||
|
message = message,
|
||||||
|
isError = isError,
|
||||||
|
isPersistent = isPersistent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AppThemeMode.toSharedAppThemeMode(): SharedAppThemeMode {
|
||||||
|
return SharedAppThemeMode.valueOf(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AppContrastOption.toSharedAppContrastOption(): SharedAppContrastOption {
|
||||||
|
return SharedAppContrastOption.valueOf(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CustomAppTheme.toSharedCustomAppTheme(): SharedCustomAppTheme {
|
||||||
|
return SharedCustomAppTheme(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
seedColor = seedColor
|
||||||
|
)
|
||||||
|
}
|
||||||
42
app/src/main/java/com/aryan/reader/UiLabelResources.kt
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
|
||||||
|
val AddBooksSource.labelRes: Int
|
||||||
|
@StringRes get() = when (this) {
|
||||||
|
AddBooksSource.UNSHELVED -> R.string.add_books_source_unshelved
|
||||||
|
AddBooksSource.ALL_BOOKS -> R.string.add_books_source_all_books
|
||||||
|
}
|
||||||
|
|
||||||
|
val AppThemeMode.labelRes: Int
|
||||||
|
@StringRes get() = when (this) {
|
||||||
|
AppThemeMode.SYSTEM -> R.string.app_theme_mode_system
|
||||||
|
AppThemeMode.LIGHT -> R.string.app_theme_mode_light
|
||||||
|
AppThemeMode.DARK -> R.string.app_theme_mode_dark
|
||||||
|
}
|
||||||
|
|
||||||
|
val AppContrastOption.labelRes: Int
|
||||||
|
@StringRes get() = when (this) {
|
||||||
|
AppContrastOption.STANDARD -> R.string.app_contrast_standard
|
||||||
|
AppContrastOption.MEDIUM -> R.string.app_contrast_medium
|
||||||
|
AppContrastOption.HIGH -> R.string.app_contrast_high
|
||||||
|
}
|
||||||
|
|
||||||
|
val SortOrder.labelRes: Int
|
||||||
|
@StringRes get() = when (this) {
|
||||||
|
SortOrder.RECENT -> R.string.sort_recent
|
||||||
|
SortOrder.TITLE_ASC -> R.string.sort_title_az
|
||||||
|
SortOrder.AUTHOR_ASC -> R.string.sort_author_az
|
||||||
|
SortOrder.PERCENT_ASC -> R.string.sort_percent_asc
|
||||||
|
SortOrder.PERCENT_DESC -> R.string.sort_percent_desc
|
||||||
|
SortOrder.SIZE_ASC -> R.string.sort_size_smallest
|
||||||
|
SortOrder.SIZE_DESC -> R.string.sort_size_biggest
|
||||||
|
}
|
||||||
|
|
||||||
|
val ReadStatusFilter.labelRes: Int
|
||||||
|
@StringRes get() = when (this) {
|
||||||
|
ReadStatusFilter.ALL -> R.string.read_status_all
|
||||||
|
ReadStatusFilter.UNREAD -> R.string.read_status_unread
|
||||||
|
ReadStatusFilter.IN_PROGRESS -> R.string.read_status_in_progress
|
||||||
|
ReadStatusFilter.COMPLETED -> R.string.read_status_completed
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
TagEntity::class,
|
TagEntity::class,
|
||||||
BookTagCrossRef::class
|
BookTagCrossRef::class
|
||||||
],
|
],
|
||||||
version = 18,
|
version = 19,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(FileTypeConverter::class)
|
@TypeConverters(FileTypeConverter::class)
|
||||||
|
|
@ -251,6 +251,12 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_18_19 = object : Migration(18, 19) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE recent_files ADD COLUMN folderTextMetadataParsed INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getDatabase(context: Context): AppDatabase {
|
fun getDatabase(context: Context): AppDatabase {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
val instance = Room.databaseBuilder(
|
val instance = Room.databaseBuilder(
|
||||||
|
|
@ -263,7 +269,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
|
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
|
||||||
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
|
||||||
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
|
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
|
||||||
MIGRATION_16_17, MIGRATION_17_18
|
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration(false)
|
.fallbackToDestructiveMigration(false)
|
||||||
.build()
|
.build()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import android.net.Uri
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.aryan.reader.ReaderPerfLog
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
@ -16,6 +17,116 @@ object LocalSyncUtils {
|
||||||
private const val ANNOTATION_SUFFIX = "_annotations"
|
private const val ANNOTATION_SUFFIX = "_annotations"
|
||||||
private const val SYNC_SUBFOLDER_NAME = "EpistemeSyncData"
|
private const val SYNC_SUBFOLDER_NAME = "EpistemeSyncData"
|
||||||
|
|
||||||
|
private data class SyncFileEntry(
|
||||||
|
val name: String,
|
||||||
|
val uri: Uri
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun syncSubfolderDocId(rootDocId: String): String {
|
||||||
|
return if (rootDocId.endsWith("/$SYNC_SUBFOLDER_NAME")) {
|
||||||
|
rootDocId
|
||||||
|
} else if (rootDocId.endsWith(":")) {
|
||||||
|
rootDocId + SYNC_SUBFOLDER_NAME
|
||||||
|
} else {
|
||||||
|
"$rootDocId/$SYNC_SUBFOLDER_NAME"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun querySyncSubfolderFiles(context: Context, sourceFolderUri: Uri): List<SyncFileEntry> {
|
||||||
|
val start = ReaderPerfLog.nowNanos()
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val rootDocId = try {
|
||||||
|
DocumentsContract.getTreeDocumentId(sourceFolderUri)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
ReaderPerfLog.w("LocalSync direct query skipped: invalid tree uri=$sourceFolderUri")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val syncDocId = syncSubfolderDocId(rootDocId)
|
||||||
|
val syncDirUri = DocumentsContract.buildDocumentUriUsingTree(sourceFolderUri, syncDocId)
|
||||||
|
val documentProjection = arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||||
|
val isSyncDir = try {
|
||||||
|
resolver.query(syncDirUri, documentProjection, null, null, null)?.use { cursor ->
|
||||||
|
cursor.moveToFirst() &&
|
||||||
|
cursor.getString(0) == DocumentsContract.Document.MIME_TYPE_DIR
|
||||||
|
} == true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSyncDir) {
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"LocalSync direct query sync dir missing rootDocId=$rootDocId syncDocId=$syncDocId"
|
||||||
|
)
|
||||||
|
return querySyncSubfolderFilesFallback(context, sourceFolderUri, "missing-direct-sync-dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(sourceFolderUri, syncDocId)
|
||||||
|
val projection = arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||||
|
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||||
|
DocumentsContract.Document.COLUMN_MIME_TYPE
|
||||||
|
)
|
||||||
|
val entries = mutableListOf<SyncFileEntry>()
|
||||||
|
try {
|
||||||
|
resolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
|
||||||
|
val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||||
|
val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||||
|
val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val mimeType = cursor.getString(mimeCol)
|
||||||
|
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) continue
|
||||||
|
val name = cursor.getString(nameCol) ?: continue
|
||||||
|
val docId = cursor.getString(idCol) ?: continue
|
||||||
|
entries.add(
|
||||||
|
SyncFileEntry(
|
||||||
|
name = name,
|
||||||
|
uri = DocumentsContract.buildDocumentUriUsingTree(sourceFolderUri, docId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(TAG).e(e, "Failed to query sync subfolder directly")
|
||||||
|
return querySyncSubfolderFilesFallback(context, sourceFolderUri, "direct-query-error")
|
||||||
|
}
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"LocalSync direct query files=${entries.size} elapsed=${ReaderPerfLog.elapsedMs(start)}ms syncDocId=$syncDocId"
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun querySyncSubfolderFilesFallback(
|
||||||
|
context: Context,
|
||||||
|
sourceFolderUri: Uri,
|
||||||
|
reason: String
|
||||||
|
): List<SyncFileEntry> {
|
||||||
|
val start = ReaderPerfLog.nowNanos()
|
||||||
|
return try {
|
||||||
|
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri)
|
||||||
|
val syncDir = rootTree?.findFile(SYNC_SUBFOLDER_NAME)
|
||||||
|
if (syncDir == null || !syncDir.isDirectory) {
|
||||||
|
ReaderPerfLog.w("LocalSync fallback query found no sync dir reason=$reason uri=$sourceFolderUri")
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
val entries = syncDir.listFiles()
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.isFile }
|
||||||
|
.mapNotNull { file ->
|
||||||
|
val name = file.name ?: return@mapNotNull null
|
||||||
|
SyncFileEntry(name = name, uri = file.uri)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"LocalSync fallback query files=${entries.size} elapsed=${ReaderPerfLog.elapsedMs(start)}ms reason=$reason"
|
||||||
|
)
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(TAG).e(e, "Failed to query sync subfolder fallback")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getOrCreateSyncDir(rootTree: DocumentFile): DocumentFile? {
|
private fun getOrCreateSyncDir(rootTree: DocumentFile): DocumentFile? {
|
||||||
val existing = rootTree.findFile(SYNC_SUBFOLDER_NAME)
|
val existing = rootTree.findFile(SYNC_SUBFOLDER_NAME)
|
||||||
if (existing != null && existing.isDirectory) return existing
|
if (existing != null && existing.isDirectory) return existing
|
||||||
|
|
@ -209,22 +320,22 @@ object LocalSyncUtils {
|
||||||
|
|
||||||
suspend fun preloadAnnotationSidecars(
|
suspend fun preloadAnnotationSidecars(
|
||||||
context: Context,
|
context: Context,
|
||||||
rootTree: DocumentFile
|
sourceFolderUri: Uri
|
||||||
): Map<String, Pair<Long, String>> = withContext(Dispatchers.IO) {
|
): Map<String, Pair<Long, String>> = withContext(Dispatchers.IO) {
|
||||||
val results = mutableMapOf<String, Pair<Long, String>>()
|
val results = mutableMapOf<String, Pair<Long, String>>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME)
|
val groupedFiles = querySyncSubfolderFiles(context, sourceFolderUri)
|
||||||
if (syncDir == null || !syncDir.isDirectory) return@withContext results
|
.filter { file ->
|
||||||
val bookIds = syncDir.listFiles()
|
val name = file.name
|
||||||
.mapNotNull { extractAnnotationBookId(it.name) }
|
extractAnnotationBookId(name) != null &&
|
||||||
.toSet()
|
!name.contains(".syncthing.")
|
||||||
|
|
||||||
for (bookId in bookIds) {
|
|
||||||
val best = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
|
|
||||||
if (best != null) {
|
|
||||||
results[bookId] = best
|
|
||||||
}
|
}
|
||||||
|
.groupBy { file -> extractAnnotationBookId(file.name).orEmpty() }
|
||||||
|
|
||||||
|
for ((bookId, files) in groupedFiles) {
|
||||||
|
val best = resolveAnnotationConflictsReadOnly(context, bookId, files)
|
||||||
|
if (best != null) results[bookId] = best
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars")
|
Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars")
|
||||||
|
|
@ -233,6 +344,44 @@ object LocalSyncUtils {
|
||||||
return@withContext results
|
return@withContext results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveAnnotationConflictsReadOnly(
|
||||||
|
context: Context,
|
||||||
|
bookId: String,
|
||||||
|
files: List<SyncFileEntry>
|
||||||
|
): Pair<Long, String>? {
|
||||||
|
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
|
||||||
|
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
|
||||||
|
var bestTs = -1L
|
||||||
|
var bestData: String? = null
|
||||||
|
|
||||||
|
for (file in files) {
|
||||||
|
val name = file.name
|
||||||
|
if (!((name.startsWith(basePattern) || name.startsWith(legacyPattern)) &&
|
||||||
|
name.endsWith(".json") &&
|
||||||
|
!name.endsWith(".tmp") &&
|
||||||
|
!name.contains(".syncthing."))
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val content = context.contentResolver.openInputStream(file.uri)?.use {
|
||||||
|
it.bufferedReader().readText()
|
||||||
|
} ?: continue
|
||||||
|
val json = JSONObject(content)
|
||||||
|
val ts = json.optLong("timestamp", 0L)
|
||||||
|
val data = json.optJSONObject("data")?.toString()
|
||||||
|
if (data != null && ts > bestTs) {
|
||||||
|
bestTs = ts
|
||||||
|
bestData = data
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("FolderAnnotationSync").e(e, "Error parsing annotation sidecar: $name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestData?.let { bestTs to it }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getAnnotationSidecar(
|
suspend fun getAnnotationSidecar(
|
||||||
context: Context,
|
context: Context,
|
||||||
sourceFolderUri: Uri,
|
sourceFolderUri: Uri,
|
||||||
|
|
@ -253,12 +402,13 @@ object LocalSyncUtils {
|
||||||
private fun resolveAndCleanAnnotationConflicts(
|
private fun resolveAndCleanAnnotationConflicts(
|
||||||
context: Context,
|
context: Context,
|
||||||
syncDir: DocumentFile,
|
syncDir: DocumentFile,
|
||||||
bookId: String
|
bookId: String,
|
||||||
|
knownFiles: List<DocumentFile>? = null
|
||||||
): Pair<Long, String>? {
|
): Pair<Long, String>? {
|
||||||
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
|
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
|
||||||
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
|
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
|
||||||
|
|
||||||
val allFiles = syncDir.listFiles()
|
val allFiles = knownFiles ?: syncDir.listFiles().asList()
|
||||||
|
|
||||||
val candidates = allFiles.filter { file ->
|
val candidates = allFiles.filter { file ->
|
||||||
val name = file.name ?: ""
|
val name = file.name ?: ""
|
||||||
|
|
@ -475,21 +625,17 @@ object LocalSyncUtils {
|
||||||
val finalResults = mutableMapOf<String, FolderBookMetadata>()
|
val finalResults = mutableMapOf<String, FolderBookMetadata>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults
|
val allFiles = querySyncSubfolderFiles(context, sourceFolderUri)
|
||||||
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME)
|
|
||||||
if (syncDir == null || !syncDir.isDirectory) return@withContext finalResults
|
|
||||||
val allFiles = syncDir.listFiles()
|
|
||||||
|
|
||||||
val groupedFiles = allFiles
|
val groupedFiles = allFiles
|
||||||
.filter {
|
.filter {
|
||||||
val name = it.name ?: ""
|
val name = it.name
|
||||||
(name.endsWith(".json") || name.contains(".sync-conflict")) &&
|
(name.endsWith(".json") || name.contains(".sync-conflict")) &&
|
||||||
!name.contains(ANNOTATION_SUFFIX) &&
|
!name.contains(ANNOTATION_SUFFIX) &&
|
||||||
!name.endsWith(".tmp") &&
|
!name.endsWith(".tmp") &&
|
||||||
!name.contains(".syncthing.")
|
!name.contains(".syncthing.")
|
||||||
}
|
}
|
||||||
.groupBy { file ->
|
.groupBy { file ->
|
||||||
var name = file.name ?: ""
|
var name = file.name
|
||||||
if (name.startsWith(".")) name = name.substring(1)
|
if (name.startsWith(".")) name = name.substring(1)
|
||||||
if (name.contains(".sync-conflict")) {
|
if (name.contains(".sync-conflict")) {
|
||||||
name.substringBefore(".sync-conflict")
|
name.substringBefore(".sync-conflict")
|
||||||
|
|
@ -499,17 +645,47 @@ object LocalSyncUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
groupedFiles.forEach { (bookId, files) ->
|
groupedFiles.forEach { (bookId, files) ->
|
||||||
val winner = resolveAndCleanConflicts(context, files, bookId)
|
val winner = resolveMetadataConflictsReadOnly(context, files, bookId)
|
||||||
if (winner != null) {
|
if (winner != null) {
|
||||||
finalResults[bookId] = winner
|
finalResults[bookId] = winner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag(TAG).d("getAllFolderMetadata: Consolidated ${groupedFiles.size} book records from root.")
|
Timber.tag(TAG).d("getAllFolderMetadata: Read ${finalResults.size}/${groupedFiles.size} book records from sync data.")
|
||||||
|
ReaderPerfLog.d(
|
||||||
|
"LocalSync metadata read files=${allFiles.size} groups=${groupedFiles.size} records=${finalResults.size}"
|
||||||
|
)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag(TAG).e(e, "Error scanning root folder for metadata")
|
Timber.tag(TAG).e(e, "Error scanning sync data folder for metadata")
|
||||||
|
ReaderPerfLog.w("LocalSync metadata read failed uri=$sourceFolderUri")
|
||||||
}
|
}
|
||||||
return@withContext finalResults
|
return@withContext finalResults
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveMetadataConflictsReadOnly(
|
||||||
|
context: Context,
|
||||||
|
files: List<SyncFileEntry>,
|
||||||
|
bookId: String
|
||||||
|
): FolderBookMetadata? {
|
||||||
|
var bestMeta: FolderBookMetadata? = null
|
||||||
|
for (file in files) {
|
||||||
|
try {
|
||||||
|
val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input ->
|
||||||
|
input.bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
if (jsonString != null) {
|
||||||
|
val meta = FolderBookMetadata.fromJsonString(jsonString)
|
||||||
|
if (meta.bookId == bookId &&
|
||||||
|
(bestMeta == null || meta.lastModifiedTimestamp > bestMeta!!.lastModifiedTimestamp)
|
||||||
|
) {
|
||||||
|
bestMeta = meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(TAG).e(e, "Failed to parse metadata sidecar: ${file.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestMeta
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,59 @@ interface RecentFileDao {
|
||||||
@Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
|
@Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
|
||||||
suspend fun markAsNotRecent(bookIds: List<String>, timestamp: Long)
|
suspend fun markAsNotRecent(bookIds: List<String>, timestamp: Long)
|
||||||
|
|
||||||
@Query("SELECT * FROM recent_files WHERE sourceFolderUri IS NOT NULL AND coverImagePath IS NULL AND isDeleted = 0")
|
@Query("""
|
||||||
suspend fun getFolderBooksWithoutCovers(): List<RecentFileEntity>
|
SELECT * FROM recent_files
|
||||||
|
WHERE sourceFolderUri IS NOT NULL
|
||||||
|
AND isDeleted = 0
|
||||||
|
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
|
||||||
|
AND folderTextMetadataParsed = 0
|
||||||
|
""")
|
||||||
|
suspend fun getFolderBooksNeedingTextMetadata(): List<RecentFileEntity>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM recent_files
|
||||||
|
WHERE sourceFolderUri = :sourceFolderUri
|
||||||
|
AND isDeleted = 0
|
||||||
|
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
|
||||||
|
AND folderTextMetadataParsed = 0
|
||||||
|
""")
|
||||||
|
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String): List<RecentFileEntity>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT COUNT(*) FROM recent_files
|
||||||
|
WHERE sourceFolderUri IS NOT NULL
|
||||||
|
AND isDeleted = 0
|
||||||
|
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
|
||||||
|
AND folderTextMetadataParsed = 0
|
||||||
|
""")
|
||||||
|
suspend fun countFolderBooksNeedingTextMetadata(): Int
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT COUNT(*) FROM recent_files
|
||||||
|
WHERE sourceFolderUri = :sourceFolderUri
|
||||||
|
AND isDeleted = 0
|
||||||
|
AND type IN ('PDF', 'EPUB', 'ODT', 'FODT', 'DOCX')
|
||||||
|
AND folderTextMetadataParsed = 0
|
||||||
|
""")
|
||||||
|
suspend fun countFolderBooksNeedingTextMetadata(sourceFolderUri: String): Int
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
UPDATE recent_files
|
||||||
|
SET
|
||||||
|
coverImagePath = COALESCE(:coverImagePath, coverImagePath),
|
||||||
|
title = COALESCE(:title, title),
|
||||||
|
author = COALESCE(:author, author),
|
||||||
|
fileSize = CASE WHEN :fileSize > 0 THEN :fileSize ELSE fileSize END,
|
||||||
|
folderTextMetadataParsed = 1
|
||||||
|
WHERE bookId = :bookId
|
||||||
|
""")
|
||||||
|
suspend fun updateExtractedMetadata(
|
||||||
|
bookId: String,
|
||||||
|
coverImagePath: String?,
|
||||||
|
title: String?,
|
||||||
|
author: String?,
|
||||||
|
fileSize: Long
|
||||||
|
)
|
||||||
|
|
||||||
@Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL")
|
@Query("UPDATE recent_files SET sourceFolderUri = NULL WHERE sourceFolderUri IS NOT NULL")
|
||||||
suspend fun detachAllFolderBooks()
|
suspend fun detachAllFolderBooks()
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@ data class RecentFileEntity(
|
||||||
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
|
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
|
||||||
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
|
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
|
||||||
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
|
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
|
||||||
@ColumnInfo(defaultValue = "NULL") val description: String?
|
@ColumnInfo(defaultValue = "NULL") val description: String?,
|
||||||
|
@ColumnInfo(defaultValue = "0") val folderTextMetadataParsed: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RecentFileSummary(
|
data class RecentFileSummary(
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,7 @@
|
||||||
*/
|
*/
|
||||||
package com.aryan.reader.data
|
package com.aryan.reader.data
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import com.aryan.reader.FileType
|
import com.aryan.reader.FileType
|
||||||
import androidx.core.net.toUri
|
|
||||||
|
|
||||||
data class RecentFileItem(
|
data class RecentFileItem(
|
||||||
val bookId: String,
|
val bookId: String,
|
||||||
|
|
@ -51,10 +49,9 @@ data class RecentFileItem(
|
||||||
val seriesName: String? = null,
|
val seriesName: String? = null,
|
||||||
val seriesIndex: Double? = null,
|
val seriesIndex: Double? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
|
val folderTextMetadataParsed: Boolean = false,
|
||||||
val tags: List<TagEntity> = emptyList()
|
val tags: List<TagEntity> = emptyList()
|
||||||
) {
|
)
|
||||||
fun getUri(): Uri? = uriString?.toUri()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
||||||
return RecentFileItem(
|
return RecentFileItem(
|
||||||
|
|
@ -84,7 +81,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
|
||||||
fileSize = this.fileSize,
|
fileSize = this.fileSize,
|
||||||
seriesName = this.seriesName,
|
seriesName = this.seriesName,
|
||||||
seriesIndex = this.seriesIndex,
|
seriesIndex = this.seriesIndex,
|
||||||
description = this.description
|
description = this.description,
|
||||||
|
folderTextMetadataParsed = this.folderTextMetadataParsed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +114,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
|
||||||
fileSize = this.fileSize,
|
fileSize = this.fileSize,
|
||||||
seriesName = this.seriesName,
|
seriesName = this.seriesName,
|
||||||
seriesIndex = this.seriesIndex,
|
seriesIndex = this.seriesIndex,
|
||||||
description = this.description
|
description = this.description,
|
||||||
|
folderTextMetadataParsed = this.folderTextMetadataParsed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.aryan.reader.data
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
|
fun RecentFileItem.getUri(): Uri? = uriString?.toUri()
|
||||||
|
|
@ -24,6 +24,7 @@ import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import com.aryan.reader.ReaderPerfLog
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import com.aryan.reader.BookImporter
|
import com.aryan.reader.BookImporter
|
||||||
import com.aryan.reader.paginatedreader.Locator
|
import com.aryan.reader.paginatedreader.Locator
|
||||||
|
|
@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import androidx.room.withTransaction
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import com.aryan.reader.pdf.data.PdfAnnotationRepository
|
import com.aryan.reader.pdf.data.PdfAnnotationRepository
|
||||||
|
|
@ -47,7 +49,8 @@ private const val COVER_CACHE_DIR = "cover_cache"
|
||||||
|
|
||||||
class RecentFilesRepository(private val context: Context) {
|
class RecentFilesRepository(private val context: Context) {
|
||||||
|
|
||||||
private val recentFileDao = AppDatabase.getDatabase(context).recentFileDao()
|
private val database = AppDatabase.getDatabase(context)
|
||||||
|
private val recentFileDao = database.recentFileDao()
|
||||||
private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR)
|
private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR)
|
||||||
private val bookImporter = BookImporter(context)
|
private val bookImporter = BookImporter(context)
|
||||||
|
|
||||||
|
|
@ -57,10 +60,10 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
private val pdfTextBoxRepository = PdfTextBoxRepository(context)
|
private val pdfTextBoxRepository = PdfTextBoxRepository(context)
|
||||||
private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context)
|
private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context)
|
||||||
|
|
||||||
val activeShelvesFlow = AppDatabase.getDatabase(context).shelfDao().getAllActiveShelves()
|
val activeShelvesFlow = database.shelfDao().getAllActiveShelves()
|
||||||
val shelfCrossRefsFlow = AppDatabase.getDatabase(context).shelfDao().getAllBookShelfCrossRefs()
|
val shelfCrossRefsFlow = database.shelfDao().getAllBookShelfCrossRefs()
|
||||||
val tagsFlow = AppDatabase.getDatabase(context).tagDao().getAllTags()
|
val tagsFlow = database.tagDao().getAllTags()
|
||||||
val tagCrossRefsFlow = AppDatabase.getDatabase(context).tagDao().getAllBookTagCrossRefs()
|
val tagCrossRefsFlow = database.tagDao().getAllBookTagCrossRefs()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (!coverCacheDir.exists()) {
|
if (!coverCacheDir.exists()) {
|
||||||
|
|
@ -184,7 +187,8 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize,
|
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize,
|
||||||
seriesName = item.seriesName ?: existingItem.seriesName,
|
seriesName = item.seriesName ?: existingItem.seriesName,
|
||||||
seriesIndex = item.seriesIndex ?: existingItem.seriesIndex,
|
seriesIndex = item.seriesIndex ?: existingItem.seriesIndex,
|
||||||
description = item.description ?: existingItem.description
|
description = item.description ?: existingItem.description,
|
||||||
|
folderTextMetadataParsed = item.folderTextMetadataParsed || existingItem.folderTextMetadataParsed
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
item.toRecentFileEntity()
|
item.toRecentFileEntity()
|
||||||
|
|
@ -355,12 +359,25 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) {
|
suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) {
|
||||||
val filesToRemove = getFilesBySourceFolder(folderUriString)
|
val start = ReaderPerfLog.nowNanos()
|
||||||
if (filesToRemove.isNotEmpty()) {
|
val filesToRemove = recentFileDao.getFilesBySourceFolder(folderUriString)
|
||||||
Timber.d("DeleteDebug: Cascading deletion for ${filesToRemove.size} files from folder.")
|
|
||||||
deleteFilePermanently(filesToRemove.map { it.bookId })
|
|
||||||
} else {
|
|
||||||
recentFileDao.deleteFilesBySourceFolder(folderUriString)
|
recentFileDao.deleteFilesBySourceFolder(folderUriString)
|
||||||
|
ReaderPerfLog.i(
|
||||||
|
"FolderRemove db delete books=${filesToRemove.size} elapsed=${ReaderPerfLog.elapsedMs(start)}ms folder=$folderUriString"
|
||||||
|
)
|
||||||
|
|
||||||
|
filesToRemove.forEach { item ->
|
||||||
|
item.coverImagePath?.let { deleteCachedCover(it) }
|
||||||
|
try {
|
||||||
|
pdfAnnotationRepository.getAnnotationFileForSync(item.bookId)?.delete()
|
||||||
|
pdfRichTextRepository.getFileForSync(item.bookId).delete()
|
||||||
|
pageLayoutRepository.getLayoutFile(item.bookId).delete()
|
||||||
|
pdfTextBoxRepository.getFileForSync(item.bookId).delete()
|
||||||
|
pdfHighlightRepository.getFileForSync(item.bookId).delete()
|
||||||
|
ImportedFileCache.clearBookCache(context, item.bookId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Error during local cleanup for detached folder book ${item.bookId}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,8 +398,40 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getFolderBooksWithoutCovers(): List<RecentFileItem> = withContext(Dispatchers.IO) {
|
suspend fun getFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): List<RecentFileItem> = withContext(Dispatchers.IO) {
|
||||||
return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() }
|
val entities = if (sourceFolderUri.isNullOrBlank()) {
|
||||||
|
recentFileDao.getFolderBooksNeedingTextMetadata()
|
||||||
|
} else {
|
||||||
|
recentFileDao.getFolderBooksNeedingTextMetadata(sourceFolderUri)
|
||||||
|
}
|
||||||
|
return@withContext entities.map { it.toRecentFileItem() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun hasFolderBooksNeedingTextMetadata(sourceFolderUri: String? = null): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val count = if (sourceFolderUri.isNullOrBlank()) {
|
||||||
|
recentFileDao.countFolderBooksNeedingTextMetadata()
|
||||||
|
} else {
|
||||||
|
recentFileDao.countFolderBooksNeedingTextMetadata(sourceFolderUri)
|
||||||
|
}
|
||||||
|
return@withContext count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateExtractedMetadata(items: List<RecentFileItem>) = withContext(Dispatchers.IO) {
|
||||||
|
if (items.isEmpty()) return@withContext
|
||||||
|
items.chunked(300).forEach { chunk ->
|
||||||
|
database.withTransaction {
|
||||||
|
chunk.forEach { item ->
|
||||||
|
recentFileDao.updateExtractedMetadata(
|
||||||
|
bookId = item.bookId,
|
||||||
|
coverImagePath = item.coverImagePath,
|
||||||
|
title = item.title,
|
||||||
|
author = item.author,
|
||||||
|
fileSize = item.fileSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Timber.tag(ReaderPerfLog.TAG).d("Metadata extraction batch updated ${items.size} rows.")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) {
|
suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import android.graphics.Bitmap
|
||||||
import com.aryan.reader.epub.EpubParser.EpubPageTarget
|
import com.aryan.reader.epub.EpubParser.EpubPageTarget
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class EpubTocEntry(
|
data class EpubTocEntry(
|
||||||
|
|
@ -51,3 +52,14 @@ data class EpubBook(
|
||||||
val seriesIndex: Double? = null,
|
val seriesIndex: Double? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun EpubBook.hasReadableExtractedContent(): Boolean {
|
||||||
|
if (extractionBasePath.isBlank()) return false
|
||||||
|
val extractionDir = File(extractionBasePath)
|
||||||
|
if (!extractionDir.isDirectory) return false
|
||||||
|
if (chapters.isEmpty()) return extractionDir.list()?.isNotEmpty() == true
|
||||||
|
|
||||||
|
return chapters.all { chapter ->
|
||||||
|
File(extractionDir, chapter.htmlFilePath).isFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,12 @@ object ImportedFileCache {
|
||||||
private const val TEMP_PREFIX = "imported_file_tmp_"
|
private const val TEMP_PREFIX = "imported_file_tmp_"
|
||||||
private val invalidSegmentChars = Regex("[^A-Za-z0-9._-]+")
|
private val invalidSegmentChars = Regex("[^A-Za-z0-9._-]+")
|
||||||
|
|
||||||
|
fun activeBookDirName(bookId: String): String {
|
||||||
|
return "$ACTIVE_PREFIX${bookMarker(bookId)}"
|
||||||
|
}
|
||||||
|
|
||||||
fun activeBookDir(context: Context, bookId: String): File {
|
fun activeBookDir(context: Context, bookId: String): File {
|
||||||
return File(context.cacheDir, "$ACTIVE_PREFIX$bookId")
|
return File(context.cacheDir, activeBookDirName(bookId))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun prepareActiveBookDir(context: Context, bookId: String): File {
|
fun prepareActiveBookDir(context: Context, bookId: String): File {
|
||||||
|
|
@ -39,6 +43,7 @@ object ImportedFileCache {
|
||||||
|
|
||||||
fun clearBookCache(context: Context, bookId: String) {
|
fun clearBookCache(context: Context, bookId: String) {
|
||||||
activeBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
|
activeBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
|
||||||
|
legacyActiveBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
|
||||||
clearTemporaryBookDirs(context, bookId)
|
clearTemporaryBookDirs(context, bookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +74,10 @@ object ImportedFileCache {
|
||||||
return name.startsWith(ACTIVE_PREFIX) && !isTemporaryBookDir(name)
|
return name.startsWith(ACTIVE_PREFIX) && !isTemporaryBookDir(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun legacyActiveBookDir(context: Context, bookId: String): File {
|
||||||
|
return File(context.cacheDir, "$ACTIVE_PREFIX$bookId")
|
||||||
|
}
|
||||||
|
|
||||||
private fun bookMarker(bookId: String): String {
|
private fun bookMarker(bookId: String): String {
|
||||||
val normalized = bookId.toCacheSegment().ifBlank { "book" }.take(40)
|
val normalized = bookId.toCacheSegment().ifBlank { "book" }.take(40)
|
||||||
val hash = bookId.hashCode().toLong() and 0xffffffffL
|
val hash = bookId.hashCode().toLong() and 0xffffffffL
|
||||||
|
|
|
||||||
|
|
@ -109,10 +109,20 @@ class MobiParser(private val context: Context) {
|
||||||
private external fun parseMobiFile(filePath: String): ParsedMobiData?
|
private external fun parseMobiFile(filePath: String): ParsedMobiData?
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
init {
|
private val nativeLoadError: Throwable? = try {
|
||||||
System.loadLibrary("mobi")
|
System.loadLibrary("mobi")
|
||||||
System.loadLibrary("native-lib")
|
System.loadLibrary("native-lib")
|
||||||
|
null
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Timber.e(t, "MOBI native parser is unavailable on this device.")
|
||||||
|
t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isNativeParserAvailable: Boolean
|
||||||
|
get() = nativeLoadError == null
|
||||||
|
|
||||||
|
fun nativeParserUnavailableMessage(): String =
|
||||||
|
nativeLoadError?.message ?: "MOBI native parser is unavailable on this device."
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createMobiBook(
|
suspend fun createMobiBook(
|
||||||
|
|
@ -122,6 +132,11 @@ class MobiParser(private val context: Context) {
|
||||||
parseContent: Boolean = true,
|
parseContent: Boolean = true,
|
||||||
extractionDirOverride: File? = null
|
extractionDirOverride: File? = null
|
||||||
): EpubBook? = withContext(Dispatchers.IO) {
|
): EpubBook? = withContext(Dispatchers.IO) {
|
||||||
|
if (!isNativeParserAvailable) {
|
||||||
|
Timber.e("Skipping MOBI parsing: ${nativeParserUnavailableMessage()}")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir)
|
val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir)
|
||||||
try {
|
try {
|
||||||
tempFile.outputStream().use { output ->
|
tempFile.outputStream().use { output ->
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.safety.Safelist
|
||||||
import org.zwobble.mammoth.DocumentConverter
|
import org.zwobble.mammoth.DocumentConverter
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -52,6 +54,15 @@ class SingleFileImporter(private val context: Context) {
|
||||||
private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L
|
private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val htmlSafelist = Safelist.relaxed()
|
||||||
|
.addTags("article", "aside", "details", "div", "figcaption", "figure", "footer", "header", "main", "section", "summary")
|
||||||
|
.addAttributes(":all", "class", "dir", "id", "lang", "title")
|
||||||
|
.addAttributes("a", "name", "target")
|
||||||
|
.addProtocols("a", "href", "http", "https", "mailto", "tel", "#")
|
||||||
|
.addProtocols("img", "src", "http", "https", "data", "file", "content")
|
||||||
|
|
||||||
|
private val htmlOutputSettings = Document.OutputSettings().prettyPrint(false)
|
||||||
|
|
||||||
suspend fun importSingleFile(
|
suspend fun importSingleFile(
|
||||||
inputStream: InputStream,
|
inputStream: InputStream,
|
||||||
type: FileType,
|
type: FileType,
|
||||||
|
|
@ -61,8 +72,9 @@ class SingleFileImporter(private val context: Context) {
|
||||||
): EpubBook {
|
): EpubBook {
|
||||||
|
|
||||||
val lowerHint = originalBookNameHint.lowercase()
|
val lowerHint = originalBookNameHint.lowercase()
|
||||||
val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv")
|
val isCsv = lowerHint.endsWith(".csv") || lowerHint.endsWith(".tsv") ||
|
||||||
val isCodeOrData = listOf(".json", ".xml", ".log", ".java", ".kt", ".py", ".js", ".cpp", ".c", ".cs", ".rb", ".go").any { lowerHint.endsWith(it) }
|
lowerHint.endsWith(".csv.txt") || lowerHint.endsWith(".tsv.txt")
|
||||||
|
val isCodeOrData = com.aryan.reader.isCodeOrDataFileName(originalBookNameHint)
|
||||||
|
|
||||||
if (type == FileType.HTML && (isCsv || isCodeOrData)) {
|
if (type == FileType.HTML && (isCsv || isCodeOrData)) {
|
||||||
return parseDynamicContentToHtml(inputStream, originalBookNameHint, bookId, parseContent, isCsv)
|
return parseDynamicContentToHtml(inputStream, originalBookNameHint, bookId, parseContent, isCsv)
|
||||||
|
|
@ -99,7 +111,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
writer.write("</head>\n<body>\n<pre><code>\n")
|
writer.write("</head>\n<body>\n<pre><code>\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
val delimiter = if (originalBookNameHint.lowercase().endsWith(".tsv")) '\t' else ','
|
val delimiter = if (originalBookNameHint.lowercase().let { it.endsWith(".tsv") || it.endsWith(".tsv.txt") }) '\t' else ','
|
||||||
|
|
||||||
inputStream.bufferedReader().use { reader ->
|
inputStream.bufferedReader().use { reader ->
|
||||||
var line = reader.readLine()
|
var line = reader.readLine()
|
||||||
|
|
@ -177,11 +189,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
|
||||||
|
|
||||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
|
||||||
if (!exists()) mkdirs()
|
|
||||||
}
|
|
||||||
val metadataFile = File(extractionDir, "book_metadata.json")
|
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||||
|
|
||||||
if (metadataFile.exists()) {
|
if (metadataFile.exists()) {
|
||||||
|
|
@ -245,7 +253,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
val chapterTitle = "Page $pageNum"
|
val chapterTitle = "Page $pageNum"
|
||||||
|
|
||||||
val document = parser.parse(rawText)
|
val document = parser.parse(rawText)
|
||||||
val htmlBody = renderer.render(document)
|
val htmlBody = sanitizeHtmlFragment(renderer.render(document))
|
||||||
|
|
||||||
val fileName = "page_$pageNum.html"
|
val fileName = "page_$pageNum.html"
|
||||||
val file = File(extractionDir, fileName)
|
val file = File(extractionDir, fileName)
|
||||||
|
|
@ -315,11 +323,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
|
||||||
|
|
||||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
|
||||||
if (!exists()) mkdirs()
|
|
||||||
}
|
|
||||||
val metadataFile = File(extractionDir, "book_metadata.json")
|
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||||
|
|
||||||
if (metadataFile.exists()) {
|
if (metadataFile.exists()) {
|
||||||
|
|
@ -480,11 +484,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
|
||||||
|
|
||||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
|
||||||
if (!exists()) mkdirs()
|
|
||||||
}
|
|
||||||
val metadataFile = File(extractionDir, "book_metadata.json")
|
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||||
|
|
||||||
if (metadataFile.exists()) {
|
if (metadataFile.exists()) {
|
||||||
|
|
@ -507,6 +507,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
val chapters = mutableListOf<EpubChapter>()
|
val chapters = mutableListOf<EpubChapter>()
|
||||||
|
|
||||||
inputStream.bufferedReader().use { reader ->
|
inputStream.bufferedReader().use { reader ->
|
||||||
|
var inScript = false
|
||||||
var inStyle = false
|
var inStyle = false
|
||||||
var inBody = false
|
var inBody = false
|
||||||
var pageNum = 1
|
var pageNum = 1
|
||||||
|
|
@ -516,6 +517,19 @@ class SingleFileImporter(private val context: Context) {
|
||||||
while (reader.readLine().also { line = it } != null) {
|
while (reader.readLine().also { line = it } != null) {
|
||||||
val trimmed = line!!.trim()
|
val trimmed = line!!.trim()
|
||||||
|
|
||||||
|
if (inScript) {
|
||||||
|
if (trimmed.contains("</script", ignoreCase = true)) {
|
||||||
|
inScript = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("<script", ignoreCase = true)) {
|
||||||
|
if (!trimmed.contains("</script", ignoreCase = true)) {
|
||||||
|
inScript = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (!inBody) {
|
if (!inBody) {
|
||||||
if (trimmed.startsWith("<title", ignoreCase = true)) {
|
if (trimmed.startsWith("<title", ignoreCase = true)) {
|
||||||
val t = trimmed.substringAfter(">").substringBefore("</title>")
|
val t = trimmed.substringAfter(">").substringBefore("</title>")
|
||||||
|
|
@ -632,6 +646,10 @@ class SingleFileImporter(private val context: Context) {
|
||||||
return@withContext book
|
return@withContext book
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sanitizeHtmlFragment(html: String): String {
|
||||||
|
return Jsoup.clean(html, "", htmlSafelist, htmlOutputSettings)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun parseDocx(
|
private suspend fun parseDocx(
|
||||||
inputStream: InputStream,
|
inputStream: InputStream,
|
||||||
originalBookNameHint: String,
|
originalBookNameHint: String,
|
||||||
|
|
@ -654,11 +672,7 @@ class SingleFileImporter(private val context: Context) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
File(context.cacheDir, "imported_file_$bookId").deleteRecursively()
|
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
|
||||||
|
|
||||||
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
|
|
||||||
if (!exists()) mkdirs()
|
|
||||||
}
|
|
||||||
val metadataFile = File(extractionDir, "book_metadata.json")
|
val metadataFile = File(extractionDir, "book_metadata.json")
|
||||||
|
|
||||||
if (metadataFile.exists()) {
|
if (metadataFile.exists()) {
|
||||||
|
|
@ -754,8 +768,9 @@ class SingleFileImporter(private val context: Context) {
|
||||||
val chapterTitle = if (pageNum > 1 || bodyContent.contains("<page-break")) "Page $pageNum" else title
|
val chapterTitle = if (pageNum > 1 || bodyContent.contains("<page-break")) "Page $pageNum" else title
|
||||||
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 fullHtml = "<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n<title>${title.replace("\"", """)}</title>\n<style>${cssStyle}</style>\n</head>\n<body>\n${bodyContent.trim()}\n</body>\n</html>"
|
val fullHtml = "<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n<title>${title.replace("\"", """)}</title>\n<style>${cssStyle}</style>\n</head>\n<body>\n${sanitizedBodyContent.trim()}\n</body>\n</html>"
|
||||||
|
|
||||||
file.writeText(fullHtml)
|
file.writeText(fullHtml)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,8 @@ import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.util.Base64
|
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebSettings
|
import android.webkit.WebSettings
|
||||||
|
|
@ -83,13 +81,12 @@ import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupPositionProvider
|
import androidx.compose.ui.window.PopupPositionProvider
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.ReaderTexture
|
import com.aryan.reader.getReaderTextureDataUri
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
|
|
||||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||||
|
|
@ -351,6 +348,7 @@ fun ChapterWebView(
|
||||||
currentParagraphGap: Float,
|
currentParagraphGap: Float,
|
||||||
currentImageSize: Float,
|
currentImageSize: Float,
|
||||||
currentHorizontalMargin: Float,
|
currentHorizontalMargin: Float,
|
||||||
|
currentVerticalMargin: Float,
|
||||||
onChapterInitiallyScrolled: () -> Unit,
|
onChapterInitiallyScrolled: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onTap: () -> Unit,
|
onTap: () -> Unit,
|
||||||
|
|
@ -386,7 +384,8 @@ fun ChapterWebView(
|
||||||
activeHighlightPalette: List<HighlightColor>,
|
activeHighlightPalette: List<HighlightColor>,
|
||||||
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
||||||
onInternalLinkClick: (String) -> Unit,
|
onInternalLinkClick: (String) -> Unit,
|
||||||
activeTextureId: String? = null
|
activeTextureId: String? = null,
|
||||||
|
activeTextureAlpha: Float = 0.55f
|
||||||
) {
|
) {
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget"
|
"RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget"
|
||||||
|
|
@ -406,18 +405,9 @@ fun ChapterWebView(
|
||||||
|
|
||||||
val textureBase64 by remember(activeTextureId) {
|
val textureBase64 by remember(activeTextureId) {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
activeTextureId?.let { id ->
|
getReaderTextureDataUri(context, activeTextureId)
|
||||||
ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
|
|
||||||
val bmp = BitmapFactory.decodeResource(context.resources, resId)
|
|
||||||
val out = ByteArrayOutputStream()
|
|
||||||
bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
|
|
||||||
"data:image/png;base64," + Base64.encodeToString(
|
|
||||||
out.toByteArray(),
|
|
||||||
Base64.NO_WRAP
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady)
|
val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady)
|
||||||
val currentOnCfiGenerated by rememberUpdatedState(onCfiGenerated)
|
val currentOnCfiGenerated by rememberUpdatedState(onCfiGenerated)
|
||||||
|
|
@ -476,10 +466,10 @@ fun ChapterWebView(
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
|
||||||
LaunchedEffect(isDarkTheme, effectiveBg, effectiveText, textureBase64) {
|
LaunchedEffect(isDarkTheme, effectiveBg, effectiveText, textureBase64, activeTextureAlpha) {
|
||||||
val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
|
val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
|
||||||
val textHex = String.format("#%06X", (0xFFFFFF and effectiveText.toArgb()))
|
val textHex = String.format("#%06X", (0xFFFFFF and effectiveText.toArgb()))
|
||||||
localWebViewRef?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"});", null)
|
localWebViewRef?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"}, ${activeTextureAlpha.coerceIn(0f, 1f)});", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
key(
|
key(
|
||||||
|
|
@ -489,6 +479,7 @@ fun ChapterWebView(
|
||||||
currentParagraphGap,
|
currentParagraphGap,
|
||||||
currentImageSize,
|
currentImageSize,
|
||||||
currentHorizontalMargin,
|
currentHorizontalMargin,
|
||||||
|
currentVerticalMargin,
|
||||||
currentFontFamily,
|
currentFontFamily,
|
||||||
currentTextAlign
|
currentTextAlign
|
||||||
) {
|
) {
|
||||||
|
|
@ -737,7 +728,7 @@ fun ChapterWebView(
|
||||||
val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
|
val bgHex = String.format("#%06X", (0xFFFFFF and effectiveBg.toArgb()))
|
||||||
val textHex =
|
val textHex =
|
||||||
String.format("#%06X", (0xFFFFFF and effectiveText.toArgb()))
|
String.format("#%06X", (0xFFFFFF and effectiveText.toArgb()))
|
||||||
view?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"});",
|
view?.evaluateJavascript("javascript:window.applyReaderTheme($isDarkTheme, '$bgHex', '$textHex', ${textureBase64?.let { "'$it'" } ?: "null"}, ${activeTextureAlpha.coerceIn(0f, 1f)});",
|
||||||
null)
|
null)
|
||||||
|
|
||||||
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
|
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
|
||||||
|
|
@ -782,7 +773,7 @@ fun ChapterWebView(
|
||||||
}
|
}
|
||||||
|
|
||||||
view?.evaluateJavascript(
|
view?.evaluateJavascript(
|
||||||
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
|
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -946,7 +937,7 @@ fun ChapterWebView(
|
||||||
)
|
)
|
||||||
|
|
||||||
webView.evaluateJavascript(
|
webView.evaluateJavascript(
|
||||||
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
|
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);",
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
import com.aryan.reader.BuildConfig
|
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
|
import com.aryan.reader.areReaderAiFeaturesEnabled
|
||||||
|
|
||||||
@Suppress("KotlinConstantConditions")
|
@Suppress("KotlinConstantConditions")
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -92,7 +92,7 @@ fun DictionarySettingsDialog(
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Dictionary ──
|
// ── Dictionary ──
|
||||||
if (BuildConfig.FLAVOR != "oss") {
|
if (areReaderAiFeaturesEnabled(context)) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,14 @@ import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import com.aryan.reader.AiDefinitionPopup
|
import com.aryan.reader.AiDefinitionPopup
|
||||||
|
import com.aryan.reader.AiFeature
|
||||||
import com.aryan.reader.AiDefinitionResult
|
import com.aryan.reader.AiDefinitionResult
|
||||||
import com.aryan.reader.AiHubBottomSheet
|
import com.aryan.reader.AiHubBottomSheet
|
||||||
|
import com.aryan.reader.BuildConfig
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.SummarizationResult
|
import com.aryan.reader.SummarizationResult
|
||||||
import com.aryan.reader.SummaryCacheManager
|
import com.aryan.reader.SummaryCacheManager
|
||||||
|
import com.aryan.reader.callByokTextAi
|
||||||
import com.aryan.reader.epub.EpubBook
|
import com.aryan.reader.epub.EpubBook
|
||||||
import com.aryan.reader.fetchRecap
|
import com.aryan.reader.fetchRecap
|
||||||
import com.aryan.reader.paginatedreader.IPaginator
|
import com.aryan.reader.paginatedreader.IPaginator
|
||||||
|
|
@ -55,6 +58,7 @@ import java.net.URL
|
||||||
*/
|
*/
|
||||||
suspend fun summarizeBookContent(
|
suspend fun summarizeBookContent(
|
||||||
content: String,
|
content: String,
|
||||||
|
context: Context,
|
||||||
authToken: String?,
|
authToken: String?,
|
||||||
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> },
|
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> },
|
||||||
onUpdate: (String) -> Unit,
|
onUpdate: (String) -> Unit,
|
||||||
|
|
@ -68,6 +72,27 @@ suspend fun summarizeBookContent(
|
||||||
}
|
}
|
||||||
Timber.d("Starting summarization for content of length: ${content.length}")
|
Timber.d("Starting summarization for content of length: ${content.length}")
|
||||||
|
|
||||||
|
@Suppress("KotlinConstantConditions")
|
||||||
|
if (BuildConfig.FLAVOR == "oss") {
|
||||||
|
if (BuildConfig.IS_OFFLINE) {
|
||||||
|
onError("AI features are unavailable in the offline OSS build.")
|
||||||
|
onFinish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callByokTextAi(
|
||||||
|
context = context,
|
||||||
|
feature = AiFeature.SUMMARIZE,
|
||||||
|
systemInstruction = "You are an expert in analyzing written content. Provide a concise, easy-to-read summary of the provided chapter. Identify the main ideas, plot points, and themes. Do not add a preamble like 'Here is the summary:'",
|
||||||
|
userPrompt = content,
|
||||||
|
temperature = 0.2,
|
||||||
|
maxTokens = 8192,
|
||||||
|
onUpdate = onUpdate,
|
||||||
|
onError = onError
|
||||||
|
)
|
||||||
|
onFinish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
var connection: HttpURLConnection? = null
|
var connection: HttpURLConnection? = null
|
||||||
try {
|
try {
|
||||||
|
|
@ -196,6 +221,7 @@ suspend fun executeRecapLogic(
|
||||||
|
|
||||||
summarizeBookContent(
|
summarizeBookContent(
|
||||||
content = textToSummarize,
|
content = textToSummarize,
|
||||||
|
context = context,
|
||||||
authToken = authToken,
|
authToken = authToken,
|
||||||
onUsageReceived = { cost, _ ->
|
onUsageReceived = { cost, _ ->
|
||||||
Timber.i("[AI-Billing] Background past chapter summary cost: $cost credits")
|
Timber.i("[AI-Billing] Background past chapter summary cost: $cost credits")
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,9 @@ fun VerticalScrollbar(
|
||||||
|
|
||||||
if (viewportRatio >= 1f) return@derivedStateOf null
|
if (viewportRatio >= 1f) return@derivedStateOf null
|
||||||
|
|
||||||
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
|
val maxThumbHeight = viewportHeight / 2f
|
||||||
|
val minThumbHeight = minOf(80f, maxThumbHeight)
|
||||||
|
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight)
|
||||||
|
|
||||||
val firstItemIndex = listState.firstVisibleItemIndex
|
val firstItemIndex = listState.firstVisibleItemIndex
|
||||||
val firstItemOffset = listState.firstVisibleItemScrollOffset
|
val firstItemOffset = listState.firstVisibleItemScrollOffset
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
*/
|
*/
|
||||||
// EpubReaderScreen.kt
|
// EpubReaderScreen.kt
|
||||||
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead",
|
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead",
|
||||||
"UnusedVariable", "Unused", "SimplifyBooleanWithConstants"
|
"UnusedVariable", "Unused", "SimplifyBooleanWithConstants", "KotlinConstantConditions"
|
||||||
)
|
)
|
||||||
|
|
||||||
package com.aryan.reader.epubreader
|
package com.aryan.reader.epubreader
|
||||||
|
|
@ -35,6 +35,8 @@ import android.graphics.Bitmap
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.view.RoundedCorner
|
||||||
|
import android.view.View
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
|
@ -119,9 +121,14 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.BiasAlignment
|
import androidx.compose.ui.BiasAlignment
|
||||||
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.draw.drawBehind
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageShader
|
||||||
|
import androidx.compose.ui.graphics.ShaderBrush
|
||||||
|
import androidx.compose.ui.graphics.TileMode
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
@ -134,6 +141,7 @@ import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
|
@ -158,12 +166,17 @@ import com.aryan.reader.SearchResult
|
||||||
import com.aryan.reader.SummarizationResult
|
import com.aryan.reader.SummarizationResult
|
||||||
import com.aryan.reader.SummaryCacheManager
|
import com.aryan.reader.SummaryCacheManager
|
||||||
import com.aryan.reader.TtsSettingsSheet
|
import com.aryan.reader.TtsSettingsSheet
|
||||||
|
import com.aryan.reader.areReaderAiFeaturesEnabled
|
||||||
import com.aryan.reader.countWords
|
import com.aryan.reader.countWords
|
||||||
|
import com.aryan.reader.isByokCloudTtsAvailable
|
||||||
import com.aryan.reader.data.CustomFontEntity
|
import com.aryan.reader.data.CustomFontEntity
|
||||||
import com.aryan.reader.epub.EpubBook
|
import com.aryan.reader.epub.EpubBook
|
||||||
|
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.loadReaderThemeId
|
import com.aryan.reader.loadReaderThemeId
|
||||||
|
import com.aryan.reader.loadReaderTextureBitmap
|
||||||
import com.aryan.reader.paginatedreader.BookPaginator
|
import com.aryan.reader.paginatedreader.BookPaginator
|
||||||
import com.aryan.reader.paginatedreader.CfiUtils
|
import com.aryan.reader.paginatedreader.CfiUtils
|
||||||
import com.aryan.reader.paginatedreader.HeaderBlock
|
import com.aryan.reader.paginatedreader.HeaderBlock
|
||||||
|
|
@ -180,11 +193,11 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
||||||
import com.aryan.reader.paginatedreader.semanticBlockModule
|
import com.aryan.reader.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.saveReaderThemeId
|
import com.aryan.reader.saveReaderThemeId
|
||||||
import com.aryan.reader.tts.SpeakerSamplePlayer
|
import com.aryan.reader.tts.SpeakerSamplePlayer
|
||||||
import com.aryan.reader.tts.TtsPlaybackManager
|
import com.aryan.reader.tts.TtsPlaybackManager
|
||||||
import com.aryan.reader.tts.loadTtsMode
|
import com.aryan.reader.tts.loadTtsMode
|
||||||
import com.aryan.reader.tts.rememberTtsController
|
|
||||||
import com.aryan.reader.tts.splitTextIntoChunks
|
import com.aryan.reader.tts.splitTextIntoChunks
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -218,12 +231,50 @@ private const val AUTO_SCROLL_LOCAL_MAX_PREFIX = "auto_scroll_local_max_"
|
||||||
private const val MUSICIAN_MODE_KEY = "musician_mode_enabled"
|
private const val MUSICIAN_MODE_KEY = "musician_mode_enabled"
|
||||||
private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled"
|
private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled"
|
||||||
private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools"
|
private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools"
|
||||||
|
private const val TOOL_ORDER_KEY = "reader_tool_order"
|
||||||
|
private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools"
|
||||||
private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore"
|
private const val TTS_LOCATE_REASON_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"
|
||||||
|
|
||||||
private const val TAG_LINK_NAV = "LINK_NAV"
|
private const val TAG_LINK_NAV = "LINK_NAV"
|
||||||
|
|
||||||
|
private fun View.bottomRoundedCornerRadiusPx(): Int {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
|
||||||
|
|
||||||
|
val insets = rootWindowInsets ?: return 0
|
||||||
|
return max(
|
||||||
|
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)?.radius ?: 0,
|
||||||
|
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)?.radius ?: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberBottomRoundedCornerPadding(view: View): Dp {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val configuration = LocalConfiguration.current
|
||||||
|
var radiusPx by remember(view) { mutableIntStateOf(view.bottomRoundedCornerRadiusPx()) }
|
||||||
|
|
||||||
|
DisposableEffect(
|
||||||
|
view,
|
||||||
|
configuration.orientation,
|
||||||
|
configuration.screenWidthDp,
|
||||||
|
configuration.screenHeightDp
|
||||||
|
) {
|
||||||
|
val listener = View.OnLayoutChangeListener { updatedView, _, _, _, _, _, _, _, _ ->
|
||||||
|
radiusPx = updatedView.bottomRoundedCornerRadiusPx()
|
||||||
|
}
|
||||||
|
view.addOnLayoutChangeListener(listener)
|
||||||
|
radiusPx = view.bottomRoundedCornerRadiusPx()
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
view.removeOnLayoutChangeListener(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return with(density) { radiusPx.toDp() }
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveHiddenTools(context: Context, hiddenTools: Set<String>) {
|
private fun saveHiddenTools(context: Context, hiddenTools: Set<String>) {
|
||||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) }
|
prefs.edit { putStringSet(HIDDEN_TOOLS_KEY, hiddenTools) }
|
||||||
|
|
@ -234,6 +285,34 @@ private fun loadHiddenTools(context: Context): Set<String> {
|
||||||
return prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
|
return prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()) ?: emptySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveToolOrder(context: Context, toolOrder: List<ReaderTool>) {
|
||||||
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putString(TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadToolOrder(context: Context): List<ReaderTool> {
|
||||||
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
|
val savedTools = prefs.getString(TOOL_ORDER_KEY, null)
|
||||||
|
?.split(',')
|
||||||
|
?.filter { it.isNotBlank() }
|
||||||
|
?.mapNotNull { name -> ReaderTool.entries.firstOrNull { it.name == name } }
|
||||||
|
.orEmpty()
|
||||||
|
return (savedTools + ReaderTool.entries.filterNot { it in savedTools }).distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBottomTools(context: Context, bottomTools: Set<String>) {
|
||||||
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putStringSet(BOTTOM_TOOLS_KEY, bottomTools) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadBottomTools(context: Context): Set<String> {
|
||||||
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
|
return prefs.getStringSet(
|
||||||
|
BOTTOM_TOOLS_KEY,
|
||||||
|
ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||||
|
) ?: ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
|
private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
|
||||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
prefs.edit { putBoolean(KEEP_SCREEN_ON_KEY, isEnabled) }
|
prefs.edit { putBoolean(KEEP_SCREEN_ON_KEY, isEnabled) }
|
||||||
|
|
@ -338,7 +417,7 @@ private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
|
||||||
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
|
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
|
||||||
|
|
||||||
private fun loadUseOnlineDict(context: Context): Boolean {
|
private fun loadUseOnlineDict(context: Context): Boolean {
|
||||||
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
|
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) return false
|
||||||
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
|
||||||
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
|
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
|
||||||
}
|
}
|
||||||
|
|
@ -381,6 +460,7 @@ private fun saveExternalSearchPackage(context: Context, packageName: String) {
|
||||||
const val PREF_READER_THEME = "reader_theme_id"
|
const val PREF_READER_THEME = "reader_theme_id"
|
||||||
const val PREF_CUSTOM_THEMES = "custom_themes_json"
|
const val PREF_CUSTOM_THEMES = "custom_themes_json"
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
@Composable
|
@Composable
|
||||||
fun EpubReaderScreen(
|
fun EpubReaderScreen(
|
||||||
|
|
@ -416,8 +496,8 @@ fun EpubReaderScreen(
|
||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
val hasValidExtractionBasePath = remember(epubBook.extractionBasePath) {
|
val hasValidExtractionBasePath = remember(epubBook.extractionBasePath, epubBook.chapters) {
|
||||||
epubBook.extractionBasePath.isNotBlank() && File(epubBook.extractionBasePath).exists()
|
epubBook.hasReadableExtractedContent()
|
||||||
}
|
}
|
||||||
var requestedContentRecovery by remember(epubBook.extractionBasePath, uiState.selectedBookId) {
|
var requestedContentRecovery by remember(epubBook.extractionBasePath, uiState.selectedBookId) {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
|
|
@ -563,6 +643,7 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
|
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
|
||||||
var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) }
|
var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) }
|
||||||
|
var pageInfoPosition by remember { mutableStateOf(loadPageInfoPosition(context)) }
|
||||||
var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) }
|
var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) }
|
||||||
var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) }
|
var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) }
|
||||||
var showVisualOptionsSheet by remember { mutableStateOf(false) }
|
var showVisualOptionsSheet by remember { mutableStateOf(false) }
|
||||||
|
|
@ -582,7 +663,7 @@ fun EpubReaderHost(
|
||||||
var currentTtsMode by remember {
|
var currentTtsMode by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
loadTtsMode(context).let {
|
loadTtsMode(context).let {
|
||||||
if (BuildConfig.FLAVOR == "oss") TtsPlaybackManager.TtsMode.BASE else it
|
if (BuildConfig.FLAVOR == "oss" && !isByokCloudTtsAvailable(context)) TtsPlaybackManager.TtsMode.BASE else it
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -738,18 +819,19 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
|
|
||||||
var hiddenTools by remember { mutableStateOf(loadHiddenTools(context)) }
|
var hiddenTools by remember { mutableStateOf(loadHiddenTools(context)) }
|
||||||
|
var toolOrder by remember { mutableStateOf(loadToolOrder(context)) }
|
||||||
|
var bottomTools by remember { mutableStateOf(loadBottomTools(context)) }
|
||||||
var showCustomizeToolsSheet by remember { mutableStateOf(false) }
|
var showCustomizeToolsSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
|
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
|
||||||
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
|
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String ->
|
@Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String ->
|
||||||
val isOss = BuildConfig.FLAVOR == "oss"
|
val effectiveUseOnline = areReaderAiFeaturesEnabled(context) && useOnlineDictionary
|
||||||
val effectiveUseOnline = !isOss && useOnlineDictionary
|
|
||||||
|
|
||||||
if (effectiveUseOnline) {
|
if (effectiveUseOnline) {
|
||||||
val wordCount = countWords(word)
|
val wordCount = countWords(word)
|
||||||
if (wordCount > 1 && !isProUser) {
|
if (BuildConfig.FLAVOR != "oss" && wordCount > 1 && !isProUser) {
|
||||||
showDictionaryUpsellDialog = true
|
showDictionaryUpsellDialog = true
|
||||||
} else {
|
} else {
|
||||||
selectedTextForAi = word
|
selectedTextForAi = word
|
||||||
|
|
@ -815,6 +897,8 @@ fun EpubReaderHost(
|
||||||
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
|
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
|
||||||
|
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||||
|
val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
|
||||||
|
val pageInfoCornerBottomPadding = roundedCornerBottomPadding.coerceAtMost(8.dp)
|
||||||
|
|
||||||
var bookmarks by remember(epubBook.title) {
|
var bookmarks by remember(epubBook.title) {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
|
|
@ -991,6 +1075,7 @@ fun EpubReaderHost(
|
||||||
var currentParagraphGap by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.paragraphGap) }
|
var currentParagraphGap by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.paragraphGap) }
|
||||||
var currentImageSize by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.imageSize) }
|
var currentImageSize by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.imageSize) }
|
||||||
var currentHorizontalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.horizontalMargin) }
|
var currentHorizontalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.horizontalMargin) }
|
||||||
|
var currentVerticalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.verticalMargin) }
|
||||||
var currentTextAlign by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.textAlign) }
|
var currentTextAlign by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.textAlign) }
|
||||||
var currentFontFamily by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.font) }
|
var currentFontFamily by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.font) }
|
||||||
var currentCustomFontPath by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.customPath) }
|
var currentCustomFontPath by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.customPath) }
|
||||||
|
|
@ -1006,14 +1091,14 @@ fun EpubReaderHost(
|
||||||
var showFontSelectionSheet by remember { mutableStateOf(false) }
|
var showFontSelectionSheet by remember { mutableStateOf(false) }
|
||||||
val fontSheetState = rememberModalBottomSheetState()
|
val fontSheetState = rememberModalBottomSheetState()
|
||||||
|
|
||||||
LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) {
|
LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) {
|
||||||
if (isFormatLocal) {
|
if (isFormatLocal) {
|
||||||
saveLocalReaderSettings(
|
saveLocalReaderSettings(
|
||||||
context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
|
context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
saveReaderSettings(
|
saveReaderSettings(
|
||||||
context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
|
context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentVerticalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1130,6 +1215,7 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
|
var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
|
||||||
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
|
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
|
||||||
|
var globalTextureTransparency by remember { mutableFloatStateOf(loadGlobalTextureTransparency(context)) }
|
||||||
|
|
||||||
val activeTheme = remember(currentThemeId, customThemes) {
|
val activeTheme = remember(currentThemeId, customThemes) {
|
||||||
BuiltInThemes.find { it.id == currentThemeId }
|
BuiltInThemes.find { it.id == currentThemeId }
|
||||||
|
|
@ -1151,6 +1237,19 @@ fun EpubReaderHost(
|
||||||
} else activeTheme.textColor
|
} else activeTheme.textColor
|
||||||
}
|
}
|
||||||
val activeTextureId = activeTheme.textureId
|
val activeTextureId = activeTheme.textureId
|
||||||
|
val activeTextureAlpha = 1f - globalTextureTransparency
|
||||||
|
val activeTextureBitmap = remember(activeTextureId) {
|
||||||
|
loadReaderTextureBitmap(context, activeTextureId)
|
||||||
|
}
|
||||||
|
val activeTextureModifier = activeTextureBitmap?.let { bitmap ->
|
||||||
|
Modifier.drawBehind {
|
||||||
|
drawRect(
|
||||||
|
brush = ShaderBrush(ImageShader(bitmap, TileMode.Repeated, TileMode.Repeated)),
|
||||||
|
blendMode = BlendMode.SrcOver,
|
||||||
|
alpha = activeTextureAlpha.coerceIn(0f, 1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: Modifier
|
||||||
|
|
||||||
val infoBarBgColor = remember(effectiveBg, isDarkTheme) {
|
val infoBarBgColor = remember(effectiveBg, isDarkTheme) {
|
||||||
val overlayAlpha = if (isDarkTheme) 0.08f else 0.06f
|
val overlayAlpha = if (isDarkTheme) 0.08f else 0.06f
|
||||||
|
|
@ -1401,7 +1500,7 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startTts() {
|
fun startTts() {
|
||||||
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
|
if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
|
||||||
showInsufficientCreditsDialog = true
|
showInsufficientCreditsDialog = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1441,6 +1540,7 @@ fun EpubReaderHost(
|
||||||
chapterTitle = chapterTitle,
|
chapterTitle = chapterTitle,
|
||||||
coverImageUri = coverUriString,
|
coverImageUri = coverUriString,
|
||||||
chapterIndex = chapterIndex,
|
chapterIndex = chapterIndex,
|
||||||
|
totalChapters = chapters.size,
|
||||||
ttsMode = currentTtsMode,
|
ttsMode = currentTtsMode,
|
||||||
playbackSource = "READER",
|
playbackSource = "READER",
|
||||||
authToken = token
|
authToken = token
|
||||||
|
|
@ -1460,7 +1560,7 @@ fun EpubReaderHost(
|
||||||
)
|
)
|
||||||
|
|
||||||
fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) {
|
fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) {
|
||||||
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
|
if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
|
||||||
showInsufficientCreditsDialog = true
|
showInsufficientCreditsDialog = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1504,6 +1604,7 @@ fun EpubReaderHost(
|
||||||
chapterTitle = chapterTitle,
|
chapterTitle = chapterTitle,
|
||||||
coverImageUri = coverUriString,
|
coverImageUri = coverUriString,
|
||||||
chapterIndex = chapterIndex,
|
chapterIndex = chapterIndex,
|
||||||
|
totalChapters = chapters.size,
|
||||||
ttsMode = currentTtsMode,
|
ttsMode = currentTtsMode,
|
||||||
playbackSource = "READER",
|
playbackSource = "READER",
|
||||||
authToken = token
|
authToken = token
|
||||||
|
|
@ -1582,18 +1683,6 @@ fun EpubReaderHost(
|
||||||
focusRequester = searchFocusRequester
|
focusRequester = searchFocusRequester
|
||||||
)
|
)
|
||||||
|
|
||||||
if (epubBook.extractionBasePath.isBlank() || !File(epubBook.extractionBasePath).exists()) {
|
|
||||||
Timber.e("Extraction base path is blank or does not exist: ${epubBook.extractionBasePath}"
|
|
||||||
)
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Text(
|
|
||||||
"Error: Book content not found. Path: ${epubBook.extractionBasePath}",
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val totalPagesInCurrentChapter = remember(currentScrollHeightValue, currentClientHeightValue) {
|
val totalPagesInCurrentChapter = remember(currentScrollHeightValue, currentClientHeightValue) {
|
||||||
if (currentClientHeightValue > 0) {
|
if (currentClientHeightValue > 0) {
|
||||||
max(
|
max(
|
||||||
|
|
@ -1989,10 +2078,7 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val pageInfoBottomPadding by animateDpAsState(
|
val pageInfoBarHeight = PAGE_INFO_BAR_HEIGHT + pageInfoCornerBottomPadding
|
||||||
targetValue = if (showBars && pageInfoMode == PageInfoMode.SYNC) 45.dp else 0.dp,
|
|
||||||
label = "PageInfoBottomPadding"
|
|
||||||
)
|
|
||||||
|
|
||||||
val isPageInfoVisible = when (pageInfoMode) {
|
val isPageInfoVisible = when (pageInfoMode) {
|
||||||
PageInfoMode.DEFAULT -> !showBars
|
PageInfoMode.DEFAULT -> !showBars
|
||||||
|
|
@ -2631,7 +2717,7 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
|
|
||||||
val handleGenerateSummary: (Boolean) -> Unit = { force ->
|
val handleGenerateSummary: (Boolean) -> Unit = { force ->
|
||||||
if (!isProUser && credits <= 0) {
|
if (BuildConfig.FLAVOR != "oss" && !isProUser && credits <= 0) {
|
||||||
showInsufficientCreditsDialog = true
|
showInsufficientCreditsDialog = true
|
||||||
showAiHubSheet = false
|
showAiHubSheet = false
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2688,6 +2774,7 @@ fun EpubReaderHost(
|
||||||
val finalSummaryBuilder = StringBuilder()
|
val finalSummaryBuilder = StringBuilder()
|
||||||
summarizeBookContent(
|
summarizeBookContent(
|
||||||
content = text,
|
content = text,
|
||||||
|
context = context,
|
||||||
authToken = token,
|
authToken = token,
|
||||||
onUsageReceived = { cost, freeRemaining ->
|
onUsageReceived = { cost, freeRemaining ->
|
||||||
currentCost = cost
|
currentCost = cost
|
||||||
|
|
@ -2750,7 +2837,7 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
|
|
||||||
val handleGenerateRecap: () -> Unit = {
|
val handleGenerateRecap: () -> Unit = {
|
||||||
if (credits <= 0) {
|
if (BuildConfig.FLAVOR != "oss" && credits <= 0) {
|
||||||
showInsufficientCreditsDialog = true
|
showInsufficientCreditsDialog = true
|
||||||
showAiHubSheet = false
|
showAiHubSheet = false
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2820,6 +2907,7 @@ fun EpubReaderHost(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(effectiveBg)
|
.background(effectiveBg)
|
||||||
|
.then(activeTextureModifier)
|
||||||
.padding(top = effectiveTopPadding)
|
.padding(top = effectiveTopPadding)
|
||||||
.focusRequester(containerFocusRequester)
|
.focusRequester(containerFocusRequester)
|
||||||
.focusable()
|
.focusable()
|
||||||
|
|
@ -2879,13 +2967,15 @@ fun EpubReaderHost(
|
||||||
) {
|
) {
|
||||||
when (currentRenderMode) {
|
when (currentRenderMode) {
|
||||||
RenderMode.VERTICAL_SCROLL -> {
|
RenderMode.VERTICAL_SCROLL -> {
|
||||||
val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp
|
val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp
|
||||||
|
val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp
|
||||||
|
val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.padding(top = contentTopPadding)
|
||||||
.padding(bottom = contentBottomPadding)
|
.padding(bottom = contentBottomPadding)
|
||||||
.padding(top = 16.dp)
|
|
||||||
.testTag("ReaderContainer")
|
.testTag("ReaderContainer")
|
||||||
) {
|
) {
|
||||||
if (chapters.isEmpty()) {
|
if (chapters.isEmpty()) {
|
||||||
|
|
@ -3292,10 +3382,12 @@ fun EpubReaderHost(
|
||||||
currentParagraphGap = currentParagraphGap,
|
currentParagraphGap = currentParagraphGap,
|
||||||
currentImageSize = currentImageSize,
|
currentImageSize = currentImageSize,
|
||||||
currentHorizontalMargin = currentHorizontalMargin,
|
currentHorizontalMargin = currentHorizontalMargin,
|
||||||
|
currentVerticalMargin = currentVerticalMargin,
|
||||||
currentFontFamily = currentFontFamily,
|
currentFontFamily = currentFontFamily,
|
||||||
customFontPath = currentCustomFontPath,
|
customFontPath = currentCustomFontPath,
|
||||||
currentTextAlign = currentTextAlign,
|
currentTextAlign = currentTextAlign,
|
||||||
activeTextureId = activeTextureId,
|
activeTextureId = activeTextureId,
|
||||||
|
activeTextureAlpha = activeTextureAlpha,
|
||||||
onHighlightClicked = {
|
onHighlightClicked = {
|
||||||
lastHighlightClickTime = System.currentTimeMillis()
|
lastHighlightClickTime = System.currentTimeMillis()
|
||||||
showBars = false
|
showBars = false
|
||||||
|
|
@ -3462,7 +3554,7 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
if (ttsChunks.isNotEmpty()) {
|
if (ttsChunks.isNotEmpty()) {
|
||||||
logTtsChapterDiag("Vertical TTS extraction produced ${ttsChunks.size} chunks for chapter $targetChapterIndex")
|
logTtsChapterDiag("Vertical TTS extraction produced ${ttsChunks.size} chunks for chapter $targetChapterIndex")
|
||||||
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
|
if (BuildConfig.FLAVOR != "oss" && currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
|
||||||
showInsufficientCreditsDialog = true
|
showInsufficientCreditsDialog = true
|
||||||
ttsShouldStartOnChapterLoad = false
|
ttsShouldStartOnChapterLoad = false
|
||||||
return@launch
|
return@launch
|
||||||
|
|
@ -3483,6 +3575,7 @@ fun EpubReaderHost(
|
||||||
chapterTitle = chapterTitle,
|
chapterTitle = chapterTitle,
|
||||||
coverImageUri = coverUriString,
|
coverImageUri = coverUriString,
|
||||||
chapterIndex = targetChapterIndex,
|
chapterIndex = targetChapterIndex,
|
||||||
|
totalChapters = chapters.size,
|
||||||
ttsMode = currentTtsMode,
|
ttsMode = currentTtsMode,
|
||||||
playbackSource = "READER",
|
playbackSource = "READER",
|
||||||
authToken = token
|
authToken = token
|
||||||
|
|
@ -3528,6 +3621,7 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
summarizeBookContent(
|
summarizeBookContent(
|
||||||
content = content,
|
content = content,
|
||||||
|
context = context,
|
||||||
authToken = token,
|
authToken = token,
|
||||||
onUsageReceived = { cost: Double?, freeRemaining: Int? ->
|
onUsageReceived = { cost: Double?, freeRemaining: Int? ->
|
||||||
currentCost = cost
|
currentCost = cost
|
||||||
|
|
@ -3779,11 +3873,14 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderMode.PAGINATED -> {
|
RenderMode.PAGINATED -> {
|
||||||
val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp
|
val pageInfoReserve = if (pageInfoMode != PageInfoMode.HIDDEN) pageInfoBarHeight else 0.dp
|
||||||
|
val contentTopPadding = if (pageInfoPosition == PageInfoPosition.TOP) pageInfoReserve else 0.dp
|
||||||
|
val contentBottomPadding = if (pageInfoPosition == PageInfoPosition.BOTTOM) pageInfoReserve else 0.dp
|
||||||
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.padding(top = contentTopPadding)
|
||||||
.padding(bottom = contentBottomPadding)
|
.padding(bottom = contentBottomPadding)
|
||||||
.testTag("ReaderContainer")
|
.testTag("ReaderContainer")
|
||||||
) {
|
) {
|
||||||
|
|
@ -3799,6 +3896,7 @@ fun EpubReaderHost(
|
||||||
paragraphGapMultiplier = currentParagraphGap,
|
paragraphGapMultiplier = currentParagraphGap,
|
||||||
imageSizeMultiplier = currentImageSize,
|
imageSizeMultiplier = currentImageSize,
|
||||||
horizontalMarginMultiplier = currentHorizontalMargin,
|
horizontalMarginMultiplier = currentHorizontalMargin,
|
||||||
|
verticalMarginMultiplier = currentVerticalMargin,
|
||||||
fontFamily = activeFontFamily,
|
fontFamily = activeFontFamily,
|
||||||
textAlign = currentTextAlign,
|
textAlign = currentTextAlign,
|
||||||
activeHighlightPalette = currentHighlightPalette,
|
activeHighlightPalette = currentHighlightPalette,
|
||||||
|
|
@ -3810,6 +3908,7 @@ fun EpubReaderHost(
|
||||||
offset = ttsState.startOffsetInSource
|
offset = ttsState.startOffsetInSource
|
||||||
).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 },
|
).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 },
|
||||||
activeTextureId = activeTextureId,
|
activeTextureId = activeTextureId,
|
||||||
|
activeTextureAlpha = activeTextureAlpha,
|
||||||
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
|
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
|
||||||
modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
|
modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
|
||||||
onPaginatorReady = { newPaginator ->
|
onPaginatorReady = { newPaginator ->
|
||||||
|
|
@ -4094,17 +4193,19 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
// Page Info Bar (Vertical)
|
// Page Info Bar (Vertical)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = renderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible,
|
visible = currentRenderMode == RenderMode.VERTICAL_SCROLL && isPageInfoVisible,
|
||||||
enter = fadeIn(animationSpec = tween(200)),
|
enter = fadeIn(animationSpec = tween(200)),
|
||||||
exit = fadeOut(animationSpec = tween(200)),
|
exit = fadeOut(animationSpec = tween(200)),
|
||||||
modifier = Modifier.align(Alignment.BottomCenter)
|
modifier = Modifier.align(
|
||||||
|
if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(PAGE_INFO_BAR_HEIGHT)
|
.height(pageInfoBarHeight)
|
||||||
.background(infoBarBgColor)
|
.background(infoBarBgColor)
|
||||||
.padding(bottom = bottomPadding + pageInfoBottomPadding)
|
.then(activeTextureModifier)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
|
@ -4140,17 +4241,19 @@ fun EpubReaderHost(
|
||||||
|
|
||||||
// Page Info Bar (Paginated)
|
// Page Info Bar (Paginated)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = renderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0,
|
visible = currentRenderMode == RenderMode.PAGINATED && paginator != null && isPageInfoVisible && paginatedPagerState.pageCount > 0,
|
||||||
enter = fadeIn(animationSpec = tween(200)),
|
enter = fadeIn(animationSpec = tween(200)),
|
||||||
exit = fadeOut(animationSpec = tween(200)),
|
exit = fadeOut(animationSpec = tween(200)),
|
||||||
modifier = Modifier.align(Alignment.BottomCenter)
|
modifier = Modifier.align(
|
||||||
|
if (pageInfoPosition == PageInfoPosition.TOP) Alignment.TopCenter else Alignment.BottomCenter
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(PAGE_INFO_BAR_HEIGHT)
|
.height(pageInfoBarHeight)
|
||||||
.background(infoBarBgColor)
|
.background(infoBarBgColor)
|
||||||
.padding(bottom = bottomPadding + pageInfoBottomPadding)
|
.then(activeTextureModifier)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
|
@ -4442,6 +4545,8 @@ fun EpubReaderHost(
|
||||||
volumeScrollEnabled = volumeScrollEnabled,
|
volumeScrollEnabled = volumeScrollEnabled,
|
||||||
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
|
||||||
hiddenTools = hiddenTools,
|
hiddenTools = hiddenTools,
|
||||||
|
toolOrder = toolOrder,
|
||||||
|
bottomTools = bottomTools,
|
||||||
onCustomizeTools = { showCustomizeToolsSheet = true },
|
onCustomizeTools = { showCustomizeToolsSheet = true },
|
||||||
onNavigateBack = { triggerSaveAndExit() },
|
onNavigateBack = { triggerSaveAndExit() },
|
||||||
isKeepScreenOn = isKeepScreenOn,
|
isKeepScreenOn = isKeepScreenOn,
|
||||||
|
|
@ -4522,6 +4627,71 @@ fun EpubReaderHost(
|
||||||
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
||||||
onOpenThemeSettings = { showThemePanel = true },
|
onOpenThemeSettings = { showThemePanel = true },
|
||||||
onOpenVisualOptions = { showVisualOptionsSheet = true },
|
onOpenVisualOptions = { showVisualOptionsSheet = true },
|
||||||
|
onOpenAiHub = { showAiHubSheet = true },
|
||||||
|
onOpenSlider = {
|
||||||
|
when (currentRenderMode) {
|
||||||
|
RenderMode.VERTICAL_SCROLL -> {
|
||||||
|
sliderStartPage = currentPageInChapter
|
||||||
|
sliderCurrentPage = currentPageInChapter.toFloat()
|
||||||
|
isPageSliderVisible = true
|
||||||
|
showBars = false
|
||||||
|
scope.launch {
|
||||||
|
webViewRefForTts?.let { webView ->
|
||||||
|
startPageThumbnail = captureWebViewVisibleArea(webView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RenderMode.PAGINATED -> {
|
||||||
|
if (paginatedPagerState.pageCount > 0) {
|
||||||
|
sliderStartPage = paginatedPagerState.currentPage + 1
|
||||||
|
sliderCurrentPage = (paginatedPagerState.currentPage + 1).toFloat()
|
||||||
|
isPageSliderVisible = true
|
||||||
|
showBars = false
|
||||||
|
startPageThumbnail = null
|
||||||
|
} else {
|
||||||
|
bannerMessage = BannerMessage("Book is not paginated yet.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpenDrawer = {
|
||||||
|
scope.launch { drawerState.open() }
|
||||||
|
},
|
||||||
|
onToggleFormat = {
|
||||||
|
showFormatAdjustmentBars = !showFormatAdjustmentBars
|
||||||
|
if (showFormatAdjustmentBars) {
|
||||||
|
searchState.showSearchResultsPanel = false
|
||||||
|
isPageSliderVisible = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onToggleSearch = {
|
||||||
|
searchState.isSearchActive = true
|
||||||
|
searchState.showSearchResultsPanel = true
|
||||||
|
showBars = true
|
||||||
|
showFormatAdjustmentBars = false
|
||||||
|
},
|
||||||
|
onToggleTts = {
|
||||||
|
if (isTtsSessionActive) {
|
||||||
|
Timber.d("TTS button clicked: Stopping TTS")
|
||||||
|
userStoppedTts = true
|
||||||
|
ttsController.stop()
|
||||||
|
} else {
|
||||||
|
when {
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED -> {
|
||||||
|
startTts()
|
||||||
|
}
|
||||||
|
activity?.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) == true -> {
|
||||||
|
showPermissionRationaleDialog = true
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onToggleReflow = if (onToggleReflow != null) {
|
onToggleReflow = if (onToggleReflow != null) {
|
||||||
{
|
{
|
||||||
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
|
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
|
||||||
|
|
@ -4687,8 +4857,12 @@ fun EpubReaderHost(
|
||||||
ttsState = ttsState,
|
ttsState = ttsState,
|
||||||
isProUser = isProUser,
|
isProUser = isProUser,
|
||||||
hiddenTools = hiddenTools,
|
hiddenTools = hiddenTools,
|
||||||
|
toolOrder = toolOrder,
|
||||||
|
bottomTools = bottomTools,
|
||||||
currentTtsMode = currentTtsMode,
|
currentTtsMode = currentTtsMode,
|
||||||
onOpenAiHub = { showAiHubSheet = true },
|
onOpenAiHub = { showAiHubSheet = true },
|
||||||
|
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
||||||
|
onOpenThemeSettings = { showThemePanel = true },
|
||||||
onOpenSlider = {
|
onOpenSlider = {
|
||||||
when (currentRenderMode) {
|
when (currentRenderMode) {
|
||||||
RenderMode.VERTICAL_SCROLL -> {
|
RenderMode.VERTICAL_SCROLL -> {
|
||||||
|
|
@ -4770,6 +4944,8 @@ fun EpubReaderHost(
|
||||||
onImageSizeChange = { currentImageSize = it },
|
onImageSizeChange = { currentImageSize = it },
|
||||||
currentHorizontalMargin = currentHorizontalMargin,
|
currentHorizontalMargin = currentHorizontalMargin,
|
||||||
onHorizontalMarginChange = { currentHorizontalMargin = it },
|
onHorizontalMarginChange = { currentHorizontalMargin = it },
|
||||||
|
currentVerticalMargin = currentVerticalMargin,
|
||||||
|
onVerticalMarginChange = { currentVerticalMargin = it },
|
||||||
currentFont = currentFontFamily,
|
currentFont = currentFontFamily,
|
||||||
currentCustomFontName = if(currentCustomFontPath != null) {
|
currentCustomFontName = if(currentCustomFontPath != null) {
|
||||||
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font"
|
customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font"
|
||||||
|
|
@ -4788,6 +4964,7 @@ fun EpubReaderHost(
|
||||||
currentParagraphGap = DEFAULT_PARAGRAPH_GAP_VAL
|
currentParagraphGap = DEFAULT_PARAGRAPH_GAP_VAL
|
||||||
currentImageSize = DEFAULT_IMAGE_SIZE_VAL
|
currentImageSize = DEFAULT_IMAGE_SIZE_VAL
|
||||||
currentHorizontalMargin = DEFAULT_HORIZONTAL_MARGIN_VAL
|
currentHorizontalMargin = DEFAULT_HORIZONTAL_MARGIN_VAL
|
||||||
|
currentVerticalMargin = DEFAULT_VERTICAL_MARGIN_VAL
|
||||||
currentFontFamily = ReaderFont.ORIGINAL
|
currentFontFamily = ReaderFont.ORIGINAL
|
||||||
currentCustomFontPath = null
|
currentCustomFontPath = null
|
||||||
currentTextAlign = ReaderTextAlign.DEFAULT
|
currentTextAlign = ReaderTextAlign.DEFAULT
|
||||||
|
|
@ -5085,10 +5262,20 @@ fun EpubReaderHost(
|
||||||
if (showCustomizeToolsSheet) {
|
if (showCustomizeToolsSheet) {
|
||||||
CustomizeToolsSheet(
|
CustomizeToolsSheet(
|
||||||
hiddenTools = hiddenTools,
|
hiddenTools = hiddenTools,
|
||||||
|
toolOrder = toolOrder,
|
||||||
|
bottomTools = bottomTools,
|
||||||
onUpdate = { newHiddenSet ->
|
onUpdate = { newHiddenSet ->
|
||||||
hiddenTools = newHiddenSet
|
hiddenTools = newHiddenSet
|
||||||
saveHiddenTools(context, newHiddenSet)
|
saveHiddenTools(context, newHiddenSet)
|
||||||
},
|
},
|
||||||
|
onOrderUpdate = { newOrder ->
|
||||||
|
toolOrder = newOrder
|
||||||
|
saveToolOrder(context, newOrder)
|
||||||
|
},
|
||||||
|
onPlacementUpdate = { newBottomTools ->
|
||||||
|
bottomTools = newBottomTools
|
||||||
|
saveBottomTools(context, newBottomTools)
|
||||||
|
},
|
||||||
onDismiss = { showCustomizeToolsSheet = false }
|
onDismiss = { showCustomizeToolsSheet = false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -5133,6 +5320,11 @@ fun EpubReaderHost(
|
||||||
pageInfoMode = it
|
pageInfoMode = it
|
||||||
savePageInfoMode(context, it)
|
savePageInfoMode(context, it)
|
||||||
},
|
},
|
||||||
|
pageInfoPosition = pageInfoPosition,
|
||||||
|
onPageInfoPositionChange = {
|
||||||
|
pageInfoPosition = it
|
||||||
|
savePageInfoPosition(context, it)
|
||||||
|
},
|
||||||
pullToTurnEnabled = pullToTurnEnabled,
|
pullToTurnEnabled = pullToTurnEnabled,
|
||||||
onPullToTurnChange = {
|
onPullToTurnChange = {
|
||||||
pullToTurnEnabled = it
|
pullToTurnEnabled = it
|
||||||
|
|
@ -5173,6 +5365,11 @@ fun EpubReaderHost(
|
||||||
ReaderThemePanel(
|
ReaderThemePanel(
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
currentThemeId = currentThemeId,
|
currentThemeId = currentThemeId,
|
||||||
|
globalTextureTransparency = globalTextureTransparency,
|
||||||
|
onGlobalTextureTransparencyChange = {
|
||||||
|
globalTextureTransparency = it
|
||||||
|
saveGlobalTextureTransparency(context, it)
|
||||||
|
},
|
||||||
onThemeSelected = {
|
onThemeSelected = {
|
||||||
currentThemeId = it
|
currentThemeId = it
|
||||||
saveReaderThemeId(context, it)
|
saveReaderThemeId(context, it)
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
|
||||||
private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled"
|
private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled"
|
||||||
private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode"
|
private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode"
|
||||||
private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
|
private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
|
||||||
|
private const val PAGE_INFO_POSITION_KEY = "reader_page_info_position"
|
||||||
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
|
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
|
||||||
|
|
||||||
const val DEFAULT_FONT_SIZE_VAL = 1.0f
|
const val DEFAULT_FONT_SIZE_VAL = 1.0f
|
||||||
|
|
@ -127,6 +128,7 @@ const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
|
||||||
const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f
|
const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f
|
||||||
const val DEFAULT_IMAGE_SIZE_VAL = 1.0f
|
const val DEFAULT_IMAGE_SIZE_VAL = 1.0f
|
||||||
const val DEFAULT_HORIZONTAL_MARGIN_VAL = 1.0f
|
const val DEFAULT_HORIZONTAL_MARGIN_VAL = 1.0f
|
||||||
|
const val DEFAULT_VERTICAL_MARGIN_VAL = 1.0f
|
||||||
private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate"
|
private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate"
|
||||||
private const val TTS_PITCH_KEY = "tts_pitch"
|
private const val TTS_PITCH_KEY = "tts_pitch"
|
||||||
|
|
||||||
|
|
@ -177,12 +179,18 @@ enum class PageInfoMode(val id: Int, val title: String) {
|
||||||
HIDDEN(2, "Always Hide")
|
HIDDEN(2, "Always Hide")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class PageInfoPosition(val id: Int, val title: String) {
|
||||||
|
BOTTOM(0, "Bottom"),
|
||||||
|
TOP(1, "Top")
|
||||||
|
}
|
||||||
|
|
||||||
data class FormatSettings(
|
data class FormatSettings(
|
||||||
val fontSize: Float,
|
val fontSize: Float,
|
||||||
val lineHeight: Float,
|
val lineHeight: Float,
|
||||||
val paragraphGap: Float,
|
val paragraphGap: Float,
|
||||||
val imageSize: Float,
|
val imageSize: Float,
|
||||||
val horizontalMargin: Float,
|
val horizontalMargin: Float,
|
||||||
|
val verticalMargin: Float,
|
||||||
val font: ReaderFont,
|
val font: ReaderFont,
|
||||||
val customPath: String?,
|
val customPath: String?,
|
||||||
val textAlign: ReaderTextAlign
|
val textAlign: ReaderTextAlign
|
||||||
|
|
@ -194,9 +202,11 @@ private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_"
|
||||||
private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_"
|
private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_"
|
||||||
private const val LOCAL_IMAGE_SIZE_PREFIX = "local_image_size_"
|
private const val LOCAL_IMAGE_SIZE_PREFIX = "local_image_size_"
|
||||||
private const val LOCAL_HORIZONTAL_MARGIN_PREFIX = "local_horizontal_margin_"
|
private const val LOCAL_HORIZONTAL_MARGIN_PREFIX = "local_horizontal_margin_"
|
||||||
|
private const val LOCAL_VERTICAL_MARGIN_PREFIX = "local_vertical_margin_"
|
||||||
private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_"
|
private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_"
|
||||||
private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_"
|
private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_"
|
||||||
private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin"
|
private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin"
|
||||||
|
private const val VERTICAL_MARGIN_KEY = "reader_vertical_margin"
|
||||||
|
|
||||||
fun loadFormatIsLocal(context: Context, bookId: String): Boolean {
|
fun loadFormatIsLocal(context: Context, bookId: String): Boolean {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
@ -216,6 +226,7 @@ fun saveLocalReaderSettings(
|
||||||
paragraphGap: Float,
|
paragraphGap: Float,
|
||||||
imageSize: Float,
|
imageSize: Float,
|
||||||
horizontalMargin: Float,
|
horizontalMargin: Float,
|
||||||
|
verticalMargin: Float,
|
||||||
fontFamily: ReaderFont,
|
fontFamily: ReaderFont,
|
||||||
customFontPath: String?,
|
customFontPath: String?,
|
||||||
textAlign: ReaderTextAlign
|
textAlign: ReaderTextAlign
|
||||||
|
|
@ -227,6 +238,7 @@ fun saveLocalReaderSettings(
|
||||||
putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap)
|
putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap)
|
||||||
putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize)
|
putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize)
|
||||||
putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin)
|
putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin)
|
||||||
|
putFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, verticalMargin)
|
||||||
if (customFontPath != null) {
|
if (customFontPath != null) {
|
||||||
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
|
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -258,6 +270,17 @@ fun loadPageInfoMode(context: Context): PageInfoMode {
|
||||||
return PageInfoMode.entries.find { it.id == id } ?: PageInfoMode.DEFAULT
|
return PageInfoMode.entries.find { it.id == id } ?: PageInfoMode.DEFAULT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun savePageInfoPosition(context: Context, position: PageInfoPosition) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putInt(PAGE_INFO_POSITION_KEY, position.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPageInfoPosition(context: Context): PageInfoPosition {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val id = prefs.getInt(PAGE_INFO_POSITION_KEY, PageInfoPosition.BOTTOM.id)
|
||||||
|
return PageInfoPosition.entries.find { it.id == id } ?: PageInfoPosition.BOTTOM
|
||||||
|
}
|
||||||
|
|
||||||
fun savePullToTurn(context: Context, enabled: Boolean) {
|
fun savePullToTurn(context: Context, enabled: Boolean) {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
prefs.edit { putBoolean(PULL_TO_TURN_ENABLED_KEY, enabled) }
|
prefs.edit { putBoolean(PULL_TO_TURN_ENABLED_KEY, enabled) }
|
||||||
|
|
@ -288,6 +311,11 @@ fun loadHorizontalMargin(context: Context): Float {
|
||||||
return if (loadRemoveEdgePadding(context)) 0f else DEFAULT_HORIZONTAL_MARGIN_VAL
|
return if (loadRemoveEdgePadding(context)) 0f else DEFAULT_HORIZONTAL_MARGIN_VAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadVerticalMargin(context: Context): Float {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getFloat(VERTICAL_MARGIN_KEY, DEFAULT_VERTICAL_MARGIN_VAL)
|
||||||
|
}
|
||||||
|
|
||||||
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
|
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
|
@ -321,6 +349,12 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
|
||||||
loadHorizontalMargin(context)
|
loadHorizontalMargin(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val verticalMargin = if (isLocal && prefs.contains(LOCAL_VERTICAL_MARGIN_PREFIX + bookId)) {
|
||||||
|
prefs.getFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, DEFAULT_VERTICAL_MARGIN_VAL)
|
||||||
|
} else {
|
||||||
|
loadVerticalMargin(context)
|
||||||
|
}
|
||||||
|
|
||||||
val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) {
|
val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) {
|
||||||
prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
|
prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -346,6 +380,7 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
|
||||||
paragraphGap = paragraphGap,
|
paragraphGap = paragraphGap,
|
||||||
imageSize = imageSize,
|
imageSize = imageSize,
|
||||||
horizontalMargin = horizontalMargin,
|
horizontalMargin = horizontalMargin,
|
||||||
|
verticalMargin = verticalMargin,
|
||||||
font = font,
|
font = font,
|
||||||
customPath = customPath,
|
customPath = customPath,
|
||||||
textAlign = textAlign
|
textAlign = textAlign
|
||||||
|
|
@ -390,6 +425,7 @@ fun saveReaderSettings(
|
||||||
paragraphGap: Float,
|
paragraphGap: Float,
|
||||||
imageSize: Float,
|
imageSize: Float,
|
||||||
horizontalMargin: Float,
|
horizontalMargin: Float,
|
||||||
|
verticalMargin: Float,
|
||||||
fontFamily: ReaderFont,
|
fontFamily: ReaderFont,
|
||||||
customFontPath: String?,
|
customFontPath: String?,
|
||||||
textAlign: ReaderTextAlign
|
textAlign: ReaderTextAlign
|
||||||
|
|
@ -401,6 +437,7 @@ fun saveReaderSettings(
|
||||||
putFloat(PARAGRAPH_GAP_KEY, paragraphGap)
|
putFloat(PARAGRAPH_GAP_KEY, paragraphGap)
|
||||||
putFloat(IMAGE_SIZE_KEY, imageSize)
|
putFloat(IMAGE_SIZE_KEY, imageSize)
|
||||||
putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin)
|
putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin)
|
||||||
|
putFloat(VERTICAL_MARGIN_KEY, verticalMargin)
|
||||||
if (customFontPath != null) {
|
if (customFontPath != null) {
|
||||||
putString(FONT_FAMILY_KEY, "custom|$customFontPath")
|
putString(FONT_FAMILY_KEY, "custom|$customFontPath")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -454,6 +491,8 @@ fun ReaderTextFormatPanel(
|
||||||
onImageSizeChange: (Float) -> Unit,
|
onImageSizeChange: (Float) -> Unit,
|
||||||
currentHorizontalMargin: Float,
|
currentHorizontalMargin: Float,
|
||||||
onHorizontalMarginChange: (Float) -> Unit,
|
onHorizontalMarginChange: (Float) -> Unit,
|
||||||
|
currentVerticalMargin: Float,
|
||||||
|
onVerticalMarginChange: (Float) -> Unit,
|
||||||
currentFont: ReaderFont,
|
currentFont: ReaderFont,
|
||||||
currentCustomFontName: String?,
|
currentCustomFontName: String?,
|
||||||
onFontOptionClick: () -> Unit,
|
onFontOptionClick: () -> Unit,
|
||||||
|
|
@ -699,6 +738,20 @@ fun ReaderTextFormatPanel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
FormatSlider(
|
||||||
|
label = stringResource(R.string.label_vertical_margin),
|
||||||
|
value = currentVerticalMargin,
|
||||||
|
onValueChange = onVerticalMarginChange,
|
||||||
|
valueRange = 0.0f..3.0f,
|
||||||
|
formatValue = {
|
||||||
|
when {
|
||||||
|
it <= 0.01f -> noneLabel
|
||||||
|
it in 0.99f..1.01f -> originalLabel
|
||||||
|
else -> "%.1fx".format(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -830,6 +883,8 @@ fun VisualOptionsSheet(
|
||||||
onSystemUiModeChange: (SystemUiMode) -> Unit,
|
onSystemUiModeChange: (SystemUiMode) -> Unit,
|
||||||
pageInfoMode: PageInfoMode,
|
pageInfoMode: PageInfoMode,
|
||||||
onPageInfoModeChange: (PageInfoMode) -> Unit,
|
onPageInfoModeChange: (PageInfoMode) -> Unit,
|
||||||
|
pageInfoPosition: PageInfoPosition,
|
||||||
|
onPageInfoPositionChange: (PageInfoPosition) -> Unit,
|
||||||
pullToTurnEnabled: Boolean,
|
pullToTurnEnabled: Boolean,
|
||||||
onPullToTurnChange: (Boolean) -> Unit,
|
onPullToTurnChange: (Boolean) -> Unit,
|
||||||
pullToTurnMultiplier: Float,
|
pullToTurnMultiplier: Float,
|
||||||
|
|
@ -884,6 +939,16 @@ fun VisualOptionsSheet(
|
||||||
getLabel = { it.title }
|
getLabel = { it.title }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(stringResource(R.string.visual_options_progress_bar_position), style = MaterialTheme.typography.titleSmall)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
OptionSegmentedControl(
|
||||||
|
options = PageInfoPosition.entries,
|
||||||
|
selectedOption = pageInfoPosition,
|
||||||
|
onOptionSelected = onPageInfoPositionChange,
|
||||||
|
getLabel = { it.title }
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Pull to change chapter
|
// Pull to change chapter
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
package com.aryan.reader.epubreader
|
package com.aryan.reader.epubreader
|
||||||
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import android.graphics.Color
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.Window
|
import android.view.Window
|
||||||
|
|
@ -51,14 +52,20 @@ fun EpubReaderSystemUiController(
|
||||||
return@DisposableEffect onDispose {}
|
return@DisposableEffect onDispose {}
|
||||||
}
|
}
|
||||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||||
|
val originalStatusBarColor = window.statusBarColor
|
||||||
|
val originalNavigationBarColor = window.navigationBarColor
|
||||||
Timber.d("Applying immersive mode.")
|
Timber.d("Applying immersive mode.")
|
||||||
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
window.statusBarColor = Color.TRANSPARENT
|
||||||
|
window.navigationBarColor = Color.TRANSPARENT
|
||||||
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
Timber.d("Restoring system UI.")
|
Timber.d("Restoring system UI.")
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||||
|
window.statusBarColor = originalStatusBarColor
|
||||||
|
window.navigationBarColor = originalNavigationBarColor
|
||||||
insetsController.show(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.statusBars())
|
insetsController.show(WindowInsetsCompat.Type.navigationBars() or WindowInsetsCompat.Type.statusBars())
|
||||||
insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars
|
insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars
|
||||||
insetsController.systemBarsBehavior = initialSystemBarsBehavior
|
insetsController.systemBarsBehavior = initialSystemBarsBehavior
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,8 @@ private fun handleVerticalAutoAdvance(
|
||||||
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() },
|
||||||
chapterIndex = currentTtsChapterIndex,
|
chapterIndex = currentTtsChapterIndex,
|
||||||
|
totalChapters = chapters.size,
|
||||||
|
continueSession = true,
|
||||||
ttsMode = currentTtsMode,
|
ttsMode = currentTtsMode,
|
||||||
playbackSource = "READER",
|
playbackSource = "READER",
|
||||||
authToken = token
|
authToken = token
|
||||||
|
|
@ -358,6 +360,8 @@ private fun handleVerticalAutoAdvance(
|
||||||
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() },
|
||||||
chapterIndex = nextIdx,
|
chapterIndex = nextIdx,
|
||||||
|
totalChapters = chapters.size,
|
||||||
|
continueSession = true,
|
||||||
ttsMode = currentTtsMode,
|
ttsMode = currentTtsMode,
|
||||||
playbackSource = "READER",
|
playbackSource = "READER",
|
||||||
authToken = token
|
authToken = token
|
||||||
|
|
@ -433,6 +437,8 @@ private fun handlePaginatedAutoAdvance(
|
||||||
chapterTitle = chapterTitle,
|
chapterTitle = chapterTitle,
|
||||||
coverImageUri = coverUriString,
|
coverImageUri = coverUriString,
|
||||||
chapterIndex = chapterToTry,
|
chapterIndex = chapterToTry,
|
||||||
|
totalChapters = chapters.size,
|
||||||
|
continueSession = true,
|
||||||
ttsMode = ttsMode,
|
ttsMode = ttsMode,
|
||||||
playbackSource = "READER",
|
playbackSource = "READER",
|
||||||
authToken = token
|
authToken = token
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
package com.aryan.reader.feedback
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.aryan.reader.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SupportProjectScreen(
|
||||||
|
navController: NavHostController
|
||||||
|
) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.support_project_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.FavoriteBorder,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.support_project_heading),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.support_project_desc),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
|
SupportOptionCard(
|
||||||
|
title = stringResource(R.string.support_github_sponsor),
|
||||||
|
description = stringResource(R.string.support_github_sponsor_desc),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.github),
|
||||||
|
contentDescription = stringResource(R.string.support_github_sponsor),
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
uriHandler.openUri("https://github.com/sponsors/Aryan-Raj3112")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
SupportOptionCard(
|
||||||
|
title = stringResource(R.string.support_patreon),
|
||||||
|
description = stringResource(R.string.support_patreon_desc),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.FavoriteBorder,
|
||||||
|
contentDescription = stringResource(R.string.support_patreon),
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
uriHandler.openUri("https://www.patreon.com/c/epistemereader")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SupportOptionCard(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
icon: @Composable () -> Unit,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
OutlinedCard(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
icon()
|
||||||
|
Spacer(modifier = Modifier.width(20.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
contentDescription = stringResource(R.string.action_open),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,8 @@ data class OpdsAcquisition(
|
||||||
get() = when {
|
get() = when {
|
||||||
mimeType.contains("epub") -> "EPUB"
|
mimeType.contains("epub") -> "EPUB"
|
||||||
mimeType.contains("pdf") -> "PDF"
|
mimeType.contains("pdf") -> "PDF"
|
||||||
|
mimeType.contains("markdown") || mimeType.contains("text/x-markdown") -> "MD"
|
||||||
|
mimeType.contains("html") || mimeType.contains("xhtml") -> "HTML"
|
||||||
mimeType.contains("mobi") || mimeType.contains("x-mobipocket-ebook") -> "MOBI"
|
mimeType.contains("mobi") || mimeType.contains("x-mobipocket-ebook") -> "MOBI"
|
||||||
mimeType.contains("fictionbook") || mimeType.contains("fb2") -> "FB2"
|
mimeType.contains("fictionbook") || mimeType.contains("fb2") -> "FB2"
|
||||||
mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ"
|
mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ"
|
||||||
|
|
@ -52,6 +54,7 @@ data class OpdsAcquisition(
|
||||||
"PDF" -> 4
|
"PDF" -> 4
|
||||||
"MOBI" -> 3
|
"MOBI" -> 3
|
||||||
"FB2" -> 2
|
"FB2" -> 2
|
||||||
|
"MD", "HTML" -> 2
|
||||||
"CBZ" -> 1
|
"CBZ" -> 1
|
||||||
"TXT" -> 0
|
"TXT" -> 0
|
||||||
else -> -1
|
else -> -1
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.aryan.reader.resolveFileExtensionSuffixFromName
|
||||||
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
|
||||||
|
|
@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
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 timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -93,16 +95,7 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
val body = response.body ?: throw Exception("Empty body")
|
val body = response.body ?: throw Exception("Empty body")
|
||||||
val contentLength = body.contentLength()
|
val contentLength = body.contentLength()
|
||||||
|
|
||||||
val ext = when (acquisition.formatName) {
|
val ext = resolveOpdsDownloadExtension(acquisition, response)
|
||||||
"EPUB" -> ".epub"
|
|
||||||
"PDF" -> ".pdf"
|
|
||||||
"MOBI" -> ".mobi"
|
|
||||||
"FB2" -> ".fb2"
|
|
||||||
"CBZ" -> ".cbz"
|
|
||||||
"CBR" -> ".cbr"
|
|
||||||
"TXT" -> ".txt"
|
|
||||||
else -> ".epub"
|
|
||||||
}
|
|
||||||
|
|
||||||
val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50)
|
val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50)
|
||||||
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
|
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
|
||||||
|
|
@ -148,6 +141,45 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
|
||||||
|
val candidates = listOfNotNull(
|
||||||
|
response.header("Content-Disposition")?.let(::extractContentDispositionFilename),
|
||||||
|
Uri.parse(acquisition.url).lastPathSegment
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates.forEach { candidate ->
|
||||||
|
resolveFileExtensionSuffixFromName(Uri.decode(candidate))?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (acquisition.formatName) {
|
||||||
|
"EPUB" -> ".epub"
|
||||||
|
"PDF" -> ".pdf"
|
||||||
|
"MOBI" -> ".mobi"
|
||||||
|
"FB2" -> ".fb2"
|
||||||
|
"CBZ" -> ".cbz"
|
||||||
|
"CBR" -> ".cbr"
|
||||||
|
"MD" -> ".md"
|
||||||
|
"HTML" -> ".html"
|
||||||
|
"TXT" -> ".txt"
|
||||||
|
else -> ".epub"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractContentDispositionFilename(contentDisposition: String): String? {
|
||||||
|
val encodedFilename = Regex("filename\\*=UTF-8''([^;]+)", RegexOption.IGNORE_CASE)
|
||||||
|
.find(contentDisposition)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"')
|
||||||
|
|
||||||
|
return Regex("filename=\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
|
||||||
|
.find(contentDisposition)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
?.trim()
|
||||||
|
?.trim('"')
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadCatalogs()
|
loadCatalogs()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import java.io.File
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
object AndroidHtmlResourceResolver : HtmlResourceResolver {
|
||||||
|
override fun resolvePath(chapterAbsPath: String, extractionBasePath: String, src: String): String? {
|
||||||
|
if (src.isBlank()) return null
|
||||||
|
val decodedSrc = try {
|
||||||
|
URLDecoder.decode(src, "UTF-8")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
src
|
||||||
|
}
|
||||||
|
val parentPath = File(chapterAbsPath).parent ?: ""
|
||||||
|
val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString()
|
||||||
|
val fromRelativeFile = File(extractionBasePath, relativePath)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
when {
|
||||||
|
fromRelativeFile.exists() -> fromRelativeFile.canonicalFile.absolutePath
|
||||||
|
File(extractionBasePath, decodedSrc).exists() -> File(extractionBasePath, decodedSrc).canonicalFile.absolutePath
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun readText(path: String): String? {
|
||||||
|
return runCatching { File(path).readText() }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageDimensions(path: String): Pair<Float?, Float?>? {
|
||||||
|
return runCatching {
|
||||||
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(path, options)
|
||||||
|
if (options.outWidth > 0 && options.outHeight > 0) {
|
||||||
|
options.outWidth.toFloat() to options.outHeight.toFloat()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object AndroidHtmlFontFamilyLoader : HtmlFontFamilyLoader {
|
||||||
|
override fun load(fontFaces: List<FontFaceInfo>, extractionBasePath: String): Map<String, FontFamily> {
|
||||||
|
return loadFontFamilies(fontFaces, extractionBasePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun androidHtmlToSemanticBlocks(
|
||||||
|
html: String,
|
||||||
|
cssRules: OptimizedCssRules,
|
||||||
|
textStyle: androidx.compose.ui.text.TextStyle,
|
||||||
|
chapterAbsPath: String,
|
||||||
|
extractionBasePath: String,
|
||||||
|
density: androidx.compose.ui.unit.Density,
|
||||||
|
fontFamilyMap: Map<String, FontFamily>,
|
||||||
|
constraints: androidx.compose.ui.unit.Constraints,
|
||||||
|
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
|
||||||
|
mathSvgCache: Map<String, String> = emptyMap()
|
||||||
|
): List<SemanticBlock> {
|
||||||
|
return htmlToSemanticBlocks(
|
||||||
|
html = html,
|
||||||
|
cssRules = cssRules,
|
||||||
|
textStyle = textStyle,
|
||||||
|
chapterAbsPath = chapterAbsPath,
|
||||||
|
extractionBasePath = extractionBasePath,
|
||||||
|
density = density,
|
||||||
|
fontFamilyMap = fontFamilyMap,
|
||||||
|
constraints = constraints,
|
||||||
|
imageDimensionsCache = imageDimensionsCache,
|
||||||
|
mathSvgCache = mathSvgCache,
|
||||||
|
resourceResolver = AndroidHtmlResourceResolver,
|
||||||
|
fontFamilyLoader = AndroidHtmlFontFamilyLoader
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -120,7 +120,8 @@ class BookPaginator(
|
||||||
private val mathMLRenderer: MathMLRenderer,
|
private val mathMLRenderer: MathMLRenderer,
|
||||||
private val userTextAlign: TextAlign?,
|
private val userTextAlign: TextAlign?,
|
||||||
private val paragraphGapMultiplier: Float,
|
private val paragraphGapMultiplier: Float,
|
||||||
private val imageSizeMultiplier: Float
|
private val imageSizeMultiplier: Float,
|
||||||
|
private val verticalMarginMultiplier: Float
|
||||||
) : IPaginator {
|
) : IPaginator {
|
||||||
override var totalPageCount by mutableIntStateOf(0)
|
override var totalPageCount by mutableIntStateOf(0)
|
||||||
private set
|
private set
|
||||||
|
|
@ -185,6 +186,14 @@ class BookPaginator(
|
||||||
isLoading = true
|
isLoading = true
|
||||||
Timber.d("Initialization started.")
|
Timber.d("Initialization started.")
|
||||||
|
|
||||||
|
if (chapters.isEmpty()) {
|
||||||
|
totalPageCount = 0
|
||||||
|
pageCountsAreAccurate = true
|
||||||
|
isLoading = false
|
||||||
|
Timber.w("Paginator initialized with no chapters. Skipping pagination startup.")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Book processing check (Keep existing logic)
|
// 1. Book processing check (Keep existing logic)
|
||||||
val bookRecord = bookCacheDao.getProcessedBook(bookId)
|
val bookRecord = bookCacheDao.getProcessedBook(bookId)
|
||||||
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
|
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
|
||||||
|
|
@ -214,7 +223,7 @@ class BookPaginator(
|
||||||
// 5. Prioritize CURRENT chapter only
|
// 5. Prioritize CURRENT chapter only
|
||||||
// We no longer blindly queue neighbors immediately to keep startup fast.
|
// We no longer blindly queue neighbors immediately to keep startup fast.
|
||||||
// We only queue the requested chapter.
|
// We only queue the requested chapter.
|
||||||
val startChapter = initialChapterToPaginate.coerceIn(0, chapters.size - 1)
|
val startChapter = initialChapterToPaginate.coerceIn(0, chapters.lastIndex)
|
||||||
|
|
||||||
// Trigger actual pagination for the current chapter to replace the estimate with reality
|
// Trigger actual pagination for the current chapter to replace the estimate with reality
|
||||||
triggerPagination(startChapter, PRIORITY_HIGHEST)
|
triggerPagination(startChapter, PRIORITY_HIGHEST)
|
||||||
|
|
@ -277,6 +286,7 @@ class BookPaginator(
|
||||||
append("-ta:$userTextAlign")
|
append("-ta:$userTextAlign")
|
||||||
append("-pg:$paragraphGapMultiplier")
|
append("-pg:$paragraphGapMultiplier")
|
||||||
append("-img:$imageSizeMultiplier")
|
append("-img:$imageSizeMultiplier")
|
||||||
|
append("-vm:$verticalMarginMultiplier")
|
||||||
}
|
}
|
||||||
val hash = configString.hashCode()
|
val hash = configString.hashCode()
|
||||||
return hash
|
return hash
|
||||||
|
|
@ -503,7 +513,7 @@ class BookPaginator(
|
||||||
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
|
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
|
||||||
}
|
}
|
||||||
|
|
||||||
val semanticBlocks = htmlToSemanticBlocks(
|
val semanticBlocks = androidHtmlToSemanticBlocks(
|
||||||
html = processedHtml,
|
html = processedHtml,
|
||||||
cssRules = parsingCssRules,
|
cssRules = parsingCssRules,
|
||||||
textStyle = textStyle.copy(color = Color.Black),
|
textStyle = textStyle.copy(color = Color.Black),
|
||||||
|
|
@ -792,6 +802,10 @@ class BookPaginator(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun triggerPagination(chapterIndex: Int, priority: Int) {
|
private fun triggerPagination(chapterIndex: Int, priority: Int) {
|
||||||
|
if (chapterIndex !in chapters.indices) {
|
||||||
|
Timber.w("Trigger: Ignoring invalid chapter index $chapterIndex. Chapter count: ${chapters.size}.")
|
||||||
|
return
|
||||||
|
}
|
||||||
if (pageCache[chapterIndex] != null) {
|
if (pageCache[chapterIndex] != null) {
|
||||||
Timber.v("Trigger: Chapter $chapterIndex is already in cache. Ignoring.")
|
Timber.v("Trigger: Chapter $chapterIndex is already in cache. Ignoring.")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -163,10 +163,12 @@ class ContentStyler(
|
||||||
}
|
}
|
||||||
|
|
||||||
is SemanticMath -> {
|
is SemanticMath -> {
|
||||||
|
val svgContent = block.svgContent
|
||||||
|
val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
|
||||||
val finalSvgContent = when {
|
val finalSvgContent = when {
|
||||||
block.isFromMathJax || block.svgContent.isNullOrBlank() -> block.svgContent
|
block.isFromMathJax || nonBlankSvgContent == null -> svgContent
|
||||||
else -> {
|
else -> {
|
||||||
val themedSvg = applyThemeToSvg(block.svgContent)
|
val themedSvg = applyThemeToSvg(nonBlankSvgContent)
|
||||||
embedImagesInSvg(themedSvg)
|
embedImagesInSvg(themedSvg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -452,11 +454,11 @@ class ContentStyler(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (span.linkHref != null) {
|
span.linkHref?.let { linkHref ->
|
||||||
addStringAnnotation("URL", span.linkHref, span.start, span.end)
|
addStringAnnotation("URL", linkHref, span.start, span.end)
|
||||||
}
|
}
|
||||||
if (span.elementId != null) {
|
span.elementId?.let { elementId ->
|
||||||
addStringAnnotation("ID", span.elementId, span.start, span.end)
|
addStringAnnotation("ID", elementId, span.start, span.end)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,47 +27,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
/**
|
|
||||||
* A centralized mapper for handling conversions between generic CSS font family names
|
|
||||||
* and Compose's FontFamily objects.
|
|
||||||
*/
|
|
||||||
object FontFamilyMapper {
|
|
||||||
private val genericFontMap = mapOf(
|
|
||||||
"serif" to FontFamily.Serif,
|
|
||||||
"sans-serif" to FontFamily.SansSerif,
|
|
||||||
"monospace" to FontFamily.Monospace,
|
|
||||||
"cursive" to FontFamily.Cursive,
|
|
||||||
"default" to FontFamily.Default,
|
|
||||||
"system-ui" to FontFamily.Default,
|
|
||||||
"ui-sans-serif" to FontFamily.Default,
|
|
||||||
"ui-serif" to FontFamily.Default,
|
|
||||||
"ui-monospace" to FontFamily.Default,
|
|
||||||
"ui-rounded" to FontFamily.Default
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a string name (e.g., "serif") to a Compose [FontFamily].
|
|
||||||
*/
|
|
||||||
fun nameToFontFamily(name: String): FontFamily? {
|
|
||||||
return genericFontMap[name.trim().lowercase()]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a Compose [FontFamily] back to its primary string name for serialization.
|
|
||||||
* Custom fonts are not serialized by name and will return null.
|
|
||||||
*/
|
|
||||||
fun fontFamilyToName(fontFamily: FontFamily): String? {
|
|
||||||
return when (fontFamily) {
|
|
||||||
FontFamily.Serif -> "serif"
|
|
||||||
FontFamily.SansSerif -> "sans-serif"
|
|
||||||
FontFamily.Monospace -> "monospace"
|
|
||||||
FontFamily.Cursive -> "cursive"
|
|
||||||
FontFamily.Default -> "default"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCacheKeyForFont(bookId: String, fontPath: String): String {
|
private fun getCacheKeyForFont(bookId: String, fontPath: String): String {
|
||||||
val identifier = "$bookId:$fontPath"
|
val identifier = "$bookId:$fontPath"
|
||||||
val digest = MessageDigest.getInstance("MD5").digest(identifier.toByteArray())
|
val digest = MessageDigest.getInstance("MD5").digest(identifier.toByteArray())
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ class LocatorConverter(
|
||||||
otherComplex = mergedOtherComplex
|
otherComplex = mergedOtherComplex
|
||||||
)
|
)
|
||||||
|
|
||||||
val semanticBlocks = htmlToSemanticBlocks(
|
val semanticBlocks = androidHtmlToSemanticBlocks(
|
||||||
html = htmlToParse,
|
html = htmlToParse,
|
||||||
cssRules = parsingCssRules,
|
cssRules = parsingCssRules,
|
||||||
textStyle = TextStyle(),
|
textStyle = TextStyle(),
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
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.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -147,7 +148,7 @@ import coil.compose.AsyncImage
|
||||||
import coil.imageLoader
|
import coil.imageLoader
|
||||||
import coil.request.ImageRequest.Builder
|
import coil.request.ImageRequest.Builder
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.ReaderTexture
|
import com.aryan.reader.loadReaderTextureBitmap
|
||||||
import com.aryan.reader.countWords
|
import com.aryan.reader.countWords
|
||||||
import com.aryan.reader.epub.EpubBook
|
import com.aryan.reader.epub.EpubBook
|
||||||
import com.aryan.reader.epubreader.HighlightColor
|
import com.aryan.reader.epubreader.HighlightColor
|
||||||
|
|
@ -251,6 +252,12 @@ private fun headerFontScale(level: Int): Float = when (level) {
|
||||||
else -> 1.0f
|
else -> 1.0f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER = 1.2f
|
||||||
|
|
||||||
|
private fun paginationLineHeightMultiplierForWebViewSetting(multiplier: Float): Float {
|
||||||
|
return if (abs(multiplier - 1.0f) < 0.001f) WEB_VIEW_NORMAL_LINE_HEIGHT_MULTIPLIER else multiplier
|
||||||
|
}
|
||||||
|
|
||||||
private fun createHeaderTextStyle(
|
private fun createHeaderTextStyle(
|
||||||
baseStyle: TextStyle,
|
baseStyle: TextStyle,
|
||||||
level: Int,
|
level: Int,
|
||||||
|
|
@ -473,6 +480,51 @@ private fun computeImageRenderSizeDp(
|
||||||
return with(density) { widthPx.toDp() to heightPx.toDp() }
|
return with(density) { widthPx.toDp() to heightPx.toDp() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun imageBlockContentAlignment(style: BlockStyle): Alignment {
|
||||||
|
return when {
|
||||||
|
style.float == "right" || style.horizontalAlign == "right" || style.horizontalAlign == "end" -> Alignment.CenterEnd
|
||||||
|
style.float == "left" || style.horizontalAlign == "left" || style.horizontalAlign == "start" -> Alignment.CenterStart
|
||||||
|
else -> Alignment.Center
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tableCellImageModifier(
|
||||||
|
block: ImageBlock,
|
||||||
|
density: Density,
|
||||||
|
imageSizeMultiplier: Float
|
||||||
|
): Modifier {
|
||||||
|
val baseModifier = if (block.style.width.isSpecified && block.style.width > 0.dp) {
|
||||||
|
Modifier.width(block.style.width * imageSizeMultiplier)
|
||||||
|
} else {
|
||||||
|
Modifier.fillMaxWidth(imageSizeMultiplier.coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
val intrinsicWidth = block.intrinsicWidth
|
||||||
|
val intrinsicHeight = block.intrinsicHeight
|
||||||
|
val sizedModifier = if (
|
||||||
|
intrinsicWidth != null &&
|
||||||
|
intrinsicHeight != null &&
|
||||||
|
intrinsicWidth > 0f &&
|
||||||
|
intrinsicHeight > 0f
|
||||||
|
) {
|
||||||
|
baseModifier.aspectRatio(intrinsicWidth / intrinsicHeight)
|
||||||
|
} else {
|
||||||
|
baseModifier.height(
|
||||||
|
if (block.expectedHeight > 0) {
|
||||||
|
with(density) { (block.expectedHeight * imageSizeMultiplier).toDp() }
|
||||||
|
} else {
|
||||||
|
250.dp
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (block.style.maxWidth.isSpecified && block.style.maxWidth > 0.dp) {
|
||||||
|
sizedModifier.widthIn(max = block.style.maxWidth * imageSizeMultiplier)
|
||||||
|
} else {
|
||||||
|
sizedModifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WrappingContentLayout(
|
private fun WrappingContentLayout(
|
||||||
block: WrappingContentBlock,
|
block: WrappingContentBlock,
|
||||||
|
|
@ -682,6 +734,7 @@ fun PaginatedReaderScreen(
|
||||||
paragraphGapMultiplier: Float,
|
paragraphGapMultiplier: Float,
|
||||||
imageSizeMultiplier: Float,
|
imageSizeMultiplier: Float,
|
||||||
horizontalMarginMultiplier: Float,
|
horizontalMarginMultiplier: Float,
|
||||||
|
verticalMarginMultiplier: Float,
|
||||||
fontFamily: FontFamily,
|
fontFamily: FontFamily,
|
||||||
textAlign: ReaderTextAlign,
|
textAlign: ReaderTextAlign,
|
||||||
ttsHighlightInfo: TtsHighlightInfo?,
|
ttsHighlightInfo: TtsHighlightInfo?,
|
||||||
|
|
@ -702,7 +755,8 @@ fun PaginatedReaderScreen(
|
||||||
onHighlightDeleted: (String) -> Unit,
|
onHighlightDeleted: (String) -> Unit,
|
||||||
activeHighlightPalette: List<HighlightColor>,
|
activeHighlightPalette: List<HighlightColor>,
|
||||||
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
||||||
activeTextureId: String? = null
|
activeTextureId: String? = null,
|
||||||
|
activeTextureAlpha: Float = 0.55f
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(userHighlights) {
|
LaunchedEffect(userHighlights) {
|
||||||
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
|
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
|
||||||
|
|
@ -713,11 +767,7 @@ fun PaginatedReaderScreen(
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val textureBitmap = remember(activeTextureId) {
|
val textureBitmap = remember(activeTextureId) {
|
||||||
activeTextureId?.let { id ->
|
loadReaderTextureBitmap(context, activeTextureId)
|
||||||
ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
|
|
||||||
ImageBitmap.imageResource(context.resources, resId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val textureModifier = if (textureBitmap != null) {
|
val textureModifier = if (textureBitmap != null) {
|
||||||
|
|
@ -725,13 +775,13 @@ fun PaginatedReaderScreen(
|
||||||
val brush = ShaderBrush(
|
val brush = ShaderBrush(
|
||||||
ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)
|
ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)
|
||||||
)
|
)
|
||||||
drawRect(brush = brush, blendMode = BlendMode.Multiply, alpha = 0.6f)
|
drawRect(brush = brush, blendMode = BlendMode.SrcOver, alpha = activeTextureAlpha.coerceIn(0f, 1f))
|
||||||
}
|
}
|
||||||
} else Modifier
|
} else Modifier
|
||||||
|
|
||||||
var isNavigatingByLink by remember { mutableStateOf(false) }
|
var isNavigatingByLink by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg).then(textureModifier)) {
|
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg)) {
|
||||||
val textMeasurer = rememberTextMeasurer()
|
val textMeasurer = rememberTextMeasurer()
|
||||||
val baseTextStyle = MaterialTheme.typography.bodyLarge
|
val baseTextStyle = MaterialTheme.typography.bodyLarge
|
||||||
|
|
||||||
|
|
@ -740,6 +790,7 @@ fun PaginatedReaderScreen(
|
||||||
var debouncedParagraphGapMult by remember { mutableFloatStateOf(paragraphGapMultiplier) }
|
var debouncedParagraphGapMult by remember { mutableFloatStateOf(paragraphGapMultiplier) }
|
||||||
var debouncedImageSizeMult by remember { mutableFloatStateOf(imageSizeMultiplier) }
|
var debouncedImageSizeMult by remember { mutableFloatStateOf(imageSizeMultiplier) }
|
||||||
var debouncedHorizontalMarginMult by remember { mutableFloatStateOf(horizontalMarginMultiplier) }
|
var debouncedHorizontalMarginMult by remember { mutableFloatStateOf(horizontalMarginMultiplier) }
|
||||||
|
var debouncedVerticalMarginMult by remember { mutableFloatStateOf(verticalMarginMultiplier) }
|
||||||
var debouncedFontFamily by remember { mutableStateOf(fontFamily) }
|
var debouncedFontFamily by remember { mutableStateOf(fontFamily) }
|
||||||
var debouncedTextAlign by remember { mutableStateOf(textAlign) }
|
var debouncedTextAlign by remember { mutableStateOf(textAlign) }
|
||||||
|
|
||||||
|
|
@ -781,7 +832,7 @@ fun PaginatedReaderScreen(
|
||||||
debouncedFontFamily
|
debouncedFontFamily
|
||||||
) {
|
) {
|
||||||
val adjustedFontSize = baseTextStyle.fontSize * debouncedFontSizeMult
|
val adjustedFontSize = baseTextStyle.fontSize * debouncedFontSizeMult
|
||||||
val adjustedLineHeight = adjustedFontSize * debouncedLineHeightMult
|
val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
|
||||||
|
|
||||||
baseTextStyle.copy(
|
baseTextStyle.copy(
|
||||||
color = effectiveText,
|
color = effectiveText,
|
||||||
|
|
@ -810,12 +861,13 @@ fun PaginatedReaderScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, fontFamily, textAlign) {
|
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, verticalMarginMultiplier, fontFamily, textAlign) {
|
||||||
if (fontSizeMultiplier != debouncedFontSizeMult ||
|
if (fontSizeMultiplier != debouncedFontSizeMult ||
|
||||||
lineHeightMultiplier != debouncedLineHeightMult ||
|
lineHeightMultiplier != debouncedLineHeightMult ||
|
||||||
paragraphGapMultiplier != debouncedParagraphGapMult ||
|
paragraphGapMultiplier != debouncedParagraphGapMult ||
|
||||||
imageSizeMultiplier != debouncedImageSizeMult ||
|
imageSizeMultiplier != debouncedImageSizeMult ||
|
||||||
horizontalMarginMultiplier != debouncedHorizontalMarginMult ||
|
horizontalMarginMultiplier != debouncedHorizontalMarginMult ||
|
||||||
|
verticalMarginMultiplier != debouncedVerticalMarginMult ||
|
||||||
fontFamily != debouncedFontFamily ||
|
fontFamily != debouncedFontFamily ||
|
||||||
textAlign != debouncedTextAlign
|
textAlign != debouncedTextAlign
|
||||||
) {
|
) {
|
||||||
|
|
@ -836,6 +888,7 @@ fun PaginatedReaderScreen(
|
||||||
debouncedParagraphGapMult = paragraphGapMultiplier
|
debouncedParagraphGapMult = paragraphGapMultiplier
|
||||||
debouncedImageSizeMult = imageSizeMultiplier
|
debouncedImageSizeMult = imageSizeMultiplier
|
||||||
debouncedHorizontalMarginMult = horizontalMarginMultiplier
|
debouncedHorizontalMarginMult = horizontalMarginMultiplier
|
||||||
|
debouncedVerticalMarginMult = verticalMarginMultiplier
|
||||||
debouncedFontFamily = fontFamily
|
debouncedFontFamily = fontFamily
|
||||||
debouncedTextAlign = textAlign
|
debouncedTextAlign = textAlign
|
||||||
Timber.d("Debounce complete. Applying new format settings.")
|
Timber.d("Debounce complete. Applying new format settings.")
|
||||||
|
|
@ -851,8 +904,28 @@ fun PaginatedReaderScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val horizontalPadding = 16.dp * debouncedHorizontalMarginMult
|
val requestedHorizontalPadding = 16.dp * debouncedHorizontalMarginMult
|
||||||
val verticalPadding = 16.dp
|
val requestedVerticalPadding = 16.dp * debouncedVerticalMarginMult
|
||||||
|
val effectiveReaderPadding =
|
||||||
|
remember(this.constraints, density, requestedHorizontalPadding, requestedVerticalPadding) {
|
||||||
|
val requestedHorizontalPaddingPx = with(density) { requestedHorizontalPadding.roundToPx() }
|
||||||
|
val requestedVerticalPaddingPx = with(density) { requestedVerticalPadding.roundToPx() }
|
||||||
|
val minReadableWidthPx = with(density) { 96.dp.roundToPx() }
|
||||||
|
.coerceAtMost(this.constraints.maxWidth)
|
||||||
|
val minReadableHeightPx = with(density) { 160.dp.roundToPx() }
|
||||||
|
.coerceAtMost(this.constraints.maxHeight)
|
||||||
|
val horizontalPaddingPx = requestedHorizontalPaddingPx.coerceAtMost(
|
||||||
|
((this.constraints.maxWidth - minReadableWidthPx) / 2).coerceAtLeast(0)
|
||||||
|
)
|
||||||
|
val verticalPaddingPx = requestedVerticalPaddingPx.coerceAtMost(
|
||||||
|
((this.constraints.maxHeight - minReadableHeightPx) / 2).coerceAtLeast(0)
|
||||||
|
)
|
||||||
|
with(density) {
|
||||||
|
horizontalPaddingPx.toDp() to verticalPaddingPx.toDp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val horizontalPadding = effectiveReaderPadding.first
|
||||||
|
val verticalPadding = effectiveReaderPadding.second
|
||||||
|
|
||||||
val textConstraints =
|
val textConstraints =
|
||||||
remember(this.constraints, density, horizontalPadding, verticalPadding) {
|
remember(this.constraints, density, horizontalPadding, verticalPadding) {
|
||||||
|
|
@ -860,9 +933,9 @@ fun PaginatedReaderScreen(
|
||||||
val verticalPaddingPx = with(density) { verticalPadding.roundToPx() }
|
val verticalPaddingPx = with(density) { verticalPadding.roundToPx() }
|
||||||
val finalConstraints = this.constraints.copy(
|
val finalConstraints = this.constraints.copy(
|
||||||
minWidth = 0,
|
minWidth = 0,
|
||||||
maxWidth = this.constraints.maxWidth - (2 * horizontalPaddingPx),
|
maxWidth = (this.constraints.maxWidth - (2 * horizontalPaddingPx)).coerceAtLeast(1),
|
||||||
minHeight = 0,
|
minHeight = 0,
|
||||||
maxHeight = this.constraints.maxHeight - (2 * verticalPaddingPx)
|
maxHeight = (this.constraints.maxHeight - (2 * verticalPaddingPx)).coerceAtLeast(1)
|
||||||
)
|
)
|
||||||
finalConstraints
|
finalConstraints
|
||||||
}
|
}
|
||||||
|
|
@ -950,7 +1023,8 @@ fun PaginatedReaderScreen(
|
||||||
mathMLRenderer = mathMLRenderer,
|
mathMLRenderer = mathMLRenderer,
|
||||||
userTextAlign = userTextAlign,
|
userTextAlign = userTextAlign,
|
||||||
paragraphGapMultiplier = debouncedParagraphGapMult,
|
paragraphGapMultiplier = debouncedParagraphGapMult,
|
||||||
imageSizeMultiplier = debouncedImageSizeMult
|
imageSizeMultiplier = debouncedImageSizeMult,
|
||||||
|
verticalMarginMultiplier = debouncedVerticalMarginMult
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1168,7 +1242,10 @@ fun PaginatedReaderScreen(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
activeHighlightPalette = activeHighlightPalette,
|
activeHighlightPalette = activeHighlightPalette,
|
||||||
onUpdatePalette = onUpdatePalette,
|
onUpdatePalette = onUpdatePalette,
|
||||||
effectiveText = effectiveText
|
effectiveText = effectiveText,
|
||||||
|
pageTextureModifier = if (isPageTurnAnimationEnabled) Modifier else textureModifier,
|
||||||
|
pageTextureBitmap = textureBitmap,
|
||||||
|
pageTextureAlpha = activeTextureAlpha.coerceIn(0f, 1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
|
@ -1989,7 +2066,10 @@ internal fun PaginatedReaderContent(
|
||||||
onHighlightDeleted: (String) -> Unit,
|
onHighlightDeleted: (String) -> Unit,
|
||||||
activeHighlightPalette: List<HighlightColor>,
|
activeHighlightPalette: List<HighlightColor>,
|
||||||
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
onUpdatePalette: (Int, HighlightColor) -> Unit,
|
||||||
isDarkTheme: Boolean
|
isDarkTheme: Boolean,
|
||||||
|
pageTextureModifier: Modifier = Modifier,
|
||||||
|
pageTextureBitmap: ImageBitmap? = null,
|
||||||
|
pageTextureAlpha: Float = 0f
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
@ -2130,7 +2210,9 @@ internal fun PaginatedReaderContent(
|
||||||
pageIndex,
|
pageIndex,
|
||||||
effectiveBg,
|
effectiveBg,
|
||||||
isDarkTheme,
|
isDarkTheme,
|
||||||
pageTurnTouchY
|
pageTurnTouchY,
|
||||||
|
pageTextureBitmap,
|
||||||
|
pageTextureAlpha
|
||||||
)
|
)
|
||||||
} else Modifier
|
} else Modifier
|
||||||
|
|
||||||
|
|
@ -2284,7 +2366,7 @@ internal fun PaginatedReaderContent(
|
||||||
pendingCrossPageSelection = null
|
pendingCrossPageSelection = null
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().then(pageModifier)) {
|
Box(modifier = Modifier.fillMaxSize().background(effectiveBg).then(pageTextureModifier).then(pageModifier)) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
|
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
|
|
@ -2784,12 +2866,14 @@ internal fun PaginatedReaderContent(
|
||||||
val markerAreaModifier =
|
val markerAreaModifier =
|
||||||
Modifier.width(32.dp)
|
Modifier.width(32.dp)
|
||||||
.padding(end = 8.dp)
|
.padding(end = 8.dp)
|
||||||
|
val itemMarkerImage = block.itemMarkerImage
|
||||||
|
val itemMarker = block.itemMarker
|
||||||
|
|
||||||
if (block.itemMarkerImage != null) {
|
if (itemMarkerImage != null) {
|
||||||
val imageRequest =
|
val imageRequest =
|
||||||
Builder(LocalContext.current).data(
|
Builder(LocalContext.current).data(
|
||||||
File(
|
File(
|
||||||
block.itemMarkerImage
|
itemMarkerImage
|
||||||
)
|
)
|
||||||
).crossfade(true).build()
|
).crossfade(true).build()
|
||||||
val imageSize = with(density) {
|
val imageSize = with(density) {
|
||||||
|
|
@ -2805,9 +2889,9 @@ internal fun PaginatedReaderContent(
|
||||||
alignment = Alignment.CenterEnd,
|
alignment = Alignment.CenterEnd,
|
||||||
contentScale = ContentScale.FillHeight
|
contentScale = ContentScale.FillHeight
|
||||||
)
|
)
|
||||||
} else if (block.itemMarker != null) {
|
} else if (itemMarker != null) {
|
||||||
Text(
|
Text(
|
||||||
text = block.itemMarker,
|
text = itemMarker,
|
||||||
style = textStyle.copy(
|
style = textStyle.copy(
|
||||||
textAlign = TextAlign.End
|
textAlign = TextAlign.End
|
||||||
),
|
),
|
||||||
|
|
@ -3017,10 +3101,12 @@ internal fun PaginatedReaderContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
is MathBlock -> {
|
is MathBlock -> {
|
||||||
|
val svgContent = block.svgContent?.takeIf { it.isNotBlank() }
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${!block.svgContent.isNullOrBlank()}"
|
"PaginatedReader: Rendering MathBlock. Alt: '${block.altText}', Has SVG: ${svgContent != null}"
|
||||||
)
|
)
|
||||||
if (!block.svgContent.isNullOrBlank()) {
|
if (svgContent != null) {
|
||||||
|
val nonBlankSvgContent = svgContent
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = paddingModifier
|
modifier = paddingModifier
|
||||||
) {
|
) {
|
||||||
|
|
@ -3104,7 +3190,7 @@ internal fun PaginatedReaderContent(
|
||||||
val imageRequest =
|
val imageRequest =
|
||||||
Builder(LocalContext.current).data(
|
Builder(LocalContext.current).data(
|
||||||
SvgData(
|
SvgData(
|
||||||
block.svgContent
|
nonBlankSvgContent
|
||||||
)
|
)
|
||||||
).listener(
|
).listener(
|
||||||
onError = { _, result ->
|
onError = { _, result ->
|
||||||
|
|
@ -3189,7 +3275,10 @@ internal fun PaginatedReaderContent(
|
||||||
)
|
)
|
||||||
}).crossfade(true).build()
|
}).crossfade(true).build()
|
||||||
|
|
||||||
BoxWithConstraints(modifier = paddingModifier) {
|
BoxWithConstraints(
|
||||||
|
modifier = paddingModifier,
|
||||||
|
contentAlignment = imageBlockContentAlignment(style)
|
||||||
|
) {
|
||||||
val scaledSize = computeImageRenderSizeDp(
|
val scaledSize = computeImageRenderSizeDp(
|
||||||
block = block,
|
block = block,
|
||||||
density = density,
|
density = density,
|
||||||
|
|
@ -3357,9 +3446,10 @@ internal fun PaginatedReaderContent(
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
if (blockInCell.itemMarker != null) {
|
val itemMarker = blockInCell.itemMarker
|
||||||
|
if (itemMarker != null) {
|
||||||
Text(
|
Text(
|
||||||
text = blockInCell.itemMarker,
|
text = itemMarker,
|
||||||
style = cellTextStyle,
|
style = cellTextStyle,
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(
|
||||||
end = 4.dp
|
end = 4.dp
|
||||||
|
|
@ -3390,26 +3480,6 @@ internal fun PaginatedReaderContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
is ImageBlock -> {
|
is ImageBlock -> {
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
val scaledSize = computeImageRenderSizeDp(
|
|
||||||
block = blockInCell,
|
|
||||||
density = density,
|
|
||||||
maxWidthDp = maxWidth,
|
|
||||||
imageSizeMultiplier = imageSizeMultiplier
|
|
||||||
)
|
|
||||||
val imageModifier = Modifier.then(
|
|
||||||
if (scaledSize != null) {
|
|
||||||
Modifier.width(scaledSize.first).height(scaledSize.second)
|
|
||||||
} else {
|
|
||||||
Modifier.fillMaxWidth().then(
|
|
||||||
if (blockInCell.expectedHeight > 0) {
|
|
||||||
Modifier.height(with(density) { (blockInCell.expectedHeight * imageSizeMultiplier).toDp() })
|
|
||||||
} else {
|
|
||||||
Modifier.height(250.dp)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = Builder(
|
model = Builder(
|
||||||
LocalContext.current
|
LocalContext.current
|
||||||
|
|
@ -3421,9 +3491,12 @@ internal fun PaginatedReaderContent(
|
||||||
.build(),
|
.build(),
|
||||||
contentDescription = blockInCell.altText,
|
contentDescription = blockInCell.altText,
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = imageModifier
|
modifier = tableCellImageModifier(
|
||||||
|
block = blockInCell,
|
||||||
|
density = density,
|
||||||
|
imageSizeMultiplier = imageSizeMultiplier
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is TextContentBlock -> {
|
is TextContentBlock -> {
|
||||||
|
|
@ -4101,10 +4174,12 @@ private fun RenderFlexChildBlock(
|
||||||
val markerAreaModifier = Modifier
|
val markerAreaModifier = Modifier
|
||||||
.width(32.dp)
|
.width(32.dp)
|
||||||
.padding(end = 8.dp)
|
.padding(end = 8.dp)
|
||||||
|
val itemMarkerImage = childBlock.itemMarkerImage
|
||||||
|
val itemMarker = childBlock.itemMarker
|
||||||
|
|
||||||
if (childBlock.itemMarkerImage != null) {
|
if (itemMarkerImage != null) {
|
||||||
val imageRequest =
|
val imageRequest =
|
||||||
Builder(LocalContext.current).data(File(childBlock.itemMarkerImage))
|
Builder(LocalContext.current).data(File(itemMarkerImage))
|
||||||
.crossfade(true).build()
|
.crossfade(true).build()
|
||||||
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
|
val imageSize = with(density) { (textStyle.fontSize.value * 0.8f).sp.toDp() }
|
||||||
|
|
||||||
|
|
@ -4115,9 +4190,9 @@ private fun RenderFlexChildBlock(
|
||||||
alignment = Alignment.CenterEnd,
|
alignment = Alignment.CenterEnd,
|
||||||
contentScale = ContentScale.FillHeight
|
contentScale = ContentScale.FillHeight
|
||||||
)
|
)
|
||||||
} else if (childBlock.itemMarker != null) {
|
} else if (itemMarker != null) {
|
||||||
Text(
|
Text(
|
||||||
text = childBlock.itemMarker,
|
text = itemMarker,
|
||||||
style = textStyle.copy(textAlign = TextAlign.End),
|
style = textStyle.copy(textAlign = TextAlign.End),
|
||||||
modifier = markerAreaModifier
|
modifier = markerAreaModifier
|
||||||
)
|
)
|
||||||
|
|
@ -4160,7 +4235,7 @@ private fun RenderFlexChildBlock(
|
||||||
ColorFilter.colorMatrix(ColorMatrix(matrix))
|
ColorFilter.colorMatrix(ColorMatrix(matrix))
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
BoxWithConstraints {
|
BoxWithConstraints(contentAlignment = imageBlockContentAlignment(style)) {
|
||||||
val scaledSize = computeImageRenderSizeDp(
|
val scaledSize = computeImageRenderSizeDp(
|
||||||
block = childBlock,
|
block = childBlock,
|
||||||
density = density,
|
density = density,
|
||||||
|
|
@ -4278,26 +4353,6 @@ private fun RenderFlexChildBlock(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
} else if (blockInCell is ImageBlock) {
|
} else if (blockInCell is ImageBlock) {
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
val scaledSize = computeImageRenderSizeDp(
|
|
||||||
block = blockInCell,
|
|
||||||
density = density,
|
|
||||||
maxWidthDp = maxWidth,
|
|
||||||
imageSizeMultiplier = imageSizeMultiplier
|
|
||||||
)
|
|
||||||
val imageModifier = Modifier.then(
|
|
||||||
if (scaledSize != null) {
|
|
||||||
Modifier.width(scaledSize.first).height(scaledSize.second)
|
|
||||||
} else {
|
|
||||||
Modifier.fillMaxWidth().then(
|
|
||||||
if (blockInCell.expectedHeight > 0) {
|
|
||||||
Modifier.height(with(density) { (blockInCell.expectedHeight * imageSizeMultiplier).toDp() })
|
|
||||||
} else {
|
|
||||||
Modifier.height(250.dp)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = Builder(LocalContext.current).data(
|
model = Builder(LocalContext.current).data(
|
||||||
File(
|
File(
|
||||||
|
|
@ -4306,9 +4361,12 @@ private fun RenderFlexChildBlock(
|
||||||
).build(),
|
).build(),
|
||||||
contentDescription = blockInCell.altText,
|
contentDescription = blockInCell.altText,
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = imageModifier
|
modifier = tableCellImageModifier(
|
||||||
|
block = blockInCell,
|
||||||
|
density = density,
|
||||||
|
imageSizeMultiplier = imageSizeMultiplier
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4332,7 +4390,9 @@ private fun Modifier.realisticBookPage(
|
||||||
pageIndex: Int,
|
pageIndex: Int,
|
||||||
paperColor: Color,
|
paperColor: Color,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
touchY: Float?
|
touchY: Float?,
|
||||||
|
textureBitmap: ImageBitmap? = null,
|
||||||
|
textureAlpha: Float = 0f
|
||||||
): Modifier = composed {
|
): Modifier = composed {
|
||||||
|
|
||||||
val frontPath = remember { Path() }
|
val frontPath = remember { Path() }
|
||||||
|
|
@ -4360,9 +4420,19 @@ private fun Modifier.realisticBookPage(
|
||||||
.drawWithContent {
|
.drawWithContent {
|
||||||
val drawStart = System.nanoTime()
|
val drawStart = System.nanoTime()
|
||||||
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
|
||||||
|
fun drawPaperBackground() {
|
||||||
|
drawRect(color = paperColor)
|
||||||
|
if (textureBitmap != null && textureAlpha > 0f) {
|
||||||
|
drawRect(
|
||||||
|
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
|
||||||
|
blendMode = BlendMode.SrcOver,
|
||||||
|
alpha = textureAlpha
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (abs(pageOffset) < 0.001f) {
|
if (abs(pageOffset) < 0.001f) {
|
||||||
drawRect(color = paperColor)
|
drawPaperBackground()
|
||||||
drawContent()
|
drawContent()
|
||||||
}
|
}
|
||||||
else if (pageOffset < 0f && pageOffset > -1f) {
|
else if (pageOffset < 0f && pageOffset > -1f) {
|
||||||
|
|
@ -4423,7 +4493,7 @@ private fun Modifier.realisticBookPage(
|
||||||
frontPath.close()
|
frontPath.close()
|
||||||
|
|
||||||
clipPath(frontPath) {
|
clipPath(frontPath) {
|
||||||
drawRect(color = paperColor)
|
drawPaperBackground()
|
||||||
this@drawWithContent.drawContent()
|
this@drawWithContent.drawContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4466,6 +4536,15 @@ private fun Modifier.realisticBookPage(
|
||||||
clipRect(0f, 0f, w, h) {
|
clipRect(0f, 0f, w, h) {
|
||||||
clipPath(frontPath) {
|
clipPath(frontPath) {
|
||||||
drawPath(reflectedScreenPath, color = paperColor)
|
drawPath(reflectedScreenPath, color = paperColor)
|
||||||
|
if (textureBitmap != null && textureAlpha > 0f) {
|
||||||
|
clipPath(reflectedScreenPath) {
|
||||||
|
drawRect(
|
||||||
|
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
|
||||||
|
blendMode = BlendMode.SrcOver,
|
||||||
|
alpha = textureAlpha
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
|
val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
|
||||||
drawPath(reflectedScreenPath, color = flapTint)
|
drawPath(reflectedScreenPath, color = flapTint)
|
||||||
|
|
||||||
|
|
@ -4493,12 +4572,12 @@ private fun Modifier.realisticBookPage(
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
drawRect(color = paperColor)
|
drawPaperBackground()
|
||||||
drawContent()
|
drawContent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
drawRect(color = paperColor)
|
drawPaperBackground()
|
||||||
drawContent()
|
drawContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4515,10 +4594,14 @@ fun Modifier.drawCssBorders(
|
||||||
blockStyle: BlockStyle,
|
blockStyle: BlockStyle,
|
||||||
@Suppress("unused") density: Density
|
@Suppress("unused") density: Density
|
||||||
): Modifier = this.drawBehind {
|
): Modifier = this.drawBehind {
|
||||||
val topWidth = blockStyle.borderTop?.width?.toPx() ?: 0f
|
val borderTop = blockStyle.borderTop
|
||||||
val rightWidth = blockStyle.borderRight?.width?.toPx() ?: 0f
|
val borderRight = blockStyle.borderRight
|
||||||
val bottomWidth = blockStyle.borderBottom?.width?.toPx() ?: 0f
|
val borderBottom = blockStyle.borderBottom
|
||||||
val leftWidth = blockStyle.borderLeft?.width?.toPx() ?: 0f
|
val borderLeft = blockStyle.borderLeft
|
||||||
|
val topWidth = borderTop?.width?.toPx() ?: 0f
|
||||||
|
val rightWidth = borderRight?.width?.toPx() ?: 0f
|
||||||
|
val bottomWidth = borderBottom?.width?.toPx() ?: 0f
|
||||||
|
val leftWidth = borderLeft?.width?.toPx() ?: 0f
|
||||||
|
|
||||||
val tlRadius = blockStyle.borderTopLeftRadius.toPx()
|
val tlRadius = blockStyle.borderTopLeftRadius.toPx()
|
||||||
val trRadius = blockStyle.borderTopRightRadius.toPx()
|
val trRadius = blockStyle.borderTopRightRadius.toPx()
|
||||||
|
|
@ -4550,9 +4633,9 @@ fun Modifier.drawCssBorders(
|
||||||
}
|
}
|
||||||
|
|
||||||
// TOP
|
// TOP
|
||||||
if (topWidth > 0f && blockStyle.borderTop != null) {
|
if (topWidth > 0f && borderTop != null) {
|
||||||
val color = blockStyle.borderTop.color
|
val color = borderTop.color
|
||||||
val effect = getPathEffect(blockStyle.borderTop.style, topWidth)
|
val effect = getPathEffect(borderTop.style, topWidth)
|
||||||
val offset = topWidth / 2f
|
val offset = topWidth / 2f
|
||||||
|
|
||||||
val startX = if (tlRadius > 0) tlRadius else 0f
|
val startX = if (tlRadius > 0) tlRadius else 0f
|
||||||
|
|
@ -4568,9 +4651,9 @@ fun Modifier.drawCssBorders(
|
||||||
}
|
}
|
||||||
|
|
||||||
// BOTTOM
|
// BOTTOM
|
||||||
if (bottomWidth > 0f && blockStyle.borderBottom != null) {
|
if (bottomWidth > 0f && borderBottom != null) {
|
||||||
val color = blockStyle.borderBottom.color
|
val color = borderBottom.color
|
||||||
val effect = getPathEffect(blockStyle.borderBottom.style, bottomWidth)
|
val effect = getPathEffect(borderBottom.style, bottomWidth)
|
||||||
val offset = size.height - (bottomWidth / 2f)
|
val offset = size.height - (bottomWidth / 2f)
|
||||||
|
|
||||||
val startX = if (blRadius > 0) blRadius else 0f
|
val startX = if (blRadius > 0) blRadius else 0f
|
||||||
|
|
@ -4586,9 +4669,9 @@ fun Modifier.drawCssBorders(
|
||||||
}
|
}
|
||||||
|
|
||||||
// LEFT
|
// LEFT
|
||||||
if (leftWidth > 0f && blockStyle.borderLeft != null) {
|
if (leftWidth > 0f && borderLeft != null) {
|
||||||
val color = blockStyle.borderLeft.color
|
val color = borderLeft.color
|
||||||
val effect = getPathEffect(blockStyle.borderLeft.style, leftWidth)
|
val effect = getPathEffect(borderLeft.style, leftWidth)
|
||||||
val offset = leftWidth / 2f
|
val offset = leftWidth / 2f
|
||||||
|
|
||||||
val startY = if (tlRadius > 0) tlRadius else 0f
|
val startY = if (tlRadius > 0) tlRadius else 0f
|
||||||
|
|
@ -4604,9 +4687,9 @@ fun Modifier.drawCssBorders(
|
||||||
}
|
}
|
||||||
|
|
||||||
// RIGHT
|
// RIGHT
|
||||||
if (rightWidth > 0f && blockStyle.borderRight != null) {
|
if (rightWidth > 0f && borderRight != null) {
|
||||||
val color = blockStyle.borderRight.color
|
val color = borderRight.color
|
||||||
val effect = getPathEffect(blockStyle.borderRight.style, rightWidth)
|
val effect = getPathEffect(borderRight.style, rightWidth)
|
||||||
val offset = size.width - (rightWidth / 2f)
|
val offset = size.width - (rightWidth / 2f)
|
||||||
|
|
||||||
val startY = if (trRadius > 0) trRadius else 0f
|
val startY = if (trRadius > 0) trRadius else 0f
|
||||||
|
|
@ -4621,9 +4704,9 @@ fun Modifier.drawCssBorders(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tlRadius > 0f && topWidth > 0f && leftWidth > 0f && blockStyle.borderTop != null) {
|
if (tlRadius > 0f && topWidth > 0f && leftWidth > 0f && borderTop != null) {
|
||||||
drawArc(
|
drawArc(
|
||||||
color = blockStyle.borderTop.color,
|
color = borderTop.color,
|
||||||
startAngle = 180f, sweepAngle = 90f,
|
startAngle = 180f, sweepAngle = 90f,
|
||||||
useCenter = false,
|
useCenter = false,
|
||||||
topLeft = Offset(leftWidth/2f, topWidth/2f),
|
topLeft = Offset(leftWidth/2f, topWidth/2f),
|
||||||
|
|
@ -4632,9 +4715,9 @@ fun Modifier.drawCssBorders(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && blockStyle.borderTop != null) {
|
if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && borderTop != null) {
|
||||||
drawArc(
|
drawArc(
|
||||||
color = blockStyle.borderTop.color,
|
color = borderTop.color,
|
||||||
startAngle = 270f, sweepAngle = 90f,
|
startAngle = 270f, sweepAngle = 90f,
|
||||||
useCenter = false,
|
useCenter = false,
|
||||||
topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f),
|
topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f),
|
||||||
|
|
@ -4643,9 +4726,9 @@ fun Modifier.drawCssBorders(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && blockStyle.borderBottom != null) {
|
if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && borderBottom != null) {
|
||||||
drawArc(
|
drawArc(
|
||||||
color = blockStyle.borderBottom.color,
|
color = borderBottom.color,
|
||||||
startAngle = 0f, sweepAngle = 90f,
|
startAngle = 0f, sweepAngle = 90f,
|
||||||
useCenter = false,
|
useCenter = false,
|
||||||
topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)),
|
topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)),
|
||||||
|
|
@ -4654,9 +4737,9 @@ fun Modifier.drawCssBorders(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && blockStyle.borderBottom != null) {
|
if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && borderBottom != null) {
|
||||||
drawArc(
|
drawArc(
|
||||||
color = blockStyle.borderBottom.color,
|
color = borderBottom.color,
|
||||||
startAngle = 90f, sweepAngle = 90f,
|
startAngle = 90f, sweepAngle = 90f,
|
||||||
useCenter = false,
|
useCenter = false,
|
||||||
topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)),
|
topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)),
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,8 @@ class PaginatedReaderViewModel : ViewModel() {
|
||||||
mathMLRenderer = mathMLRenderer,
|
mathMLRenderer = mathMLRenderer,
|
||||||
userTextAlign = null,
|
userTextAlign = null,
|
||||||
paragraphGapMultiplier = paragraphGapMultiplier,
|
paragraphGapMultiplier = paragraphGapMultiplier,
|
||||||
imageSizeMultiplier = 1.0f
|
imageSizeMultiplier = 1.0f,
|
||||||
|
verticalMarginMultiplier = 1.0f
|
||||||
)
|
)
|
||||||
paginator = newPaginator
|
paginator = newPaginator
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ abstract class BookCacheDao {
|
||||||
ConfigurationCache::class,
|
ConfigurationCache::class,
|
||||||
AnchorIndexEntry::class
|
AnchorIndexEntry::class
|
||||||
],
|
],
|
||||||
version = 8,
|
version = 10,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class BookCacheDatabase : RoomDatabase() {
|
abstract class BookCacheDatabase : RoomDatabase() {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import androidx.room.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
const val LATEST_PROCESSING_VERSION = 8
|
const val LATEST_PROCESSING_VERSION = 10
|
||||||
|
|
||||||
@Entity(tableName = "processed_books")
|
@Entity(tableName = "processed_books")
|
||||||
data class ProcessedBook(
|
data class ProcessedBook(
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ import com.aryan.reader.paginatedreader.FontFaceInfo
|
||||||
import com.aryan.reader.paginatedreader.MathMLRenderer
|
import com.aryan.reader.paginatedreader.MathMLRenderer
|
||||||
import com.aryan.reader.paginatedreader.OptimizedCssRules
|
import com.aryan.reader.paginatedreader.OptimizedCssRules
|
||||||
import com.aryan.reader.paginatedreader.RenderResult
|
import com.aryan.reader.paginatedreader.RenderResult
|
||||||
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
|
import com.aryan.reader.paginatedreader.androidHtmlToSemanticBlocks
|
||||||
import com.aryan.reader.paginatedreader.loadFontFamilies
|
import com.aryan.reader.paginatedreader.loadFontFamilies
|
||||||
import com.aryan.reader.paginatedreader.semanticBlockModule
|
import com.aryan.reader.paginatedreader.semanticBlockModule
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -277,7 +277,7 @@ class BookProcessingWorker(
|
||||||
Timber.d("Chapter $index (Background Worker): Processed HTML contains <math-placeholder>: ${processedHtml.contains("math-placeholder")}")
|
Timber.d("Chapter $index (Background Worker): Processed HTML contains <math-placeholder>: ${processedHtml.contains("math-placeholder")}")
|
||||||
|
|
||||||
|
|
||||||
val semanticBlocks = htmlToSemanticBlocks(
|
val semanticBlocks = androidHtmlToSemanticBlocks(
|
||||||
html = processedHtml,
|
html = processedHtml,
|
||||||
cssRules = lightThemeCssRules,
|
cssRules = lightThemeCssRules,
|
||||||
textStyle = textStyle,
|
textStyle = textStyle,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.max
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -68,6 +69,9 @@ fun MagnifierComposable(
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
val magnifierWidthPx = size.width
|
val magnifierWidthPx = size.width
|
||||||
val magnifierHeightPx = size.height
|
val magnifierHeightPx = size.height
|
||||||
|
if (magnifierWidthPx <= 0f || magnifierHeightPx <= 0f || zoomFactor <= 0f) {
|
||||||
|
return@Canvas
|
||||||
|
}
|
||||||
|
|
||||||
Timber.d("Magnifier: START. scale=$currentScale, centerOnBitmap=$magnifierCenterOnBitmap")
|
Timber.d("Magnifier: START. scale=$currentScale, centerOnBitmap=$magnifierCenterOnBitmap")
|
||||||
|
|
||||||
|
|
@ -109,8 +113,10 @@ fun MagnifierComposable(
|
||||||
val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f)
|
val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f)
|
||||||
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
|
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
|
||||||
|
|
||||||
val clampedSrcLeft = srcLeft.coerceIn(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
val maxSrcLeft = max(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
||||||
val clampedSrcTop = srcTop.coerceIn(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
val maxSrcTop = max(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
||||||
|
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
|
||||||
|
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
|
||||||
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
|
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
|
||||||
|
|
||||||
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
|
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
|
||||||
|
|
@ -174,8 +180,10 @@ fun MagnifierComposable(
|
||||||
val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f)
|
val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f)
|
||||||
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
|
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
|
||||||
|
|
||||||
val clampedSrcLeft = srcLeft.coerceIn(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
val maxSrcLeft = max(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
|
||||||
val clampedSrcTop = srcTop.coerceIn(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
val maxSrcTop = max(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
|
||||||
|
val clampedSrcLeft = srcLeft.coerceIn(0f, maxSrcLeft)
|
||||||
|
val clampedSrcTop = srcTop.coerceIn(0f, maxSrcTop)
|
||||||
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
|
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
|
||||||
|
|
||||||
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
|
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package com.aryan.reader.pdf
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.pdf.PdfiumAnnotationSubtype
|
||||||
|
|
||||||
object NativePdfiumBridge {
|
object NativePdfiumBridge {
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("native-lib")
|
System.loadLibrary("native-lib")
|
||||||
|
|
@ -31,9 +33,9 @@ object NativePdfiumBridge {
|
||||||
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
|
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
|
||||||
@JvmStatic external fun checkActionSupport(): Boolean
|
@JvmStatic external fun checkActionSupport(): Boolean
|
||||||
|
|
||||||
const val ANNOT_TEXT = 1 // Sticky Note
|
const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT
|
||||||
const val ANNOT_LINK = 2 // Link
|
const val ANNOT_LINK = PdfiumAnnotationSubtype.LINK
|
||||||
const val ANNOT_HIGHLIGHT = 8 // Highlight
|
const val ANNOT_HIGHLIGHT = PdfiumAnnotationSubtype.HIGHLIGHT
|
||||||
const val ANNOT_INK = 12 // Freehand drawing
|
const val ANNOT_INK = PdfiumAnnotationSubtype.INK
|
||||||
const val ANNOT_WIDGET = 19
|
const val ANNOT_WIDGET = PdfiumAnnotationSubtype.WIDGET
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +84,9 @@ fun VerticalScrollbar(
|
||||||
|
|
||||||
if (viewportRatio >= 1f) return@derivedStateOf null
|
if (viewportRatio >= 1f) return@derivedStateOf null
|
||||||
|
|
||||||
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
|
val maxThumbHeight = viewportHeight / 2f
|
||||||
|
val minThumbHeight = minOf(80f, maxThumbHeight)
|
||||||
|
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight)
|
||||||
|
|
||||||
val firstItemIndex = listState.firstVisibleItemIndex
|
val firstItemIndex = listState.firstVisibleItemIndex
|
||||||
val firstItemOffset = listState.firstVisibleItemScrollOffset
|
val firstItemOffset = listState.firstVisibleItemScrollOffset
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,13 @@ import androidx.compose.ui.graphics.BlendMode
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.graphics.ColorMatrix
|
import androidx.compose.ui.graphics.ColorMatrix
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.ImageShader
|
||||||
import androidx.compose.ui.graphics.Path
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.ShaderBrush
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.graphics.StrokeJoin
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.TileMode
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||||
|
|
@ -119,6 +123,7 @@ import androidx.core.graphics.scale
|
||||||
import androidx.core.graphics.set
|
import androidx.core.graphics.set
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.R
|
||||||
import com.aryan.reader.SearchResult
|
import com.aryan.reader.SearchResult
|
||||||
|
import com.aryan.reader.loadReaderTextureBitmap
|
||||||
import com.aryan.reader.ml.SpeechBubble
|
import com.aryan.reader.ml.SpeechBubble
|
||||||
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
|
||||||
|
|
@ -127,6 +132,7 @@ import com.aryan.reader.pdf.ocr.OcrElement
|
||||||
import com.aryan.reader.pdf.ocr.OcrResult
|
import com.aryan.reader.pdf.ocr.OcrResult
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
|
@ -180,6 +186,13 @@ data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
|
||||||
|
|
||||||
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
|
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
|
||||||
|
|
||||||
|
private const val PDF_TILE_SIZE_DP = 256
|
||||||
|
private const val PDF_MAX_TILE_BITMAP_SIZE_PX = 3072
|
||||||
|
private const val PDF_TILE_SCALE_TOLERANCE = 0.06f
|
||||||
|
private const val PDF_TILE_IDLE_RENDER_DELAY_MS = 90L
|
||||||
|
private const val PDF_PAGINATION_PAN_FLING_MIN_VELOCITY = 600f
|
||||||
|
private const val PDF_PAGINATION_PAN_FLING_MULTIPLIER = 0.72f
|
||||||
|
|
||||||
enum class LinkSource {
|
enum class LinkSource {
|
||||||
ANNOTATION, TEXT_CONTENT
|
ANNOTATION, TEXT_CONTENT
|
||||||
}
|
}
|
||||||
|
|
@ -426,7 +439,10 @@ data class PageStaticData(
|
||||||
val colorFilter: StableHolder<ColorFilter?>,
|
val colorFilter: StableHolder<ColorFilter?>,
|
||||||
val isDarkMode: Boolean,
|
val isDarkMode: Boolean,
|
||||||
val excludeImages: Boolean,
|
val excludeImages: Boolean,
|
||||||
val imageRects: StableHolder<List<android.graphics.Rect>>
|
val imageRects: StableHolder<List<android.graphics.Rect>>,
|
||||||
|
val textureBitmap: StableHolder<ImageBitmap?>,
|
||||||
|
val textureAlpha: Float,
|
||||||
|
val textureBlendMode: BlendMode
|
||||||
)
|
)
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
|
|
@ -497,6 +513,7 @@ internal fun PdfPageComposable(
|
||||||
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
||||||
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
|
||||||
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
||||||
|
activeTextureAlpha: Float = 0.55f,
|
||||||
excludeImages: Boolean = false,
|
excludeImages: Boolean = false,
|
||||||
onDoubleTap: ((Offset) -> Unit)? = null,
|
onDoubleTap: ((Offset) -> Unit)? = null,
|
||||||
isEditMode: Boolean = false,
|
isEditMode: Boolean = false,
|
||||||
|
|
@ -552,7 +569,7 @@ internal fun PdfPageComposable(
|
||||||
var isLoadingPage by remember { mutableStateOf(true) }
|
var isLoadingPage by remember { mutableStateOf(true) }
|
||||||
var pageErrorMessage by remember { mutableStateOf<String?>(null) }
|
var pageErrorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
LocalContext.current
|
val context = LocalContext.current
|
||||||
val viewConfiguration = LocalViewConfiguration.current
|
val viewConfiguration = LocalViewConfiguration.current
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var isStylusEraserOverride by remember { mutableStateOf(false) }
|
var isStylusEraserOverride by remember { mutableStateOf(false) }
|
||||||
|
|
@ -564,6 +581,7 @@ internal fun PdfPageComposable(
|
||||||
var isTransforming by remember { mutableStateOf(false) }
|
var isTransforming by remember { mutableStateOf(false) }
|
||||||
var scale by remember { mutableFloatStateOf(1f) }
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
var paginationPanFlingJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(scale, offset) {
|
LaunchedEffect(scale, offset) {
|
||||||
onZoomAndPanChanged?.invoke(scale, offset)
|
onZoomAndPanChanged?.invoke(scale, offset)
|
||||||
|
|
@ -590,8 +608,10 @@ internal fun PdfPageComposable(
|
||||||
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
|
val pdfPageIndex = (virtualPage as? VirtualPage.PdfPage)?.pdfIndex ?: pageIndex
|
||||||
|
|
||||||
var tiles by remember { mutableStateOf<List<PdfTile>>(emptyList()) }
|
var tiles by remember { mutableStateOf<List<PdfTile>>(emptyList()) }
|
||||||
val tileSizeDp = 256.dp
|
val tileSizeDp = PDF_TILE_SIZE_DP.dp
|
||||||
val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
|
val tileSizePx = with(LocalDensity.current) { tileSizeDp.toPx().toInt() }
|
||||||
|
val latestEffectiveScale by rememberUpdatedState(effectiveScale)
|
||||||
|
val latestEffectiveOffset by rememberUpdatedState(effectiveOffset)
|
||||||
|
|
||||||
SideEffect {
|
SideEffect {
|
||||||
Timber.tag("PdfDrawPerf")
|
Timber.tag("PdfDrawPerf")
|
||||||
|
|
@ -633,6 +653,8 @@ internal fun PdfPageComposable(
|
||||||
var actualBitmapHeightPx by remember { mutableIntStateOf(0) }
|
var actualBitmapHeightPx by remember { mutableIntStateOf(0) }
|
||||||
var currentPageRotation by remember { mutableIntStateOf(0) }
|
var currentPageRotation by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
val needsTilingNow = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
|
||||||
|
|
||||||
val canvasWidthPx = remember { mutableFloatStateOf(0f) }
|
val canvasWidthPx = remember { mutableFloatStateOf(0f) }
|
||||||
val canvasHeightPx = remember { mutableFloatStateOf(0f) }
|
val canvasHeightPx = remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
|
@ -687,6 +709,15 @@ internal fun PdfPageComposable(
|
||||||
activeTheme.backgroundColor
|
activeTheme.backgroundColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val textureBitmap = remember(activeTheme.textureId) {
|
||||||
|
loadReaderTextureBitmap(context, activeTheme.textureId)
|
||||||
|
}
|
||||||
|
val effectiveTextureAlpha = remember(activeTheme.textureId, activeTextureAlpha) {
|
||||||
|
if (activeTheme.textureId == null) 0f else activeTextureAlpha.coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
val textureBlendMode = remember(activeTheme.textureId, activeTheme.isDark, activeTheme.id) {
|
||||||
|
if (activeTheme.isDark || activeTheme.id == "reverse") BlendMode.Screen else BlendMode.Multiply
|
||||||
|
}
|
||||||
|
|
||||||
val centeringOffsetX by remember(canvasWidthPx.floatValue, actualBitmapWidthPx) {
|
val centeringOffsetX by remember(canvasWidthPx.floatValue, actualBitmapWidthPx) {
|
||||||
derivedStateOf { (canvasWidthPx.floatValue - actualBitmapWidthPx) / 2f }
|
derivedStateOf { (canvasWidthPx.floatValue - actualBitmapWidthPx) / 2f }
|
||||||
|
|
@ -1146,13 +1177,13 @@ internal fun PdfPageComposable(
|
||||||
try {
|
try {
|
||||||
val pagePtr = pageWrapper.getNativePointer()
|
val pagePtr = pageWrapper.getNativePointer()
|
||||||
if (pagePtr != 0L) {
|
if (pagePtr != 0L) {
|
||||||
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
|
val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr)
|
||||||
val imgRects = mutableListOf<android.graphics.Rect>()
|
val imgRects = mutableListOf<android.graphics.Rect>()
|
||||||
val outRect = FloatArray(4)
|
val outRect = FloatArray(4)
|
||||||
|
|
||||||
for (i in 0 until objCount) {
|
for (i in 0 until objCount) {
|
||||||
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
|
if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) { // 3 = FPDF_PAGEOBJ_IMAGE
|
||||||
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, outRect)) {
|
if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, outRect)) {
|
||||||
val pdfRectF = android.graphics.RectF(
|
val pdfRectF = android.graphics.RectF(
|
||||||
min(outRect[0], outRect[2]),
|
min(outRect[0], outRect[2]),
|
||||||
max(outRect[1], outRect[3]),
|
max(outRect[1], outRect[3]),
|
||||||
|
|
@ -1180,26 +1211,26 @@ internal fun PdfPageComposable(
|
||||||
val pagePtr = pageWrapper.getNativePointer()
|
val pagePtr = pageWrapper.getNativePointer()
|
||||||
|
|
||||||
if (pagePtr != 0L) {
|
if (pagePtr != 0L) {
|
||||||
val count = NativePdfiumBridge.getAnnotCount(pagePtr)
|
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
|
||||||
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
val count = NativePdfiumBridge.getAnnotCount(pagePtr)
|
val count = PdfiumEngineProvider.bridge.getAnnotCount(pagePtr)
|
||||||
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
Timber.tag("PdfCommentDebug").d("Page $pageIndex: Total Annotations found = $count")
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
val allAnnots = (0 until count).mapNotNull { i ->
|
val allAnnots = (0 until count).mapNotNull { i ->
|
||||||
val subtype = NativePdfiumBridge.getAnnotSubtype(pagePtr, i)
|
val subtype = PdfiumEngineProvider.bridge.getAnnotSubtype(pagePtr, i)
|
||||||
if (subtype == annotLink) return@mapNotNull null
|
if (subtype == annotLink) return@mapNotNull null
|
||||||
|
|
||||||
var contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "Contents")
|
var contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "Contents")
|
||||||
if (contents.isNullOrBlank()) {
|
if (contents.isNullOrBlank()) {
|
||||||
contents = NativePdfiumBridge.getAnnotString(pagePtr, i, "RC")
|
contents = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "RC")
|
||||||
}
|
}
|
||||||
|
|
||||||
val name = NativePdfiumBridge.getAnnotString(pagePtr, i, "NM")
|
val name = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "NM")
|
||||||
val irt = NativePdfiumBridge.getAnnotString(pagePtr, i, "IRT")
|
val irt = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "IRT")
|
||||||
val author = NativePdfiumBridge.getAnnotString(pagePtr, i, "T")
|
val author = PdfiumEngineProvider.bridge.getAnnotString(pagePtr, i, "T")
|
||||||
|
|
||||||
val pdfRectArray = NativePdfiumBridge.getAnnotRect(pagePtr, i)
|
val pdfRectArray = PdfiumEngineProvider.bridge.getAnnotRect(pagePtr, i)
|
||||||
val pdfRectF = if (pdfRectArray != null) {
|
val pdfRectF = if (pdfRectArray != null) {
|
||||||
android.graphics.RectF(
|
android.graphics.RectF(
|
||||||
min(pdfRectArray[0], pdfRectArray[2]),
|
min(pdfRectArray[0], pdfRectArray[2]),
|
||||||
|
|
@ -1311,8 +1342,7 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
effectiveScale,
|
needsTilingNow,
|
||||||
effectiveOffset,
|
|
||||||
actualBitmapWidthPx,
|
actualBitmapWidthPx,
|
||||||
actualBitmapHeightPx,
|
actualBitmapHeightPx,
|
||||||
canvasWidthPx.floatValue,
|
canvasWidthPx.floatValue,
|
||||||
|
|
@ -1323,8 +1353,7 @@ internal fun PdfPageComposable(
|
||||||
virtualPage,
|
virtualPage,
|
||||||
isActivePage
|
isActivePage
|
||||||
) {
|
) {
|
||||||
val needsTiling = (effectiveScale > 1f || actualBitmapWidthPx > 3000 || actualBitmapHeightPx > 3000) && (isVerticalScroll || isActivePage)
|
if (!needsTilingNow) {
|
||||||
if (!needsTiling) {
|
|
||||||
if (tiles.isNotEmpty()) {
|
if (tiles.isNotEmpty()) {
|
||||||
val oldTiles = tiles
|
val oldTiles = tiles
|
||||||
tiles = emptyList()
|
tiles = emptyList()
|
||||||
|
|
@ -1358,24 +1387,37 @@ internal fun PdfPageComposable(
|
||||||
|
|
||||||
snapshotFlow {
|
snapshotFlow {
|
||||||
val rect = visibleScreenRect()
|
val rect = visibleScreenRect()
|
||||||
if (rect == null) null
|
val observedScale = latestEffectiveScale
|
||||||
else {
|
if (isVerticalScroll && rect != null) {
|
||||||
val qTop = rect.top / (tileSizePx / 2)
|
val qTop = rect.top / (tileSizePx / 2)
|
||||||
val qLeft = rect.left / (tileSizePx / 2)
|
val qLeft = rect.left / (tileSizePx / 2)
|
||||||
val qBottom = rect.bottom / (tileSizePx / 2)
|
val qBottom = rect.bottom / (tileSizePx / 2)
|
||||||
val qRight = rect.right / (tileSizePx / 2)
|
val qRight = rect.right / (tileSizePx / 2)
|
||||||
listOf(qTop, qLeft, qBottom, qRight)
|
listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt())
|
||||||
|
} else if (!isVerticalScroll) {
|
||||||
|
val observedOffset = latestEffectiveOffset
|
||||||
|
val pivotX = screenWidth / 2f
|
||||||
|
val pivotY = screenHeight / 2f
|
||||||
|
val pxTl = (((0 - observedOffset.x) - pivotX) / observedScale + pivotX) - centeringOffsetX
|
||||||
|
val pyTl = (((0 - observedOffset.y) - pivotY) / observedScale + pivotY) - centeringOffsetY
|
||||||
|
val pxBr = (((screenWidth - observedOffset.x) - pivotX) / observedScale + pivotX) - centeringOffsetX
|
||||||
|
val pyBr = (((screenHeight - observedOffset.y) - pivotY) / observedScale + pivotY) - centeringOffsetY
|
||||||
|
|
||||||
|
val qTop = pyTl.toInt() / (tileSizePx / 2)
|
||||||
|
val qLeft = pxTl.toInt() / (tileSizePx / 2)
|
||||||
|
val qBottom = pyBr.toInt() / (tileSizePx / 2)
|
||||||
|
val qRight = pxBr.toInt() / (tileSizePx / 2)
|
||||||
|
listOf(qTop, qLeft, qBottom, qRight, (observedScale * 10f).roundToInt())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}.conflate().collectLatest { _ ->
|
}.conflate().collectLatest { _ ->
|
||||||
|
|
||||||
delay(150)
|
|
||||||
|
|
||||||
val tileCalcStart = System.nanoTime()
|
val tileCalcStart = System.nanoTime()
|
||||||
if (!isActive) return@collectLatest
|
if (!isActive) return@collectLatest
|
||||||
|
|
||||||
if (isScrolling && effectiveScale > 1f) {
|
val renderScale = latestEffectiveScale
|
||||||
return@collectLatest
|
val renderOffset = latestEffectiveOffset
|
||||||
}
|
|
||||||
|
|
||||||
val currentVisibleRect = visibleScreenRect()
|
val currentVisibleRect = visibleScreenRect()
|
||||||
|
|
||||||
|
|
@ -1404,14 +1446,14 @@ internal fun PdfPageComposable(
|
||||||
val pivotX = screenWidth / 2f
|
val pivotX = screenWidth / 2f
|
||||||
val pivotY = screenHeight / 2f
|
val pivotY = screenHeight / 2f
|
||||||
|
|
||||||
pxTl = (((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
|
pxTl = (((0 - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX
|
||||||
pyTl = (((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
|
pyTl = (((0 - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY
|
||||||
pxBr = (((screenWidth - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
|
pxBr = (((screenWidth - renderOffset.x) - pivotX) / renderScale + pivotX) - centeringOffsetX
|
||||||
pyBr = (((screenHeight - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
|
pyBr = (((screenHeight - renderOffset.y) - pivotY) / renderScale + pivotY) - centeringOffsetY
|
||||||
}
|
}
|
||||||
|
|
||||||
val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt())
|
val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt())
|
||||||
val inset = if (effectiveScale > 2f) 0 else -tileSizePx
|
val inset = if (renderScale > 2f) 0 else -tileSizePx
|
||||||
visibleBitmapRect.inset(inset, inset)
|
visibleBitmapRect.inset(inset, inset)
|
||||||
|
|
||||||
val requiredTileIds = mutableSetOf<Int>()
|
val requiredTileIds = mutableSetOf<Int>()
|
||||||
|
|
@ -1431,8 +1473,10 @@ internal fun PdfPageComposable(
|
||||||
|
|
||||||
val currentTileIds = tiles.map { it.tileId }.toSet()
|
val currentTileIds = tiles.map { it.tileId }.toSet()
|
||||||
|
|
||||||
val scaleTolerance = 0.05f
|
val scaleTolerance = PDF_TILE_SCALE_TOLERANCE
|
||||||
val validCurrentTileIds = tiles.filter { abs(it.renderScale - effectiveScale) <= scaleTolerance }.map { it.tileId }.toSet()
|
val validCurrentTileIds = tiles.filter { abs(it.renderScale - renderScale) <= scaleTolerance }.map { it.tileId }.toSet()
|
||||||
|
val tilesToRenderIds = requiredTileIds - validCurrentTileIds
|
||||||
|
val tilesToRecycleIds = currentTileIds - requiredTileIds
|
||||||
|
|
||||||
val duration = (System.nanoTime() - tileCalcStart) / 1_000_000f
|
val duration = (System.nanoTime() - tileCalcStart) / 1_000_000f
|
||||||
if (duration > 2f) {
|
if (duration > 2f) {
|
||||||
|
|
@ -1441,11 +1485,6 @@ internal fun PdfPageComposable(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiredTileIds != validCurrentTileIds) {
|
|
||||||
|
|
||||||
val tilesToRenderIds = requiredTileIds - validCurrentTileIds
|
|
||||||
val tilesToRecycleIds = currentTileIds - requiredTileIds
|
|
||||||
|
|
||||||
if (tilesToRecycleIds.isNotEmpty()) {
|
if (tilesToRecycleIds.isNotEmpty()) {
|
||||||
val (tilesToRecycle, tilesToKeep) = tiles.partition { it.tileId in tilesToRecycleIds }
|
val (tilesToRecycle, tilesToKeep) = tiles.partition { it.tileId in tilesToRecycleIds }
|
||||||
tiles = tilesToKeep
|
tiles = tilesToKeep
|
||||||
|
|
@ -1454,8 +1493,18 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isScrolling && renderScale > 1f) {
|
||||||
|
return@collectLatest
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredTileIds != validCurrentTileIds) {
|
||||||
if (tilesToRenderIds.isNotEmpty()) {
|
if (tilesToRenderIds.isNotEmpty()) {
|
||||||
withContext(Dispatchers.IO) {
|
delay(PDF_TILE_IDLE_RENDER_DELAY_MS)
|
||||||
|
if (!isActive) return@collectLatest
|
||||||
|
if (isScrolling && latestEffectiveScale > 1f) return@collectLatest
|
||||||
|
|
||||||
|
val renderedTiles = withContext(Dispatchers.IO) {
|
||||||
|
val newTiles = mutableListOf<PdfTile>()
|
||||||
tilesToRenderIds.forEach { tileId ->
|
tilesToRenderIds.forEach { tileId ->
|
||||||
if (!isActive) return@forEach
|
if (!isActive) return@forEach
|
||||||
|
|
||||||
|
|
@ -1469,14 +1518,18 @@ internal fun PdfPageComposable(
|
||||||
(col + 1) * tileSizePx,
|
(col + 1) * tileSizePx,
|
||||||
(row + 1) * tileSizePx
|
(row + 1) * tileSizePx
|
||||||
)
|
)
|
||||||
val tileRenderSize = (tileSizePx * effectiveScale).toInt().coerceAtLeast(1)
|
val tileRenderScale = min(
|
||||||
|
renderScale,
|
||||||
|
PDF_MAX_TILE_BITMAP_SIZE_PX.toFloat() / tileSizePx.toFloat()
|
||||||
|
)
|
||||||
|
val tileRenderSize = (tileSizePx * tileRenderScale).toInt().coerceAtLeast(1)
|
||||||
|
|
||||||
val tileBitmap = PdfBitmapPool.get(tileRenderSize)
|
val tileBitmap = PdfBitmapPool.get(tileRenderSize)
|
||||||
|
|
||||||
val fullPageRenderWidth = (actualBitmapWidthPx * effectiveScale).toInt()
|
val fullPageRenderWidth = (actualBitmapWidthPx * tileRenderScale).toInt()
|
||||||
val fullPageRenderHeight = (actualBitmapHeightPx * effectiveScale).toInt()
|
val fullPageRenderHeight = (actualBitmapHeightPx * tileRenderScale).toInt()
|
||||||
val tileRenderX = (col * tileSizePx * effectiveScale).toInt()
|
val tileRenderX = (col * tileSizePx * tileRenderScale).toInt()
|
||||||
val tileRenderY = (row * tileSizePx * effectiveScale).toInt()
|
val tileRenderY = (row * tileSizePx * tileRenderScale).toInt()
|
||||||
|
|
||||||
page?.renderPageBitmap(
|
page?.renderPageBitmap(
|
||||||
bitmap = tileBitmap,
|
bitmap = tileBitmap,
|
||||||
|
|
@ -1487,24 +1540,26 @@ internal fun PdfPageComposable(
|
||||||
renderAnnot = true
|
renderAnnot = true
|
||||||
)
|
)
|
||||||
|
|
||||||
val newTile = PdfTile(tileBitmap, tileRect, tileId, effectiveScale)
|
newTiles += PdfTile(tileBitmap, tileRect, tileId, renderScale)
|
||||||
var handedOver = false
|
}
|
||||||
try {
|
newTiles
|
||||||
withContext(Dispatchers.Main) {
|
}
|
||||||
val oldTile = tiles.find { it.tileId == tileId }
|
|
||||||
tiles = tiles.filter { it.tileId != tileId } + newTile
|
|
||||||
handedOver = true
|
|
||||||
|
|
||||||
oldTile?.let {
|
if (!isActive) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
renderedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
|
||||||
|
}
|
||||||
|
return@collectLatest
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderedTiles.isNotEmpty()) {
|
||||||
|
val renderedIds = renderedTiles.map { it.tileId }.toSet()
|
||||||
|
val replacedTiles = tiles.filter { it.tileId in renderedIds }
|
||||||
|
tiles = tiles.filterNot { it.tileId in renderedIds } + renderedTiles
|
||||||
|
|
||||||
|
if (replacedTiles.isNotEmpty()) {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
PdfBitmapPool.recycle(it.bitmap)
|
replacedTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!handedOver) {
|
|
||||||
PdfBitmapPool.recycle(tileBitmap)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2799,7 +2854,7 @@ internal fun PdfPageComposable(
|
||||||
|
|
||||||
Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr")
|
Timber.tag("PdfLinkDiagnostic").i("Extracted docPtr: $docPtr | pagePtr: $pagePtr")
|
||||||
|
|
||||||
val linkInfo = NativePdfiumBridge.getLinkInfoAtPoint(
|
val linkInfo = PdfiumEngineProvider.bridge.getLinkInfoAtPoint(
|
||||||
docPtr, pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()
|
docPtr, pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -2818,7 +2873,7 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val clickHandled = NativePdfiumBridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble())
|
val clickHandled = PdfiumEngineProvider.bridge.performClick(pagePtr, pdfCoords.x.toDouble(), pdfCoords.y.toDouble())
|
||||||
if (clickHandled) {
|
if (clickHandled) {
|
||||||
return@withContext 2
|
return@withContext 2
|
||||||
}
|
}
|
||||||
|
|
@ -3007,25 +3062,21 @@ internal fun PdfPageComposable(
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
@Suppress("UnusedVariable", "Unused") val down =
|
@Suppress("UnusedVariable", "Unused") val down =
|
||||||
awaitFirstDown(requireUnconsumed = false)
|
awaitFirstDown(requireUnconsumed = false)
|
||||||
|
paginationPanFlingJob?.cancel()
|
||||||
|
paginationPanFlingJob = null
|
||||||
velocityTracker.resetTracking()
|
velocityTracker.resetTracking()
|
||||||
|
|
||||||
var mode = 0
|
var mode = 0
|
||||||
var accumulatedZoom = 1f
|
var accumulatedZoom = 1f
|
||||||
var accumulatedPan = Offset.Zero
|
var accumulatedPan = Offset.Zero
|
||||||
var swipeAccumulatorX = 0f
|
var swipeAccumulatorX = 0f
|
||||||
|
var velocityAccumulator = Offset.Zero
|
||||||
|
|
||||||
do {
|
do {
|
||||||
val event = awaitPointerEvent()
|
val event = awaitPointerEvent()
|
||||||
val canceled = event.changes.any { it.isConsumed }
|
val canceled = event.changes.any { it.isConsumed }
|
||||||
val pointerCount = event.changes.size
|
val pointerCount = event.changes.size
|
||||||
|
|
||||||
val currentCentroid = event.calculateCentroid(useCurrent = true)
|
|
||||||
if (pointerCount > 0 && currentCentroid != Offset.Unspecified) {
|
|
||||||
velocityTracker.addPosition(
|
|
||||||
event.changes[0].uptimeMillis, currentCentroid
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canceled) {
|
if (!canceled) {
|
||||||
val rawPanChange = event.calculatePan()
|
val rawPanChange = event.calculatePan()
|
||||||
val panChange = if (isScrollLocked && pointerCount == 1) {
|
val panChange = if (isScrollLocked && pointerCount == 1) {
|
||||||
|
|
@ -3077,6 +3128,14 @@ internal fun PdfPageComposable(
|
||||||
Timber.tag("PdfZoomDebug").v("Panning: Offset $offset -> $newX, $newY (Max: $maxOffsetX, $maxOffsetY)")
|
Timber.tag("PdfZoomDebug").v("Panning: Offset $offset -> $newX, $newY (Max: $maxOffsetX, $maxOffsetY)")
|
||||||
offset = Offset(newX, newY)
|
offset = Offset(newX, newY)
|
||||||
|
|
||||||
|
if (event.changes.isNotEmpty() && panChange != Offset.Zero) {
|
||||||
|
velocityAccumulator += panChange
|
||||||
|
velocityTracker.addPosition(
|
||||||
|
event.changes[0].uptimeMillis,
|
||||||
|
velocityAccumulator
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
event.changes.forEach {
|
event.changes.forEach {
|
||||||
if (it.positionChanged()) it.consume()
|
if (it.positionChanged()) it.consume()
|
||||||
}
|
}
|
||||||
|
|
@ -3189,41 +3248,57 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (mode == 1 && scale > 1f) {
|
} else if (mode == 1 && scale > 1f) {
|
||||||
val velocity = velocityTracker.calculateVelocity()
|
|
||||||
val contentWidth = actualBitmapWidthPx * scale
|
val contentWidth = actualBitmapWidthPx * scale
|
||||||
val contentHeight = actualBitmapHeightPx * scale
|
val contentHeight = actualBitmapHeightPx * scale
|
||||||
val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f
|
val maxOffsetX = (contentWidth - size.width).coerceAtLeast(0f) / 2f
|
||||||
val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f
|
val maxOffsetY = (contentHeight - size.height).coerceAtLeast(0f) / 2f
|
||||||
|
|
||||||
val startX = offset.x
|
val velocity = velocityTracker.calculateVelocity()
|
||||||
val startY = offset.y
|
val flingX = if (!isScrollLocked && abs(velocity.x) > PDF_PAGINATION_PAN_FLING_MIN_VELOCITY) {
|
||||||
|
velocity.x * PDF_PAGINATION_PAN_FLING_MULTIPLIER
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
val flingY = if (abs(velocity.y) > PDF_PAGINATION_PAN_FLING_MIN_VELOCITY) {
|
||||||
|
velocity.y * PDF_PAGINATION_PAN_FLING_MULTIPLIER
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
|
||||||
coroutineScope.launch {
|
if (flingX == 0f && flingY == 0f) {
|
||||||
|
offset = Offset(
|
||||||
|
x = offset.x.coerceIn(-maxOffsetX, maxOffsetX),
|
||||||
|
y = offset.y.coerceIn(-maxOffsetY, maxOffsetY)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val startOffset = offset
|
||||||
|
paginationPanFlingJob = coroutineScope.launch {
|
||||||
|
try {
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
launch {
|
launch {
|
||||||
if (!isScrollLocked) {
|
if (flingX != 0f) {
|
||||||
Animatable(startX).animateDecay(
|
Animatable(startOffset.x).animateDecay(flingX, decay) {
|
||||||
velocity.x, decay
|
offset = offset.copy(
|
||||||
) {
|
x = value.coerceIn(-maxOffsetX, maxOffsetX)
|
||||||
val newX = value.coerceIn(
|
|
||||||
-maxOffsetX, maxOffsetX
|
|
||||||
)
|
)
|
||||||
offset = offset.copy(x = newX)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
launch {
|
launch {
|
||||||
Animatable(startY).animateDecay(
|
if (flingY != 0f) {
|
||||||
velocity.y, decay
|
Animatable(startOffset.y).animateDecay(flingY, decay) {
|
||||||
) {
|
offset = offset.copy(
|
||||||
val newY = value.coerceIn(
|
y = value.coerceIn(-maxOffsetY, maxOffsetY)
|
||||||
-maxOffsetY, maxOffsetY
|
|
||||||
)
|
)
|
||||||
offset = offset.copy(y = newY)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
paginationPanFlingJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3256,7 +3331,7 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
|
|
||||||
val buttons = currentEvent.buttons
|
val buttons = currentEvent.buttons
|
||||||
Timber.tag("StylusEraserDiagnostic").d(
|
Timber.tag("StylusDebug").d(
|
||||||
"Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
|
"Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -3881,7 +3956,10 @@ internal fun PdfPageComposable(
|
||||||
stableColorFilter,
|
stableColorFilter,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
excludeImages,
|
excludeImages,
|
||||||
stableImageRects
|
stableImageRects,
|
||||||
|
textureBitmap,
|
||||||
|
effectiveTextureAlpha,
|
||||||
|
textureBlendMode
|
||||||
) {
|
) {
|
||||||
Timber.tag("PdfDrawPerf").v(
|
Timber.tag("PdfDrawPerf").v(
|
||||||
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
|
"STATIC DATA GENERATED: Scale=$effectiveScale, Tiles=${stableTiles.item.size}"
|
||||||
|
|
@ -3899,7 +3977,10 @@ internal fun PdfPageComposable(
|
||||||
colorFilter = stableColorFilter,
|
colorFilter = stableColorFilter,
|
||||||
isDarkMode = isDarkMode,
|
isDarkMode = isDarkMode,
|
||||||
excludeImages = excludeImages,
|
excludeImages = excludeImages,
|
||||||
imageRects = stableImageRects
|
imageRects = stableImageRects,
|
||||||
|
textureBitmap = StableHolder(textureBitmap),
|
||||||
|
textureAlpha = effectiveTextureAlpha,
|
||||||
|
textureBlendMode = textureBlendMode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4263,7 +4344,10 @@ private fun PdfBitmapLayer(
|
||||||
colorFilter: ColorFilter? = null,
|
colorFilter: ColorFilter? = null,
|
||||||
isDarkMode: Boolean = false,
|
isDarkMode: Boolean = false,
|
||||||
excludeImages: Boolean = false,
|
excludeImages: Boolean = false,
|
||||||
imageRects: List<android.graphics.Rect> = emptyList()
|
imageRects: List<android.graphics.Rect> = emptyList(),
|
||||||
|
textureBitmap: ImageBitmap? = null,
|
||||||
|
textureAlpha: Float = 0f,
|
||||||
|
textureBlendMode: BlendMode = BlendMode.Multiply
|
||||||
) {
|
) {
|
||||||
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
|
Canvas(modifier = Modifier.fillMaxSize().graphicsLayer()) {
|
||||||
translate(left = centeringOffsetX, top = centeringOffsetY) {
|
translate(left = centeringOffsetX, top = centeringOffsetY) {
|
||||||
|
|
@ -4363,6 +4447,15 @@ private fun PdfBitmapLayer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (textureBitmap != null && textureAlpha > 0f) {
|
||||||
|
drawRect(
|
||||||
|
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
|
||||||
|
size = Size(dstW.toFloat(), dstH.toFloat()),
|
||||||
|
blendMode = textureBlendMode,
|
||||||
|
alpha = textureAlpha
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4884,7 +4977,10 @@ private fun PdfPageStaticLayer(data: PageStaticData) {
|
||||||
colorFilter = data.colorFilter.item,
|
colorFilter = data.colorFilter.item,
|
||||||
isDarkMode = data.isDarkMode,
|
isDarkMode = data.isDarkMode,
|
||||||
excludeImages = data.excludeImages,
|
excludeImages = data.excludeImages,
|
||||||
imageRects = data.imageRects.item
|
imageRects = data.imageRects.item,
|
||||||
|
textureBitmap = data.textureBitmap.item,
|
||||||
|
textureAlpha = data.textureAlpha,
|
||||||
|
textureBlendMode = data.textureBlendMode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ 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.ReaderTheme
|
import com.aryan.reader.ReaderTheme
|
||||||
|
import com.aryan.reader.ReaderTexture
|
||||||
import com.aryan.reader.epubreader.SystemUiMode
|
import com.aryan.reader.epubreader.SystemUiMode
|
||||||
|
|
||||||
internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll"
|
internal const val VERTICAL_SCROLL_TAG = "PdfVerticalScroll"
|
||||||
|
|
@ -39,6 +40,8 @@ private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
|
||||||
private const val PDF_THEME_KEY = "pdf_reader_theme"
|
private const val PDF_THEME_KEY = "pdf_reader_theme"
|
||||||
private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
|
private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
|
||||||
private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
|
private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
|
||||||
|
private const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
|
||||||
|
private const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
|
||||||
private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
|
private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
|
||||||
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
|
||||||
|
|
||||||
|
|
@ -76,7 +79,13 @@ val PdfBuiltInThemes = listOf(
|
||||||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
|
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
|
||||||
|
ReaderTheme("pdf_natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
|
||||||
|
ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
|
||||||
|
ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
|
||||||
|
ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
|
||||||
|
ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
|
||||||
|
ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
internal fun loadPdfHiddenTools(context: Context): Set<String> {
|
||||||
|
|
@ -89,6 +98,32 @@ internal fun savePdfHiddenTools(context: Context, hiddenTools: Set<String>) {
|
||||||
prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) }
|
prefs.edit { putStringSet(PDF_HIDDEN_TOOLS_KEY, hiddenTools) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfToolOrder(context: Context): List<PdfReaderTool> {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val savedTools = prefs.getString(PDF_TOOL_ORDER_KEY, null)
|
||||||
|
?.split(',')
|
||||||
|
?.filter { it.isNotBlank() }
|
||||||
|
?.mapNotNull { name -> PdfReaderTool.entries.firstOrNull { it.name == name } }
|
||||||
|
.orEmpty()
|
||||||
|
return (savedTools + PdfReaderTool.entries.filterNot { it in savedTools }).distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfToolOrder(context: Context, toolOrder: List<PdfReaderTool>) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putString(PDF_TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadPdfBottomTools(context: Context): Set<String> {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val defaultBottomTools = PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet()
|
||||||
|
return prefs.getStringSet(PDF_BOTTOM_TOOLS_KEY, defaultBottomTools) ?: defaultBottomTools
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun savePdfBottomTools(context: Context, bottomTools: Set<String>) {
|
||||||
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit { putStringSet(PDF_BOTTOM_TOOLS_KEY, bottomTools) }
|
||||||
|
}
|
||||||
|
|
||||||
internal fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> {
|
internal fun loadCustomHighlightColors(context: Context): Map<PdfHighlightColor, Color> {
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
return PdfHighlightColor.entries.associateWith {
|
return PdfHighlightColor.entries.associateWith {
|
||||||
|
|
@ -139,7 +174,7 @@ internal fun loadPdfThemeId(context: Context): String {
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun loadUseOnlineDict(context: Context): Boolean {
|
internal fun loadUseOnlineDict(context: Context): Boolean {
|
||||||
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
|
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) return false
|
||||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
|
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
package com.aryan.reader.pdf
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -11,99 +12,499 @@ import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.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.LockOpen
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.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
|
||||||
|
|
||||||
|
|
||||||
|
enum class PdfFlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
|
||||||
|
|
||||||
|
data class PdfFlatToolItem(
|
||||||
|
val id: String,
|
||||||
|
val type: PdfFlatItemType,
|
||||||
|
val tool: PdfReaderTool? = null,
|
||||||
|
val section: PdfToolbarSection? = null,
|
||||||
|
val title: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
fun sanitizePdfPlaceholders(list: List<PdfFlatToolItem>): List<PdfFlatToolItem> {
|
||||||
|
val result = mutableListOf<PdfFlatToolItem>()
|
||||||
|
val sectionMap = mutableMapOf<PdfToolbarSection, MutableList<PdfFlatToolItem>>()
|
||||||
|
PdfToolbarSection.entries.forEach { sectionMap[it] = mutableListOf() }
|
||||||
|
|
||||||
|
list.forEach { item ->
|
||||||
|
if (item.type == PdfFlatItemType.TOOL) {
|
||||||
|
item.section?.let { sectionMap[it]?.add(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PdfToolbarSection.entries.forEach { section ->
|
||||||
|
result.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title))
|
||||||
|
val tools = sectionMap[section] ?: emptyList()
|
||||||
|
if (tools.isEmpty()) {
|
||||||
|
result.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
|
||||||
|
} else {
|
||||||
|
result.addAll(tools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list.filter { it.type == PdfFlatItemType.MORE_HEADER || it.type == PdfFlatItemType.MORE_TOOL }.forEach {
|
||||||
|
result.add(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
class PdfDragDropState(
|
||||||
|
val lazyListState: LazyListState,
|
||||||
|
val onMove: (String, String) -> Unit
|
||||||
|
) {
|
||||||
|
var draggedItemId by mutableStateOf<String?>(null)
|
||||||
|
var dragOffset by mutableStateOf(Offset.Zero)
|
||||||
|
|
||||||
|
fun onDragStart(id: String) { draggedItemId = id; dragOffset = Offset.Zero }
|
||||||
|
fun onDrag(delta: Offset) {
|
||||||
|
val draggedId = draggedItemId ?: return
|
||||||
|
dragOffset += delta
|
||||||
|
val visibleItems = lazyListState.layoutInfo.visibleItemsInfo
|
||||||
|
val currentItem = visibleItems.find { it.key == draggedId } ?: return
|
||||||
|
val center = currentItem.offset + dragOffset.y + currentItem.size / 2f
|
||||||
|
val targetItem = visibleItems.find { it.key != draggedId && center >= it.offset && center <= (it.offset + it.size) }
|
||||||
|
if (targetItem != null) {
|
||||||
|
onMove(draggedId, targetItem.key.toString())
|
||||||
|
dragOffset = dragOffset.copy(y = dragOffset.y - (targetItem.offset - currentItem.offset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun onDragEnd() { draggedItemId = null; dragOffset = Offset.Zero }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PdfCustomizeToolsSheet(
|
fun PdfCustomizeToolsSheet(
|
||||||
hiddenTools: Set<String>,
|
hiddenTools: Set<String>,
|
||||||
|
toolOrder: List<PdfReaderTool>,
|
||||||
|
bottomTools: Set<String>,
|
||||||
onUpdate: (Set<String>) -> Unit,
|
onUpdate: (Set<String>) -> Unit,
|
||||||
|
onOrderUpdate: (List<PdfReaderTool>) -> Unit,
|
||||||
|
onPlacementUpdate: (Set<String>) -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
ModalBottomSheet(
|
val reorderableToolbarTools = setOf(
|
||||||
|
PdfReaderTool.DICTIONARY, PdfReaderTool.THEME, PdfReaderTool.LOCK_PANNING,
|
||||||
|
PdfReaderTool.SLIDER, PdfReaderTool.TOC, PdfReaderTool.SEARCH,
|
||||||
|
PdfReaderTool.HIGHLIGHT_ALL, PdfReaderTool.AI_FEATURES,
|
||||||
|
PdfReaderTool.EDIT_MODE, PdfReaderTool.TTS_CONTROLS
|
||||||
|
)
|
||||||
|
|
||||||
|
var localHiddenTools by remember { mutableStateOf(hiddenTools) }
|
||||||
|
var flatItems by remember {
|
||||||
|
mutableStateOf<List<PdfFlatToolItem>>(
|
||||||
|
run {
|
||||||
|
val toolbarTools = toolOrder.filter { it in reorderableToolbarTools }
|
||||||
|
val topTools = toolbarTools.filter { !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||||
|
val bottomToolsList = toolbarTools.filter { bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||||
|
val hiddenToolsList = toolbarTools.filter { hiddenTools.contains(it.name) }
|
||||||
|
val moreTools = toolOrder.filter { it !in reorderableToolbarTools }
|
||||||
|
|
||||||
|
val list = mutableListOf<PdfFlatToolItem>()
|
||||||
|
|
||||||
|
PdfToolbarSection.entries.forEach { section ->
|
||||||
|
val tools = when(section) {
|
||||||
|
PdfToolbarSection.TOP -> topTools
|
||||||
|
PdfToolbarSection.BOTTOM -> bottomToolsList
|
||||||
|
PdfToolbarSection.HIDDEN -> hiddenToolsList
|
||||||
|
}
|
||||||
|
list.add(PdfFlatToolItem("header_${section.name}", PdfFlatItemType.SECTION_HEADER, section = section, title = section.title))
|
||||||
|
if (tools.isEmpty()) {
|
||||||
|
list.add(PdfFlatToolItem("empty_${section.name}", PdfFlatItemType.EMPTY_PLACEHOLDER, section = section))
|
||||||
|
} else {
|
||||||
|
tools.forEach { tool ->
|
||||||
|
list.add(PdfFlatToolItem("tool_${tool.name}", PdfFlatItemType.TOOL, tool = tool, section = section))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list.add(PdfFlatToolItem("more_header", PdfFlatItemType.MORE_HEADER, title = "More menu"))
|
||||||
|
moreTools.forEach { tool ->
|
||||||
|
list.add(PdfFlatToolItem("more_${tool.name}", PdfFlatItemType.MORE_TOOL, tool = tool))
|
||||||
|
}
|
||||||
|
list
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val commitDragDrop = {
|
||||||
|
val newHidden = localHiddenTools.filter { toolName ->
|
||||||
|
toolOrder.find { it.name == toolName } !in reorderableToolbarTools
|
||||||
|
}.toMutableSet()
|
||||||
|
|
||||||
|
val newBottom = mutableSetOf<String>()
|
||||||
|
val newOrder = mutableListOf<PdfReaderTool>()
|
||||||
|
|
||||||
|
flatItems.forEach { item ->
|
||||||
|
if (item.type == PdfFlatItemType.TOOL && item.tool != null) {
|
||||||
|
newOrder.add(item.tool)
|
||||||
|
if (item.section == PdfToolbarSection.HIDDEN) newHidden.add(item.tool.name)
|
||||||
|
if (item.section == PdfToolbarSection.BOTTOM) newBottom.add(item.tool.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val moreTools = flatItems.filter { it.type == PdfFlatItemType.MORE_TOOL }.mapNotNull { it.tool }
|
||||||
|
newOrder.addAll(moreTools)
|
||||||
|
|
||||||
|
localHiddenTools = newHidden
|
||||||
|
onUpdate(newHidden)
|
||||||
|
onPlacementUpdate(newBottom)
|
||||||
|
onOrderUpdate(newOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
val lazyListState = rememberLazyListState()
|
||||||
|
val dragDropState = remember {
|
||||||
|
PdfDragDropState(lazyListState) { fromKey, toKey ->
|
||||||
|
val fromIndex = flatItems.indexOfFirst { it.id == fromKey }
|
||||||
|
val toIndex = flatItems.indexOfFirst { it.id == toKey }
|
||||||
|
if (fromIndex == -1 || toIndex == -1 || fromIndex == toIndex) return@PdfDragDropState
|
||||||
|
|
||||||
|
val fromItem = flatItems[fromIndex]
|
||||||
|
if (fromItem.type != PdfFlatItemType.TOOL) return@PdfDragDropState
|
||||||
|
|
||||||
|
val toItem = flatItems[toIndex]
|
||||||
|
if (toItem.type == PdfFlatItemType.MORE_HEADER || toItem.type == PdfFlatItemType.MORE_TOOL) return@PdfDragDropState
|
||||||
|
|
||||||
|
val newList = flatItems.toMutableList()
|
||||||
|
val movedItem = newList.removeAt(fromIndex)
|
||||||
|
|
||||||
|
val newToIndex = newList.indexOfFirst { it.id == toKey }
|
||||||
|
val insertIndex = if (fromIndex < toIndex) newToIndex + 1 else newToIndex
|
||||||
|
|
||||||
|
newList.add(insertIndex, movedItem)
|
||||||
|
|
||||||
|
var actualSection = movedItem.section
|
||||||
|
for (i in insertIndex downTo 0) {
|
||||||
|
val item = newList[i]
|
||||||
|
if (item.type == PdfFlatItemType.SECTION_HEADER) {
|
||||||
|
actualSection = item.section
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newList[insertIndex] = movedItem.copy(section = actualSection)
|
||||||
|
flatItems = newList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
contentWindowInsets = { WindowInsets.navigationBars }
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.windowInsetsPadding(WindowInsets.navigationBars),
|
||||||
|
color = MaterialTheme.colorScheme.surface
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.title_customize_toolbar),
|
text = stringResource(R.string.title_customize_toolbar),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
IconButton(onClick = onDismiss) {
|
||||||
Text(
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
||||||
text = stringResource(R.string.desc_customize_toolbar),
|
}
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
}
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
LazyColumn(
|
||||||
PdfReaderTool.entries.groupBy { it.category }.forEach { (category, tools) ->
|
state = lazyListState,
|
||||||
item {
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = 24.dp)
|
||||||
|
) {
|
||||||
|
items(flatItems, key = { it.id }) { item ->
|
||||||
|
val isDragged = item.id == dragDropState.draggedItemId
|
||||||
|
val zIndex = if (isDragged) 1f else 0f
|
||||||
|
val elevation = if (isDragged) 8.dp else 0.dp
|
||||||
|
val scale = if (isDragged) 1.03f else 1f
|
||||||
|
val translationY = if (isDragged) dragDropState.dragOffset.y else 0f
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (isDragged) Modifier else Modifier.animateItem())
|
||||||
|
.zIndex(zIndex)
|
||||||
|
.graphicsLayer {
|
||||||
|
this.translationY = translationY
|
||||||
|
this.scaleX = scale
|
||||||
|
this.scaleY = scale
|
||||||
|
this.shadowElevation = elevation.toPx()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
when (item.type) {
|
||||||
|
PdfFlatItemType.SECTION_HEADER -> {
|
||||||
Text(
|
Text(
|
||||||
text = category,
|
text = item.title ?: "",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp, start = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(tools) { tool ->
|
PdfFlatItemType.EMPTY_PLACEHOLDER -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(64.dp)
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("Drop tools here", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PdfFlatItemType.TOOL -> {
|
||||||
|
PdfToolbarDragRow(
|
||||||
|
tool = item.tool!!,
|
||||||
|
isDragging = isDragged,
|
||||||
|
onDragStart = { dragDropState.onDragStart(item.id) },
|
||||||
|
onDrag = { dragDropState.onDrag(it) },
|
||||||
|
onDragEnd = {
|
||||||
|
dragDropState.onDragEnd()
|
||||||
|
flatItems = sanitizePdfPlaceholders(flatItems).toList()
|
||||||
|
commitDragDrop()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PdfFlatItemType.MORE_HEADER -> {
|
||||||
|
Text(
|
||||||
|
text = item.title ?: "More menu",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PdfFlatItemType.MORE_TOOL -> {
|
||||||
|
PdfMoreToolVisibilityRow(
|
||||||
|
title = item.tool!!.title,
|
||||||
|
visible = !localHiddenTools.contains(item.tool.name),
|
||||||
|
onToggle = {
|
||||||
|
localHiddenTools = if (localHiddenTools.contains(item.tool.name)) {
|
||||||
|
localHiddenTools - item.tool.name
|
||||||
|
} else {
|
||||||
|
localHiddenTools + item.tool.name
|
||||||
|
}
|
||||||
|
onUpdate(localHiddenTools)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PdfToolbarDragRow(
|
||||||
|
tool: PdfReaderTool,
|
||||||
|
isDragging: Boolean,
|
||||||
|
onDragStart: () -> Unit,
|
||||||
|
onDrag: (Offset) -> Unit,
|
||||||
|
onDragEnd: () -> Unit
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = if (isDragging) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
PdfToolPreviewIcon(tool)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = tool.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Menu,
|
||||||
|
contentDescription = "Drag to reorder",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.padding(12.dp)
|
||||||
|
.pointerInput(tool) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = { onDragStart() },
|
||||||
|
onDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
onDrag(dragAmount)
|
||||||
|
},
|
||||||
|
onDragEnd = onDragEnd,
|
||||||
|
onDragCancel = onDragEnd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PdfToolbarDragRow(
|
||||||
|
tool: PdfReaderTool,
|
||||||
|
isDragging: Boolean,
|
||||||
|
onBounds: (Rect) -> Unit,
|
||||||
|
onDragStart: (Offset) -> Unit,
|
||||||
|
onDrag: (Offset) -> Unit,
|
||||||
|
onDragEnd: () -> Unit
|
||||||
|
) {
|
||||||
|
var bounds by remember { mutableStateOf<Rect?>(null) }
|
||||||
|
androidx.compose.material3.Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.onGloballyPositioned {
|
||||||
|
bounds = it.boundsInWindow()
|
||||||
|
onBounds(it.boundsInWindow())
|
||||||
|
}
|
||||||
|
.pointerInput(tool) {
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDragStart = { onDragStart(bounds?.center ?: Offset.Zero) },
|
||||||
|
onDragEnd = onDragEnd,
|
||||||
|
onDragCancel = onDragEnd,
|
||||||
|
onDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
onDrag(dragAmount)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = if (isDragging) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
PdfToolPreviewIcon(tool)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = tool.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Icon(Icons.Default.Menu, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PdfMoreToolVisibilityRow(
|
||||||
|
title: String,
|
||||||
|
visible: Boolean,
|
||||||
|
onToggle: () -> Unit
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable {
|
.clickable(onClick = onToggle)
|
||||||
val newSet = hiddenTools.toMutableSet()
|
|
||||||
if (newSet.contains(tool.name)) newSet.remove(tool.name)
|
|
||||||
else newSet.add(tool.name)
|
|
||||||
onUpdate(newSet)
|
|
||||||
}
|
|
||||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = tool.title,
|
text = title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
modifier = Modifier.weight(1f)
|
||||||
Switch(
|
|
||||||
checked = !hiddenTools.contains(tool.name),
|
|
||||||
onCheckedChange = { isVisible ->
|
|
||||||
val newSet = hiddenTools.toMutableSet()
|
|
||||||
if (isVisible) newSet.remove(tool.name) else newSet.add(tool.name)
|
|
||||||
onUpdate(newSet)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
if (visible) {
|
||||||
|
Icon(Icons.Default.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
enum class PdfToolbarSection(val title: String) {
|
||||||
|
TOP("Top Bar"),
|
||||||
|
BOTTOM("Bottom Bar"),
|
||||||
|
HIDDEN("Hidden Tools")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PdfToolPreviewIcon(tool: PdfReaderTool) {
|
||||||
|
when (tool) {
|
||||||
|
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.HIGHLIGHT_ALL -> Icon(painterResource(id = R.drawable.highlight_text), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
|
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,9 @@ object PdfToHtmlGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val doc = pdfiumCore.newDocument(pfd)
|
val doc = PdfiumEngineProvider.withPdfium {
|
||||||
|
pdfiumCore.newDocument(pfd)
|
||||||
|
}
|
||||||
val totalPages = doc.getPageCount()
|
val totalPages = doc.getPageCount()
|
||||||
Timber.tag(TAG).d("Document loaded. Total pages: $totalPages")
|
Timber.tag(TAG).d("Document loaded. Total pages: $totalPages")
|
||||||
|
|
||||||
|
|
@ -56,7 +58,9 @@ object PdfToHtmlGenerator {
|
||||||
writer.write(buildGlobalHtmlFooter())
|
writer.write(buildGlobalHtmlFooter())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PdfiumEngineProvider.withPdfium {
|
||||||
doc.close()
|
doc.close()
|
||||||
|
}
|
||||||
pfd.close()
|
pfd.close()
|
||||||
Timber.tag(TAG).d("generateHtmlFile SUCCESS | ${System.currentTimeMillis() - t0}ms")
|
Timber.tag(TAG).d("generateHtmlFile SUCCESS | ${System.currentTimeMillis() - t0}ms")
|
||||||
return@withContext true
|
return@withContext true
|
||||||
|
|
@ -137,14 +141,14 @@ object PdfToHtmlGenerator {
|
||||||
val textPagePtr = getNativePointer(textPage)
|
val textPagePtr = getNativePointer(textPage)
|
||||||
|
|
||||||
val imageElements = mutableListOf<ImageElement>()
|
val imageElements = mutableListOf<ImageElement>()
|
||||||
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
|
val objCount = PdfiumEngineProvider.bridge.getPageObjectCount(pagePtr)
|
||||||
for (i in 0 until objCount) {
|
for (i in 0 until objCount) {
|
||||||
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) {
|
if (PdfiumEngineProvider.bridge.getPageObjectType(pagePtr, i) == 3) {
|
||||||
val bbox = FloatArray(4)
|
val bbox = FloatArray(4)
|
||||||
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
|
if (PdfiumEngineProvider.bridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
|
||||||
val topY = bbox[3]
|
val topY = bbox[3]
|
||||||
val dimens = IntArray(2)
|
val dimens = IntArray(2)
|
||||||
val pixels = NativePdfiumBridge.extractImagePixels(pagePtr, i, dimens)
|
val pixels = PdfiumEngineProvider.bridge.extractImagePixels(pagePtr, i, dimens)
|
||||||
if (pixels != null && dimens[0] > 0 && dimens[1] > 0) {
|
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)
|
||||||
|
|
@ -175,11 +179,11 @@ object PdfToHtmlGenerator {
|
||||||
val flags: IntArray?
|
val flags: IntArray?
|
||||||
val charBoxes: FloatArray?
|
val charBoxes: FloatArray?
|
||||||
|
|
||||||
synchronized(NativePdfiumBridge::class.java) {
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,14 @@ import androidx.compose.foundation.rememberScrollState
|
||||||
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.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
|
@ -33,6 +34,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.aryan.reader.BuildConfig
|
import com.aryan.reader.BuildConfig
|
||||||
|
|
@ -41,9 +43,23 @@ import com.aryan.reader.R
|
||||||
import com.aryan.reader.SearchState
|
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.epubreader.SystemUiMode
|
import com.aryan.reader.epubreader.SystemUiMode
|
||||||
import kotlin.collections.isNotEmpty
|
import kotlin.collections.isNotEmpty
|
||||||
|
|
||||||
|
private val pdfToolbarTools = setOf(
|
||||||
|
PdfReaderTool.DICTIONARY,
|
||||||
|
PdfReaderTool.THEME,
|
||||||
|
PdfReaderTool.LOCK_PANNING,
|
||||||
|
PdfReaderTool.SLIDER,
|
||||||
|
PdfReaderTool.TOC,
|
||||||
|
PdfReaderTool.SEARCH,
|
||||||
|
PdfReaderTool.HIGHLIGHT_ALL,
|
||||||
|
PdfReaderTool.AI_FEATURES,
|
||||||
|
PdfReaderTool.EDIT_MODE,
|
||||||
|
PdfReaderTool.TTS_CONTROLS
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PdfTopBar(
|
internal fun PdfTopBar(
|
||||||
|
|
@ -60,6 +76,8 @@ internal fun PdfTopBar(
|
||||||
totalPages: Int,
|
totalPages: Int,
|
||||||
pagerStatePageCount: Int,
|
pagerStatePageCount: Int,
|
||||||
hiddenTools: Set<String>,
|
hiddenTools: Set<String>,
|
||||||
|
toolOrder: List<PdfReaderTool>,
|
||||||
|
bottomTools: Set<String>,
|
||||||
isScrollLocked: Boolean,
|
isScrollLocked: Boolean,
|
||||||
isEditMode: Boolean,
|
isEditMode: Boolean,
|
||||||
displayMode: DisplayMode,
|
displayMode: DisplayMode,
|
||||||
|
|
@ -83,6 +101,16 @@ internal fun PdfTopBar(
|
||||||
onShowCustomizeTools: () -> Unit,
|
onShowCustomizeTools: () -> Unit,
|
||||||
onShowOcrLanguage: () -> Unit,
|
onShowOcrLanguage: () -> Unit,
|
||||||
onShowVisualOptions: () -> Unit,
|
onShowVisualOptions: () -> Unit,
|
||||||
|
onShowSlider: () -> Unit,
|
||||||
|
onShowToc: () -> Unit,
|
||||||
|
onSearchClick: () -> Unit,
|
||||||
|
onToggleHighlights: () -> Unit,
|
||||||
|
onShowAiHub: () -> Unit,
|
||||||
|
onToggleEditMode: () -> Unit,
|
||||||
|
onToggleTts: () -> Unit,
|
||||||
|
isTtsPlayingOrLoading: Boolean,
|
||||||
|
showAllTextHighlights: Boolean,
|
||||||
|
isHighlightingLoading: Boolean,
|
||||||
tapToNavigateEnabled: Boolean,
|
tapToNavigateEnabled: Boolean,
|
||||||
onToggleTapToNavigate: () -> Unit,
|
onToggleTapToNavigate: () -> Unit,
|
||||||
onChangeDisplayMode: (DisplayMode) -> Unit,
|
onChangeDisplayMode: (DisplayMode) -> Unit,
|
||||||
|
|
@ -153,34 +181,88 @@ internal fun PdfTopBar(
|
||||||
modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator")
|
modifier = Modifier.padding(start = 12.dp).weight(1f).testTag("PageNumberIndicator")
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!hiddenTools.contains(PdfReaderTool.THEME.name)) {
|
toolOrder
|
||||||
TooltipIconButton(
|
.filter { it in pdfToolbarTools && !bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||||
|
.forEach { tool ->
|
||||||
|
when (tool) {
|
||||||
|
PdfReaderTool.THEME -> TooltipIconButton(
|
||||||
text = stringResource(R.string.tooltip_theme),
|
text = stringResource(R.string.tooltip_theme),
|
||||||
description = stringResource(R.string.tooltip_theme_desc),
|
description = stringResource(R.string.tooltip_theme_desc),
|
||||||
onClick = onShowThemePanel
|
onClick = onShowThemePanel
|
||||||
) {
|
) {
|
||||||
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.LOCK_PANNING -> TooltipIconButton(
|
||||||
|
|
||||||
if (!hiddenTools.contains(PdfReaderTool.LOCK_PANNING.name)) {
|
|
||||||
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),
|
||||||
onClick = onToggleScrollLock
|
onClick = onToggleScrollLock
|
||||||
) {
|
) {
|
||||||
Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(if (isScrollLocked) Icons.Default.Lock else Icons.Default.LockOpen, contentDescription = if (isScrollLocked) stringResource(R.string.tooltip_unlock_pan) else stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
PdfReaderTool.DICTIONARY -> TooltipIconButton(
|
||||||
|
|
||||||
if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) {
|
|
||||||
TooltipIconButton(
|
|
||||||
text = stringResource(R.string.tooltip_dictionary),
|
text = stringResource(R.string.tooltip_dictionary),
|
||||||
description = stringResource(R.string.tooltip_dictionary_desc),
|
description = stringResource(R.string.tooltip_dictionary_desc),
|
||||||
onClick = onShowDictionarySettings
|
onClick = onShowDictionarySettings
|
||||||
) {
|
) {
|
||||||
Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
|
PdfReaderTool.SLIDER -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_slider),
|
||||||
|
description = stringResource(R.string.tooltip_slider_desc),
|
||||||
|
onClick = onShowSlider,
|
||||||
|
enabled = !isTtsPlayingOrLoading
|
||||||
|
) {
|
||||||
|
Icon(painterResource(id = R.drawable.slider), contentDescription = stringResource(R.string.content_desc_navigate_slider))
|
||||||
|
}
|
||||||
|
PdfReaderTool.TOC -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_toc),
|
||||||
|
description = stringResource(R.string.tooltip_toc_desc),
|
||||||
|
onClick = onShowToc,
|
||||||
|
enabled = !isTtsPlayingOrLoading
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
|
||||||
|
}
|
||||||
|
PdfReaderTool.SEARCH -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_search),
|
||||||
|
description = stringResource(R.string.tooltip_search_desc),
|
||||||
|
onClick = onSearchClick,
|
||||||
|
enabled = !isTtsPlayingOrLoading
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
|
||||||
|
}
|
||||||
|
PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton(
|
||||||
|
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
|
||||||
|
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
|
||||||
|
onClick = onToggleHighlights
|
||||||
|
) {
|
||||||
|
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
|
||||||
|
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
|
||||||
|
TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_ai),
|
||||||
|
description = stringResource(R.string.tooltip_ai_desc),
|
||||||
|
onClick = onShowAiHub
|
||||||
|
) {
|
||||||
|
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PdfReaderTool.EDIT_MODE -> TooltipIconButton(
|
||||||
|
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
|
||||||
|
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
|
||||||
|
onClick = onToggleEditMode
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
PdfReaderTool.TTS_CONTROLS -> TooltipIconButton(
|
||||||
|
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
|
||||||
|
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
|
||||||
|
onClick = onToggleTts
|
||||||
|
) {
|
||||||
|
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
|
|
@ -197,14 +279,25 @@ internal fun PdfTopBar(
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
var showMoreMenu by remember { mutableStateOf(false) }
|
var showMoreMenu by remember { mutableStateOf(false) }
|
||||||
|
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
|
||||||
TooltipIconButton(
|
TooltipIconButton(
|
||||||
text = stringResource(R.string.tooltip_more_options),
|
text = stringResource(R.string.tooltip_more_options),
|
||||||
description = stringResource(R.string.tooltip_more_options_desc),
|
description = stringResource(R.string.tooltip_more_options_desc),
|
||||||
onClick = { showMoreMenu = true }) {
|
onClick = {
|
||||||
|
showHiddenToolsExpanded = false
|
||||||
|
showMoreMenu = true
|
||||||
|
}) {
|
||||||
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
|
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.tooltip_more_options))
|
||||||
}
|
}
|
||||||
|
|
||||||
DropdownMenu(expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false }) {
|
DropdownMenu(
|
||||||
|
expanded = showMoreMenu,
|
||||||
|
onDismissRequest = {
|
||||||
|
showHiddenToolsExpanded = false
|
||||||
|
showMoreMenu = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val hiddenToolbarTools = toolOrder.filter { it in pdfToolbarTools && hiddenTools.contains(it.name) }
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.title_customize_toolbar)) },
|
text = { Text(stringResource(R.string.title_customize_toolbar)) },
|
||||||
onClick = { showMoreMenu = false; onShowCustomizeTools() },
|
onClick = { showMoreMenu = false; onShowCustomizeTools() },
|
||||||
|
|
@ -212,6 +305,47 @@ internal fun PdfTopBar(
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
|
if (hiddenToolbarTools.isNotEmpty()) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Hidden tools") },
|
||||||
|
onClick = { showHiddenToolsExpanded = !showHiddenToolsExpanded },
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.rotate(if (showHiddenToolsExpanded) 180f else 0f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (showHiddenToolsExpanded) {
|
||||||
|
hiddenToolbarTools.forEach { tool ->
|
||||||
|
HiddenPdfToolMenuItem(
|
||||||
|
tool = tool,
|
||||||
|
isTtsPlayingOrLoading = isTtsPlayingOrLoading,
|
||||||
|
showAllTextHighlights = showAllTextHighlights,
|
||||||
|
isHighlightingLoading = isHighlightingLoading,
|
||||||
|
isEditMode = isEditMode,
|
||||||
|
isTtsSessionActive = isTtsSessionActive,
|
||||||
|
closeMenu = {
|
||||||
|
showHiddenToolsExpanded = false
|
||||||
|
showMoreMenu = false
|
||||||
|
},
|
||||||
|
onShowThemePanel = onShowThemePanel,
|
||||||
|
onToggleScrollLock = onToggleScrollLock,
|
||||||
|
onShowDictionarySettings = onShowDictionarySettings,
|
||||||
|
onShowSlider = onShowSlider,
|
||||||
|
onShowToc = onShowToc,
|
||||||
|
onSearchClick = onSearchClick,
|
||||||
|
onToggleHighlights = onToggleHighlights,
|
||||||
|
onShowAiHub = onShowAiHub,
|
||||||
|
onToggleEditMode = onToggleEditMode,
|
||||||
|
onToggleTts = onToggleTts
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
|
if (BuildConfig.IS_PRO && !hiddenTools.contains(PdfReaderTool.OCR_LANGUAGE.name)) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.menu_ocr_language)) },
|
text = { Text(stringResource(R.string.menu_ocr_language)) },
|
||||||
|
|
@ -402,6 +536,72 @@ internal fun PdfTopBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HiddenPdfToolMenuItem(
|
||||||
|
tool: PdfReaderTool,
|
||||||
|
isTtsPlayingOrLoading: Boolean,
|
||||||
|
showAllTextHighlights: Boolean,
|
||||||
|
isHighlightingLoading: Boolean,
|
||||||
|
isEditMode: Boolean,
|
||||||
|
isTtsSessionActive: Boolean,
|
||||||
|
closeMenu: () -> Unit,
|
||||||
|
onShowThemePanel: () -> Unit,
|
||||||
|
onToggleScrollLock: () -> Unit,
|
||||||
|
onShowDictionarySettings: () -> Unit,
|
||||||
|
onShowSlider: () -> Unit,
|
||||||
|
onShowToc: () -> Unit,
|
||||||
|
onSearchClick: () -> Unit,
|
||||||
|
onToggleHighlights: () -> Unit,
|
||||||
|
onShowAiHub: () -> Unit,
|
||||||
|
onToggleEditMode: () -> Unit,
|
||||||
|
onToggleTts: () -> Unit
|
||||||
|
) {
|
||||||
|
val enabled = when (tool) {
|
||||||
|
PdfReaderTool.SLIDER,
|
||||||
|
PdfReaderTool.TOC,
|
||||||
|
PdfReaderTool.SEARCH -> !isTtsPlayingOrLoading
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(tool.title) },
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = {
|
||||||
|
closeMenu()
|
||||||
|
when (tool) {
|
||||||
|
PdfReaderTool.THEME -> onShowThemePanel()
|
||||||
|
PdfReaderTool.LOCK_PANNING -> onToggleScrollLock()
|
||||||
|
PdfReaderTool.DICTIONARY -> onShowDictionarySettings()
|
||||||
|
PdfReaderTool.SLIDER -> onShowSlider()
|
||||||
|
PdfReaderTool.TOC -> onShowToc()
|
||||||
|
PdfReaderTool.SEARCH -> onSearchClick()
|
||||||
|
PdfReaderTool.HIGHLIGHT_ALL -> onToggleHighlights()
|
||||||
|
PdfReaderTool.AI_FEATURES -> onShowAiHub()
|
||||||
|
PdfReaderTool.EDIT_MODE -> onToggleEditMode()
|
||||||
|
PdfReaderTool.TTS_CONTROLS -> onToggleTts()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
when (tool) {
|
||||||
|
PdfReaderTool.DICTIONARY -> Icon(painterResource(id = R.drawable.dictionary), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.THEME -> Icon(painterResource(id = R.drawable.palette), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.LOCK_PANNING -> Icon(Icons.Default.LockOpen, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.SLIDER -> Icon(painterResource(id = R.drawable.slider), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.TOC -> Icon(Icons.Default.Menu, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.HIGHLIGHT_ALL -> {
|
||||||
|
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(20.dp))
|
||||||
|
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
PdfReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
PdfReaderTool.EDIT_MODE -> Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
PdfReaderTool.TTS_CONTROLS -> Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
else -> Icon(Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ReflowProgressOverlay(
|
fun ReflowProgressOverlay(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -447,6 +647,89 @@ fun ReflowProgressOverlay(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PdfJumpHistoryBar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showStandardBars: Boolean,
|
||||||
|
searchStateActive: Boolean,
|
||||||
|
backPage: Int?,
|
||||||
|
forwardPage: Int?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onForward: () -> Unit,
|
||||||
|
onClear: () -> Unit
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showStandardBars && !searchStateActive && (backPage != null || forwardPage != null),
|
||||||
|
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
|
||||||
|
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
tonalElevation = 3.dp
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(40.dp)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onBack,
|
||||||
|
enabled = backPage != null,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_jump_back),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = backPage?.let { stringResource(R.string.pdf_page_short, it + 1) } ?: "",
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onClear,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.action_clear),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.action_clear), maxLines = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onForward,
|
||||||
|
enabled = forwardPage != null,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = forwardPage?.let { stringResource(R.string.pdf_page_short, it + 1) } ?: "",
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_jump_forward),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PdfBottomBar(
|
fun PdfBottomBar(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -455,14 +738,17 @@ fun PdfBottomBar(
|
||||||
systemUiMode: SystemUiMode,
|
systemUiMode: SystemUiMode,
|
||||||
navBarHeightDp: Dp,
|
navBarHeightDp: Dp,
|
||||||
hiddenTools: Set<String>,
|
hiddenTools: Set<String>,
|
||||||
|
toolOrder: List<PdfReaderTool>,
|
||||||
|
bottomTools: Set<String>,
|
||||||
isTtsPlayingOrLoading: Boolean,
|
isTtsPlayingOrLoading: Boolean,
|
||||||
showAllTextHighlights: Boolean,
|
showAllTextHighlights: Boolean,
|
||||||
isHighlightingLoading: Boolean,
|
isHighlightingLoading: Boolean,
|
||||||
isEditMode: Boolean,
|
isEditMode: Boolean,
|
||||||
isTtsSessionActive: Boolean,
|
isTtsSessionActive: Boolean,
|
||||||
ttsErrorMessage: String?,
|
ttsErrorMessage: String?,
|
||||||
jumpBackPage: Int?,
|
onShowThemePanel: () -> Unit,
|
||||||
onJumpBack: () -> Unit,
|
onToggleScrollLock: () -> Unit,
|
||||||
|
onShowDictionarySettings: () -> Unit,
|
||||||
onShowSlider: () -> Unit,
|
onShowSlider: () -> Unit,
|
||||||
onShowToc: () -> Unit,
|
onShowToc: () -> Unit,
|
||||||
onSearchClick: () -> Unit,
|
onSearchClick: () -> Unit,
|
||||||
|
|
@ -490,35 +776,32 @@ fun PdfBottomBar(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
if (jumpBackPage != null) {
|
toolOrder
|
||||||
TooltipIconButton(
|
.filter { it in pdfToolbarTools && bottomTools.contains(it.name) && !hiddenTools.contains(it.name) }
|
||||||
text = stringResource(R.string.action_jump_back_to_page, jumpBackPage + 1),
|
.forEach { tool ->
|
||||||
description = stringResource(R.string.desc_return_to_previous_page),
|
when (tool) {
|
||||||
onClick = onJumpBack
|
PdfReaderTool.THEME -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_theme),
|
||||||
|
description = stringResource(R.string.tooltip_theme_desc),
|
||||||
|
onClick = onShowThemePanel
|
||||||
) {
|
) {
|
||||||
Column(
|
Icon(painterResource(id = R.drawable.palette), contentDescription = stringResource(R.string.tooltip_theme_desc), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
}
|
||||||
verticalArrangement = Arrangement.Center
|
PdfReaderTool.LOCK_PANNING -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_lock_pan),
|
||||||
|
description = stringResource(R.string.tooltip_lock_pan_desc),
|
||||||
|
onClick = onToggleScrollLock
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(Icons.Default.LockOpen, contentDescription = stringResource(R.string.tooltip_lock_pan), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Icons.AutoMirrored.Filled.Undo,
|
|
||||||
contentDescription = stringResource(R.string.content_desc_jump_back),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${jumpBackPage + 1}",
|
|
||||||
fontSize = 10.sp,
|
|
||||||
lineHeight = 10.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
PdfReaderTool.DICTIONARY -> TooltipIconButton(
|
||||||
|
text = stringResource(R.string.tooltip_dictionary),
|
||||||
|
description = stringResource(R.string.tooltip_dictionary_desc),
|
||||||
|
onClick = onShowDictionarySettings
|
||||||
|
) {
|
||||||
|
Icon(painterResource(id = R.drawable.dictionary), contentDescription = stringResource(R.string.content_desc_dictionary_settings), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
PdfReaderTool.SLIDER -> TooltipIconButton(
|
||||||
|
|
||||||
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
|
|
||||||
TooltipIconButton(
|
|
||||||
text = stringResource(R.string.tooltip_slider),
|
text = stringResource(R.string.tooltip_slider),
|
||||||
description = stringResource(R.string.tooltip_slider_desc),
|
description = stringResource(R.string.tooltip_slider_desc),
|
||||||
onClick = onShowSlider,
|
onClick = onShowSlider,
|
||||||
|
|
@ -526,9 +809,7 @@ fun PdfBottomBar(
|
||||||
) {
|
) {
|
||||||
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))
|
||||||
}
|
}
|
||||||
}
|
PdfReaderTool.TOC -> TooltipIconButton(
|
||||||
if (!hiddenTools.contains(PdfReaderTool.TOC.name)) {
|
|
||||||
TooltipIconButton(
|
|
||||||
text = stringResource(R.string.tooltip_toc),
|
text = stringResource(R.string.tooltip_toc),
|
||||||
description = stringResource(R.string.tooltip_toc_desc),
|
description = stringResource(R.string.tooltip_toc_desc),
|
||||||
onClick = onShowToc,
|
onClick = onShowToc,
|
||||||
|
|
@ -537,10 +818,7 @@ fun PdfBottomBar(
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
|
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.content_desc_table_of_contents))
|
||||||
}
|
}
|
||||||
}
|
PdfReaderTool.SEARCH -> TooltipIconButton(
|
||||||
|
|
||||||
if (!hiddenTools.contains(PdfReaderTool.SEARCH.name)) {
|
|
||||||
TooltipIconButton(
|
|
||||||
text = stringResource(R.string.tooltip_search),
|
text = stringResource(R.string.tooltip_search),
|
||||||
description = stringResource(R.string.tooltip_search_desc),
|
description = stringResource(R.string.tooltip_search_desc),
|
||||||
onClick = onSearchClick,
|
onClick = onSearchClick,
|
||||||
|
|
@ -549,9 +827,7 @@ fun PdfBottomBar(
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
|
Icon(Icons.Default.Search, contentDescription = stringResource(R.string.action_search))
|
||||||
}
|
}
|
||||||
}
|
PdfReaderTool.HIGHLIGHT_ALL -> TooltipIconButton(
|
||||||
if (!hiddenTools.contains(PdfReaderTool.HIGHLIGHT_ALL.name)) {
|
|
||||||
TooltipIconButton(
|
|
||||||
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
|
text = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off) else stringResource(R.string.tooltip_highlights),
|
||||||
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
|
description = if (showAllTextHighlights) stringResource(R.string.tooltip_highlights_off_desc) else stringResource(R.string.tooltip_highlights_desc),
|
||||||
onClick = onToggleHighlights
|
onClick = onToggleHighlights
|
||||||
|
|
@ -559,9 +835,7 @@ fun PdfBottomBar(
|
||||||
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
|
if (isHighlightingLoading) CircularProgressIndicator(Modifier.size(24.dp))
|
||||||
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
else Icon(painterResource(id = R.drawable.highlight_text), contentDescription = stringResource(R.string.content_desc_highlight_all_text), tint = if (showAllTextHighlights) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
PdfReaderTool.AI_FEATURES -> if (areReaderAiFeaturesEnabled(LocalContext.current)) {
|
||||||
|
|
||||||
if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) {
|
|
||||||
TooltipIconButton(
|
TooltipIconButton(
|
||||||
text = stringResource(R.string.tooltip_ai),
|
text = stringResource(R.string.tooltip_ai),
|
||||||
description = stringResource(R.string.tooltip_ai_desc),
|
description = stringResource(R.string.tooltip_ai_desc),
|
||||||
|
|
@ -570,25 +844,22 @@ fun PdfBottomBar(
|
||||||
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
|
Icon(painterResource(id = R.drawable.ai), contentDescription = stringResource(R.string.tooltip_ai))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PdfReaderTool.EDIT_MODE -> TooltipIconButton(
|
||||||
if (!hiddenTools.contains(PdfReaderTool.EDIT_MODE.name)) {
|
|
||||||
TooltipIconButton(
|
|
||||||
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
|
text = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit) else stringResource(R.string.tooltip_edit_mode),
|
||||||
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
|
description = if (isEditMode) stringResource(R.string.tooltip_edit_mode_exit_desc) else stringResource(R.string.tooltip_edit_mode_desc),
|
||||||
onClick = onToggleEditMode
|
onClick = onToggleEditMode
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.content_desc_toggle_editing_mode), tint = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
PdfReaderTool.TTS_CONTROLS -> TooltipIconButton(
|
||||||
|
|
||||||
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
|
|
||||||
TooltipIconButton(
|
|
||||||
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
|
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) else stringResource(R.string.tooltip_tts_start),
|
||||||
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
|
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) else stringResource(R.string.tooltip_tts_start_desc),
|
||||||
onClick = onToggleTts
|
onClick = onToggleTts
|
||||||
) {
|
) {
|
||||||
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech), contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts), tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (BuildConfig.FLAVOR != "oss") {
|
if (BuildConfig.FLAVOR != "oss") {
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,7 @@ internal fun PdfVerticalReader(
|
||||||
state: VerticalPdfReaderState,
|
state: VerticalPdfReaderState,
|
||||||
pdfDocument: StableHolder<ReaderDocument>,
|
pdfDocument: StableHolder<ReaderDocument>,
|
||||||
activeTheme: com.aryan.reader.ReaderTheme,
|
activeTheme: com.aryan.reader.ReaderTheme,
|
||||||
|
activeTextureAlpha: Float = 0.55f,
|
||||||
excludeImages: Boolean = false,
|
excludeImages: Boolean = false,
|
||||||
totalPages: Int,
|
totalPages: Int,
|
||||||
virtualPages: List<VirtualPage> = emptyList(),
|
virtualPages: List<VirtualPage> = emptyList(),
|
||||||
|
|
@ -988,7 +989,7 @@ internal fun PdfVerticalReader(
|
||||||
}
|
}
|
||||||
|
|
||||||
val buttons = currentEvent.buttons
|
val buttons = currentEvent.buttons
|
||||||
Timber.tag("StylusEraserDiagnostic").d(
|
Timber.tag("StylusDebug").d(
|
||||||
"VerticalReader | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
|
"VerticalReader | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1688,6 +1689,7 @@ internal fun PdfVerticalReader(
|
||||||
virtualPage = virtualPage,
|
virtualPage = virtualPage,
|
||||||
totalPages = totalPages,
|
totalPages = totalPages,
|
||||||
activeTheme = activeTheme,
|
activeTheme = activeTheme,
|
||||||
|
activeTextureAlpha = activeTextureAlpha,
|
||||||
excludeImages = excludeImages,
|
excludeImages = excludeImages,
|
||||||
externalScale = highResScale,
|
externalScale = highResScale,
|
||||||
onScaleChanged = {},
|
onScaleChanged = {},
|
||||||
|
|
|
||||||
120
app/src/main/java/com/aryan/reader/pdf/PdfiumEngineProvider.kt
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
package com.aryan.reader.pdf
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.pdf.PdfiumBridge
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
internal object PdfiumEngineProvider {
|
||||||
|
private val pdfiumMutex = Mutex()
|
||||||
|
|
||||||
|
val bridge: PdfiumBridge
|
||||||
|
get() = AndroidPdfiumBridge
|
||||||
|
|
||||||
|
val lock: Any = this
|
||||||
|
|
||||||
|
suspend fun <T> withPdfium(block: suspend () -> T): T =
|
||||||
|
pdfiumMutex.withLock { block() }
|
||||||
|
|
||||||
|
fun <T> withPdfiumBlocking(block: () -> T): T =
|
||||||
|
runBlocking {
|
||||||
|
pdfiumMutex.withLock { block() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object AndroidPdfiumBridge : PdfiumBridge {
|
||||||
|
override fun getFontSize(textPagePtr: Long, index: Int): Double =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getFontSize(textPagePtr, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFontWeight(textPagePtr: Long, index: Int): Int =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getFontWeight(textPagePtr, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray? =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getPageFontSizes(textPagePtr, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray? =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getPageFontWeights(textPagePtr, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray? =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getPageFontFlags(textPagePtr, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPageCharBoxes(textPagePtr: Long, count: Int): FloatArray? =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getPageCharBoxes(textPagePtr, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAnnotCount(pagePtr: Long): Int =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getAnnotCount(pagePtr)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAnnotSubtype(pagePtr: Long, index: Int): Int =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getAnnotSubtype(pagePtr, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAnnotRect(pagePtr: Long, index: Int): FloatArray? =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getAnnotRect(pagePtr, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAnnotString(pagePtr: Long, index: Int, key: String): String? =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getAnnotString(pagePtr, index, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPageObjectCount(pagePtr: Long): Int =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getPageObjectCount(pagePtr)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPageObjectType(pagePtr: Long, index: Int): Int =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getPageObjectType(pagePtr, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, index, outRect)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray? =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.extractImagePixels(pagePtr, index, dimens)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun performClick(pagePtr: Long, x: Double, y: Double): Boolean =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.performClick(pagePtr, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLinkInfoAtPoint(docPtr: Long, pagePtr: Long, x: Double, y: Double): String? =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getLinkInfoAtPoint(docPtr, pagePtr, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getAnnotSubtypeAtPoint(pagePtr, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray? =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.getAnnotRectAtPoint(pagePtr, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkActionSupport(): Boolean =
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
NativePdfiumBridge.checkActionSupport()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import me.zhanghai.android.libarchive.ArchiveException
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
import androidx.core.graphics.createBitmap
|
import androidx.core.graphics.createBitmap
|
||||||
|
|
||||||
|
|
@ -88,42 +89,97 @@ 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(pdfiumCore.newDocument(pfd, password))
|
PdfDocumentWrapper(PdfiumEngineProvider.withPdfium { pdfiumCore.newDocument(pfd, password) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================= PDF IMPLEMENTATION =================
|
// ================= PDF IMPLEMENTATION =================
|
||||||
|
|
||||||
|
private inline fun closePdfiumResource(tag: String, closeBlock: () -> Unit) {
|
||||||
|
try {
|
||||||
|
closeBlock()
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
if (e.message == "Already closed") {
|
||||||
|
Timber.tag(tag).d(e, "Ignoring duplicate Pdfium close")
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
|
class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
|
||||||
override suspend fun getPageCount() = pdfDocument.getPageCount()
|
private val isClosed = AtomicBoolean(false)
|
||||||
|
|
||||||
|
override suspend fun getPageCount() = PdfiumEngineProvider.withPdfium {
|
||||||
|
pdfDocument.getPageCount()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun openPage(pageIndex: Int): ReaderPage? {
|
override suspend fun openPage(pageIndex: Int): ReaderPage? {
|
||||||
val page = pdfDocument.openPage(pageIndex) ?: return null
|
if (isClosed.get()) return null
|
||||||
|
val page = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) null else pdfDocument.openPage(pageIndex)
|
||||||
|
} ?: return null
|
||||||
return PdfPageWrapper(page)
|
return PdfPageWrapper(page)
|
||||||
}
|
}
|
||||||
override suspend fun getTableOfContents() = pdfDocument.getFixedTableOfContents()
|
|
||||||
override fun close() { pdfDocument.close() }
|
override suspend fun getTableOfContents() = PdfiumEngineProvider.withPdfium {
|
||||||
|
pdfDocument.getFixedTableOfContents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (!isClosed.compareAndSet(false, true)) return
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
closePdfiumResource("PdfDocumentWrapper") { pdfDocument.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
||||||
override suspend fun getPageWidthPoint() = pdfPage.getPageWidthPoint()
|
private val isClosed = AtomicBoolean(false)
|
||||||
override suspend fun getPageHeightPoint() = pdfPage.getPageHeightPoint()
|
|
||||||
override suspend fun getPageRotation() = pdfPage.getPageRotation()
|
override suspend fun getPageWidthPoint() = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) 0 else pdfPage.getPageWidthPoint()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPageHeightPoint() = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) 0 else pdfPage.getPageHeightPoint()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPageRotation() = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) 0 else pdfPage.getPageRotation()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
|
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
|
||||||
|
PdfiumEngineProvider.withPdfium {
|
||||||
|
if (!isClosed.get()) {
|
||||||
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
|
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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) =
|
||||||
pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
|
PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) Rect() else pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int) =
|
override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int) =
|
||||||
pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
|
PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) PointF() else pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun openTextPage(): ReaderTextPage = PdfTextPageWrapper(pdfPage.openTextPage())
|
override suspend fun openTextPage(): ReaderTextPage = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) DummyTextPage() else PdfTextPageWrapper(pdfPage.openTextPage())
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getLinks(): List<ReaderLink> {
|
override suspend fun getLinks(): List<ReaderLink> {
|
||||||
return pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
|
return PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getNativePointer(): Long {
|
override fun getNativePointer(): Long {
|
||||||
|
|
@ -159,29 +215,80 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
||||||
return 0L
|
return 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun close() { pdfPage.close() }
|
override fun close() {
|
||||||
|
if (!isClosed.compareAndSet(false, true)) return
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
closePdfiumResource("PdfPageWrapper") { pdfPage.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
|
class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
|
||||||
override suspend fun textPageCountChars() = textPage.textPageCountChars()
|
private val isClosed = AtomicBoolean(false)
|
||||||
override suspend fun textPageGetText(startIndex: Int, count: Int) = textPage.textPageGetText(startIndex, count)
|
|
||||||
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
|
override suspend fun textPageCountChars() = PdfiumEngineProvider.withPdfium {
|
||||||
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
|
if (isClosed.get()) 0 else textPage.textPageCountChars()
|
||||||
override suspend fun textPageGetCharBox(index: Int) = textPage.textPageGetCharBox(index)
|
}
|
||||||
|
|
||||||
|
override suspend fun textPageGetText(startIndex: Int, count: Int) = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) null else textPage.textPageGetText(startIndex, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) null else textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) -1 else textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun textPageGetCharBox(index: Int) = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) null else textPage.textPageGetCharBox(index)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun textPageGetUnicode(index: Int): Int {
|
override suspend fun textPageGetUnicode(index: Int): Int {
|
||||||
return textPage.textPageGetUnicode(index).code
|
return PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) 0 else textPage.textPageGetUnicode(index).code
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun loadWebLink(): ReaderWebLinks? {
|
override suspend fun loadWebLink(): ReaderWebLinks? {
|
||||||
val links = textPage.loadWebLink() ?: return null
|
val links = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) null else textPage.loadWebLink()
|
||||||
|
} ?: return null
|
||||||
return object : ReaderWebLinks {
|
return object : ReaderWebLinks {
|
||||||
override suspend fun countWebLinks() = links.countWebLinks()
|
private val isClosed = AtomicBoolean(false)
|
||||||
override suspend fun getURL(linkIndex: Int, maxLength: Int) = links.getURL(linkIndex, maxLength)
|
|
||||||
override suspend fun countRects(linkIndex: Int) = links.countRects(linkIndex)
|
override suspend fun countWebLinks() = PdfiumEngineProvider.withPdfium {
|
||||||
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = links.getRect(linkIndex, rectIndex)
|
if (isClosed.get()) 0 else links.countWebLinks()
|
||||||
override fun close() { links.close() }
|
}
|
||||||
|
|
||||||
|
override suspend fun getURL(linkIndex: Int, maxLength: Int) = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) null else links.getURL(linkIndex, maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun countRects(linkIndex: Int) = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) 0 else links.countRects(linkIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = PdfiumEngineProvider.withPdfium {
|
||||||
|
if (isClosed.get()) RectF() else links.getRect(linkIndex, rectIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (!isClosed.compareAndSet(false, true)) return
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
closePdfiumResource("PdfWebLinksWrapper") { links.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun close() {
|
||||||
|
if (!isClosed.compareAndSet(false, true)) return
|
||||||
|
PdfiumEngineProvider.withPdfiumBlocking {
|
||||||
|
closePdfiumResource("PdfTextPageWrapper") { textPage.close() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun close() { textPage.close() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================= CBZ, CBR, CB7 IMPLEMENTATION =================
|
// ================= CBZ, CBR, CB7 IMPLEMENTATION =================
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import androidx.paging.PagingConfig
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.flatMap
|
import androidx.paging.flatMap
|
||||||
import com.aryan.reader.SearchResult
|
import com.aryan.reader.SearchResult
|
||||||
|
import com.aryan.reader.pdf.PdfiumEngineProvider
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
|
@ -171,6 +172,7 @@ class PdfTextRepository(context: Context) {
|
||||||
var ocrUsed = false
|
var ocrUsed = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
PdfiumEngineProvider.withPdfium {
|
||||||
document.openPage(pageIndex)?.use { page ->
|
document.openPage(pageIndex)?.use { page ->
|
||||||
page.openTextPage().use { textPage ->
|
page.openTextPage().use { textPage ->
|
||||||
val count = textPage.textPageCountChars()
|
val count = textPage.textPageCountChars()
|
||||||
|
|
@ -182,12 +184,15 @@ class PdfTextRepository(context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag(TAG).e(e, "Native extraction failed for page $pageIndex")
|
Timber.tag(TAG).e(e, "Native extraction failed for page $pageIndex")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text.isBlank()) {
|
if (text.isBlank()) {
|
||||||
|
var bitmap: android.graphics.Bitmap? = null
|
||||||
try {
|
try {
|
||||||
|
PdfiumEngineProvider.withPdfium {
|
||||||
document.openPage(pageIndex)?.use { page ->
|
document.openPage(pageIndex)?.use { page ->
|
||||||
val targetWidth = 1080
|
val targetWidth = 1080
|
||||||
val ptrWidth = page.getPageWidthPoint()
|
val ptrWidth = page.getPageWidthPoint()
|
||||||
|
|
@ -197,16 +202,23 @@ class PdfTextRepository(context: Context) {
|
||||||
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
|
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
|
||||||
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
|
val 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, onOcrModelDownloading)
|
}
|
||||||
|
}
|
||||||
|
bitmap?.let {
|
||||||
|
try {
|
||||||
|
val visionText = OcrHelper.extractTextFromBitmap(it, onOcrModelDownloading)
|
||||||
text = visionText?.text ?: ""
|
text = visionText?.text ?: ""
|
||||||
bitmap.recycle()
|
|
||||||
ocrUsed = true
|
ocrUsed = true
|
||||||
|
} finally {
|
||||||
|
it.recycle()
|
||||||
|
bitmap = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
bitmap?.recycle()
|
||||||
Timber.tag(TAG).e(e, "OCR failed for page $pageIndex")
|
Timber.tag(TAG).e(e, "OCR failed for page $pageIndex")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -270,11 +282,13 @@ class PdfTextRepository(context: Context) {
|
||||||
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
|
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
PdfiumEngineProvider.withPdfium {
|
||||||
document.openPage(pageIndex)?.use { page ->
|
document.openPage(pageIndex)?.use { page ->
|
||||||
page.openTextPage().use { textPage ->
|
page.openTextPage().use { textPage ->
|
||||||
textPage.textPageCountChars() > 0
|
textPage.textPageCountChars() > 0
|
||||||
}
|
}
|
||||||
} ?: false
|
} ?: false
|
||||||
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import java.io.File
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlinx.coroutines.selects.select
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
|
@ -247,11 +248,16 @@ class BaseTtsSynthesizer(private val context: Context) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
withTimeout(startTimeout) {
|
withTimeout(startTimeout) {
|
||||||
startSignal.await()
|
select {
|
||||||
|
startSignal.onAwait { }
|
||||||
|
resultDeferred.onAwait { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_: TimeoutCancellationException) {
|
} catch (_: TimeoutCancellationException) {
|
||||||
Timber.w("BaseTts: ZOMBIE DETECTED. onStart not received within ${startTimeout}ms.")
|
Timber.w(
|
||||||
throw ZombieEngineException()
|
"BaseTts: onStart not received within ${startTimeout}ms for $utteranceId. " +
|
||||||
|
"Continuing to wait for onDone because some engines omit or delay onStart for file synthesis."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -296,5 +302,4 @@ class BaseTtsSynthesizer(private val context: Context) {
|
||||||
Timber.d("TextToSpeech engine shut down.")
|
Timber.d("TextToSpeech engine shut down.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ZombieEngineException : Exception("Engine failed to start")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import androidx.media3.session.SessionToken
|
||||||
import com.aryan.reader.BuildConfig
|
import com.aryan.reader.BuildConfig
|
||||||
import com.aryan.reader.epubreader.loadTtsPitch
|
import com.aryan.reader.epubreader.loadTtsPitch
|
||||||
import com.aryan.reader.epubreader.loadTtsSpeechRate
|
import com.aryan.reader.epubreader.loadTtsSpeechRate
|
||||||
|
import com.aryan.reader.isByokCloudTtsAvailable
|
||||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
|
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import com.google.common.util.concurrent.MoreExecutors
|
import com.google.common.util.concurrent.MoreExecutors
|
||||||
|
|
@ -72,7 +73,7 @@ fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
|
||||||
val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
|
val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
|
||||||
?: TtsPlaybackManager.TtsMode.BASE.name
|
?: TtsPlaybackManager.TtsMode.BASE.name
|
||||||
|
|
||||||
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank()
|
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank() || isByokCloudTtsAvailable(context)
|
||||||
|
|
||||||
return if (isCloudAllowed) {
|
return if (isCloudAllowed) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -105,8 +106,14 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect() {
|
fun connect() {
|
||||||
if (mediaController != null || controllerFuture != null) return
|
if (mediaController != null || controllerFuture != null) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"TtsController.connect skipped. hasController=${mediaController != null}, hasFuture=${controllerFuture != null}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("TtsController.connect building MediaController.")
|
||||||
val sessionToken = SessionToken(context, ComponentName(context, TtsService::class.java))
|
val sessionToken = SessionToken(context, ComponentName(context, TtsService::class.java))
|
||||||
val future = MediaController.Builder(context, sessionToken).buildAsync()
|
val future = MediaController.Builder(context, sessionToken).buildAsync()
|
||||||
controllerFuture = future
|
controllerFuture = future
|
||||||
|
|
@ -129,10 +136,14 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
|
|
||||||
mediaController?.addListener(this)
|
mediaController?.addListener(this)
|
||||||
Timber.d("MediaController connected.")
|
Timber.d("MediaController connected.")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"MediaController connected. playbackState=${controller.playbackState}, isPlaying=${controller.isPlaying}, mediaItems=${controller.mediaItemCount}, customLayout=${controller.customLayout.size}"
|
||||||
|
)
|
||||||
updateStateFromController()
|
updateStateFromController()
|
||||||
startPolling()
|
startPolling()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.w("Failed to connect MediaController: ${e.message}")
|
Timber.w("Failed to connect MediaController: ${e.message}")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(e, "MediaController connection failed.")
|
||||||
if (controllerFuture == future) {
|
if (controllerFuture == future) {
|
||||||
controllerFuture = null
|
controllerFuture = null
|
||||||
}
|
}
|
||||||
|
|
@ -158,15 +169,21 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
chapterTitle: String?,
|
chapterTitle: String?,
|
||||||
coverImageUri: String?,
|
coverImageUri: String?,
|
||||||
chapterIndex: Int? = null,
|
chapterIndex: Int? = null,
|
||||||
|
totalChapters: Int? = null,
|
||||||
|
continueSession: Boolean = false,
|
||||||
ttsMode: TtsPlaybackManager.TtsMode,
|
ttsMode: TtsPlaybackManager.TtsMode,
|
||||||
playbackSource: String = "READER",
|
playbackSource: String = "READER",
|
||||||
authToken: String? = null
|
authToken: String? = null
|
||||||
) {
|
) {
|
||||||
if (chunks.isEmpty()) {
|
if (chunks.isEmpty()) {
|
||||||
Timber.w("TtsController: start called with empty chunks!")
|
Timber.w("TtsController: start called with empty chunks!")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsController.start aborted because chunks is empty.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Timber.d("UI sending START command with mode: $ttsMode")
|
Timber.d("UI sending START command with mode: $ttsMode")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"TtsController.start. hasController=${mediaController != null}, chunks=${chunks.size}, continueSession=$continueSession, source=$playbackSource, mode=$ttsMode, book='${bookTitle.take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters"
|
||||||
|
)
|
||||||
|
|
||||||
val textList = ArrayList(chunks.map { it.text })
|
val textList = ArrayList(chunks.map { it.text })
|
||||||
val cfiList = ArrayList(chunks.map { it.sourceCfi })
|
val cfiList = ArrayList(chunks.map { it.sourceCfi })
|
||||||
|
|
@ -181,6 +198,8 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
putString(KEY_CHAPTER_TITLE, chapterTitle)
|
putString(KEY_CHAPTER_TITLE, chapterTitle)
|
||||||
putString(KEY_COVER_IMAGE_URI, coverImageUri)
|
putString(KEY_COVER_IMAGE_URI, coverImageUri)
|
||||||
chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) }
|
chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) }
|
||||||
|
totalChapters?.let { putInt(KEY_TOTAL_CHAPTERS, it) }
|
||||||
|
putBoolean(KEY_CONTINUE_SESSION, continueSession)
|
||||||
putString(KEY_TTS_MODE, ttsMode.name)
|
putString(KEY_TTS_MODE, ttsMode.name)
|
||||||
putString(KEY_PLAYBACK_SOURCE, playbackSource)
|
putString(KEY_PLAYBACK_SOURCE, playbackSource)
|
||||||
putString(KEY_AUTH_TOKEN, authToken)
|
putString(KEY_AUTH_TOKEN, authToken)
|
||||||
|
|
@ -188,7 +207,15 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
putFloat("playback_pitch", loadTtsPitch(context))
|
putFloat("playback_pitch", loadTtsPitch(context))
|
||||||
}
|
}
|
||||||
Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $ttsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}")
|
Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $ttsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}")
|
||||||
mediaController?.sendCustomCommand(START_TTS_COMMAND, args)
|
val controller = mediaController
|
||||||
|
if (controller == null) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e("Cannot send START command because MediaController is null.")
|
||||||
|
} else {
|
||||||
|
val result = controller.sendCustomCommand(START_TTS_COMMAND, args)
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"START command sent. playbackState=${controller.playbackState}, isPlaying=${controller.isPlaying}, mediaItems=${controller.mediaItemCount}, resultDone=${result.isDone}"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
|
|
@ -240,6 +267,9 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEvents(player: Player, events: Player.Events) {
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"Controller onEvents. playbackState=${player.playbackState}, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}, events=$events"
|
||||||
|
)
|
||||||
updateStateFromController()
|
updateStateFromController()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,7 +277,9 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
mediaController?.let { controller ->
|
mediaController?.let { controller ->
|
||||||
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 currentTextFromMediaItem = currentMediaItem?.mediaMetadata?.subtitle?.toString()
|
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
|
||||||
|
val currentTextFromMediaItem = mediaItemExtras?.getString("ttsText")
|
||||||
|
?: currentMediaItem?.mediaMetadata?.subtitle?.toString()
|
||||||
val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING
|
val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING
|
||||||
val serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId)
|
val serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId)
|
||||||
val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false)
|
val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false)
|
||||||
|
|
@ -255,9 +287,13 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
val sessionFinished = customState.getBoolean("sessionFinished", false)
|
val sessionFinished = customState.getBoolean("sessionFinished", false)
|
||||||
val playbackSource = customState.getString("playbackSource")
|
val playbackSource = customState.getString("playbackSource")
|
||||||
val serviceBookTitle = customState.getString("bookTitle")
|
val serviceBookTitle = customState.getString("bookTitle")
|
||||||
|
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 serviceCurrentChunkIndex = customState.getInt("currentChunkIndex", -1)
|
||||||
|
val serviceTotalChunks = customState.getInt("totalChunks", 0)
|
||||||
|
val serviceBookProgressPercent = customState.getInt("bookProgressPercent", -1).takeIf { it >= 0 }
|
||||||
|
|
||||||
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
|
|
||||||
val sourceCfi = mediaItemExtras?.getString("sourceCfi")
|
val sourceCfi = mediaItemExtras?.getString("sourceCfi")
|
||||||
val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
|
val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
|
||||||
val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
|
val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
|
||||||
|
|
@ -279,11 +315,24 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
} else {
|
} else {
|
||||||
if (isLoading) currentState.bookTitle else serviceBookTitle
|
if (isLoading) currentState.bookTitle else serviceBookTitle
|
||||||
},
|
},
|
||||||
|
chapterTitle = if (isPlaybackActive || isLoading) {
|
||||||
|
serviceChapterTitle ?: currentState.chapterTitle
|
||||||
|
} else {
|
||||||
|
serviceChapterTitle
|
||||||
|
},
|
||||||
chapterIndex = if (isPlaybackActive || isLoading) {
|
chapterIndex = if (isPlaybackActive || isLoading) {
|
||||||
serviceChapterIndex ?: currentState.chapterIndex
|
serviceChapterIndex ?: currentState.chapterIndex
|
||||||
} else {
|
} else {
|
||||||
serviceChapterIndex
|
serviceChapterIndex
|
||||||
},
|
},
|
||||||
|
totalChapters = if (isPlaybackActive || isLoading) {
|
||||||
|
serviceTotalChapters ?: currentState.totalChapters
|
||||||
|
} else {
|
||||||
|
serviceTotalChapters
|
||||||
|
},
|
||||||
|
currentChunkIndex = serviceCurrentChunkIndex,
|
||||||
|
totalChunks = serviceTotalChunks,
|
||||||
|
bookProgressPercent = serviceBookProgressPercent,
|
||||||
speakerId = serviceSpeaker,
|
speakerId = serviceSpeaker,
|
||||||
sourceCfi = if (isPlaybackActive) {
|
sourceCfi = if (isPlaybackActive) {
|
||||||
sourceCfi
|
sourceCfi
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import androidx.core.net.toUri
|
||||||
import com.aryan.reader.paginatedreader.TimedWord
|
import com.aryan.reader.paginatedreader.TimedWord
|
||||||
import com.aryan.reader.paginatedreader.TtsChunk
|
import com.aryan.reader.paginatedreader.TtsChunk
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", Bundle.EMPTY)
|
val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", Bundle.EMPTY)
|
||||||
val STOP_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.STOP", Bundle.EMPTY)
|
val STOP_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.STOP", Bundle.EMPTY)
|
||||||
|
|
@ -57,6 +58,7 @@ private val STATE_UPDATE_COMMAND = SessionCommand("com.aryan.reader.tts.STATE_UP
|
||||||
val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY)
|
val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY)
|
||||||
val SLICE_CURRENT_AND_RELOAD_COMMAND = SessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD", Bundle.EMPTY)
|
val SLICE_CURRENT_AND_RELOAD_COMMAND = SessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD", Bundle.EMPTY)
|
||||||
val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS", Bundle.EMPTY)
|
val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS", Bundle.EMPTY)
|
||||||
|
const val TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG"
|
||||||
|
|
||||||
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
|
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
|
||||||
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
|
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
|
||||||
|
|
@ -71,6 +73,8 @@ const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS"
|
||||||
const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE"
|
const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE"
|
||||||
const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN"
|
const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN"
|
||||||
const val KEY_CHAPTER_INDEX = "KEY_CHAPTER_INDEX"
|
const val KEY_CHAPTER_INDEX = "KEY_CHAPTER_INDEX"
|
||||||
|
const val KEY_TOTAL_CHAPTERS = "KEY_TOTAL_CHAPTERS"
|
||||||
|
const val KEY_CONTINUE_SESSION = "KEY_CONTINUE_SESSION"
|
||||||
|
|
||||||
private const val PREFETCH_LOOKAHEAD = 3
|
private const val PREFETCH_LOOKAHEAD = 3
|
||||||
|
|
||||||
|
|
@ -102,7 +106,12 @@ class TtsPlaybackManager(
|
||||||
val currentText: String? = null,
|
val currentText: String? = null,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val bookTitle: String? = null,
|
val bookTitle: String? = null,
|
||||||
|
val chapterTitle: String? = null,
|
||||||
val chapterIndex: Int? = null,
|
val chapterIndex: Int? = null,
|
||||||
|
val totalChapters: Int? = null,
|
||||||
|
val currentChunkIndex: Int = -1,
|
||||||
|
val totalChunks: Int = 0,
|
||||||
|
val bookProgressPercent: Int? = null,
|
||||||
val speakerId: String = DEFAULT_SPEAKER_ID,
|
val speakerId: String = DEFAULT_SPEAKER_ID,
|
||||||
val sourceCfi: String? = null,
|
val sourceCfi: String? = null,
|
||||||
val startOffsetInSource: Int = -1,
|
val startOffsetInSource: Int = -1,
|
||||||
|
|
@ -124,6 +133,8 @@ class TtsPlaybackManager(
|
||||||
private var chapterTitle: String? = null
|
private var chapterTitle: String? = null
|
||||||
private var coverImageUri: String? = null
|
private var coverImageUri: String? = null
|
||||||
private var currentTtsMode = TtsMode.CLOUD
|
private var currentTtsMode = TtsMode.CLOUD
|
||||||
|
private var chapterIndex: Int? = null
|
||||||
|
private var totalChapters: Int? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
player.addListener(this)
|
player.addListener(this)
|
||||||
|
|
@ -146,6 +157,9 @@ class TtsPlaybackManager(
|
||||||
session: MediaSession,
|
session: MediaSession,
|
||||||
controller: MediaSession.ControllerInfo
|
controller: MediaSession.ControllerInfo
|
||||||
): MediaSession.ConnectionResult {
|
): MediaSession.ConnectionResult {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"MediaSession onConnect. package=${controller.packageName}, uid=${controller.uid}"
|
||||||
|
)
|
||||||
val availableSessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
|
val availableSessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
|
||||||
.add(START_TTS_COMMAND)
|
.add(START_TTS_COMMAND)
|
||||||
.add(STOP_TTS_COMMAND)
|
.add(STOP_TTS_COMMAND)
|
||||||
|
|
@ -186,6 +200,9 @@ class TtsPlaybackManager(
|
||||||
START_TTS_COMMAND -> {
|
START_TTS_COMMAND -> {
|
||||||
val chunks = args.getStringArrayList(KEY_TEXT_CHUNKS) ?: emptyList()
|
val chunks = args.getStringArrayList(KEY_TEXT_CHUNKS) ?: emptyList()
|
||||||
Timber.d("TtsService: START command received. Size: ${chunks.size}")
|
Timber.d("TtsService: START command received. Size: ${chunks.size}")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"START command received. chunks=${chunks.size}, continueSession=${args.getBoolean(KEY_CONTINUE_SESSION, false)}, source=${args.getString(KEY_PLAYBACK_SOURCE)}, mode=${args.getString(KEY_TTS_MODE)}, chapterIndex=${args.getInt(KEY_CHAPTER_INDEX, -1)}, totalChapters=${args.getInt(KEY_TOTAL_CHAPTERS, -1)}"
|
||||||
|
)
|
||||||
val cfis = args.getStringArrayList(KEY_SOURCE_CFIS)
|
val cfis = args.getStringArrayList(KEY_SOURCE_CFIS)
|
||||||
val offsets = args.getIntegerArrayList(KEY_START_OFFSETS)
|
val offsets = args.getIntegerArrayList(KEY_START_OFFSETS)
|
||||||
val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
|
val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
|
||||||
|
|
@ -193,6 +210,7 @@ class TtsPlaybackManager(
|
||||||
val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
|
val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
|
||||||
val coverImageUri = args.getString(KEY_COVER_IMAGE_URI)
|
val coverImageUri = args.getString(KEY_COVER_IMAGE_URI)
|
||||||
val chapterIndex = args.getInt(KEY_CHAPTER_INDEX, -1).takeIf { it >= 0 }
|
val chapterIndex = args.getInt(KEY_CHAPTER_INDEX, -1).takeIf { it >= 0 }
|
||||||
|
val totalChapters = args.getInt(KEY_TOTAL_CHAPTERS, -1).takeIf { it > 0 }
|
||||||
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
|
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
|
||||||
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
|
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
|
||||||
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
|
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
|
||||||
|
|
@ -208,10 +226,11 @@ class TtsPlaybackManager(
|
||||||
|
|
||||||
val authToken = args.getString(KEY_AUTH_TOKEN)
|
val authToken = args.getString(KEY_AUTH_TOKEN)
|
||||||
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
|
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
|
||||||
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, ttsMode, playbackSource, args)
|
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, totalChapters, ttsMode, playbackSource, args)
|
||||||
}
|
}
|
||||||
STOP_TTS_COMMAND -> {
|
STOP_TTS_COMMAND -> {
|
||||||
Timber.d("Received STOP command.")
|
Timber.d("Received STOP command.")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("STOP command received.")
|
||||||
handleStopTts(userInitiated = true)
|
handleStopTts(userInitiated = true)
|
||||||
}
|
}
|
||||||
CHANGE_SPEAKER_COMMAND -> {
|
CHANGE_SPEAKER_COMMAND -> {
|
||||||
|
|
@ -342,17 +361,20 @@ class TtsPlaybackManager(
|
||||||
chapterTitle: String?,
|
chapterTitle: String?,
|
||||||
coverImageUri: String?,
|
coverImageUri: String?,
|
||||||
chapterIndex: Int?,
|
chapterIndex: Int?,
|
||||||
|
totalChapters: Int?,
|
||||||
ttsMode: TtsMode,
|
ttsMode: TtsMode,
|
||||||
playbackSource: String?,
|
playbackSource: String?,
|
||||||
args: Bundle // Added this parameter
|
args: Bundle // Added this parameter
|
||||||
) {
|
) {
|
||||||
if (chunks.isEmpty()) {
|
if (chunks.isEmpty()) {
|
||||||
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
|
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("handleStartTts aborted because chunks is empty.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- YOUR SNIPPET START ---
|
// --- YOUR SNIPPET START ---
|
||||||
val authToken = args.getString(KEY_AUTH_TOKEN)
|
val authToken = args.getString(KEY_AUTH_TOKEN)
|
||||||
|
val continueSession = args.getBoolean(KEY_CONTINUE_SESSION, false)
|
||||||
val speed = args.getFloat("playback_speed", 1f)
|
val speed = args.getFloat("playback_speed", 1f)
|
||||||
val pitch = args.getFloat("playback_pitch", 1f)
|
val pitch = args.getFloat("playback_pitch", 1f)
|
||||||
|
|
||||||
|
|
@ -365,27 +387,54 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
|
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"handleStartTts. continueSession=$continueSession, chunks=${chunks.size}, book='${bookTitle.orEmpty().take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters, mode=$ttsMode, playbackSource=$playbackSource"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!continueSession) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("New TTS session. Calling handleStopTts(clearState=false) before start.")
|
||||||
handleStopTts(clearState = false)
|
handleStopTts(clearState = false)
|
||||||
|
}
|
||||||
|
|
||||||
textChunks = chunks
|
textChunks = chunks
|
||||||
currentSpeakerId = speakerId
|
currentSpeakerId = speakerId
|
||||||
currentTtsMode = ttsMode
|
currentTtsMode = ttsMode
|
||||||
this.bookTitle = bookTitle
|
this.bookTitle = bookTitle
|
||||||
this.chapterTitle = chapterTitle
|
this.chapterTitle = chapterTitle
|
||||||
this.coverImageUri = coverImageUri
|
this.coverImageUri = coverImageUri
|
||||||
|
this.chapterIndex = chapterIndex
|
||||||
|
this.totalChapters = totalChapters
|
||||||
|
|
||||||
onResetContext()
|
|
||||||
loadedChunks.clear()
|
loadedChunks.clear()
|
||||||
lastPrefetchIndex = -1
|
lastPrefetchIndex = -1
|
||||||
|
|
||||||
_ttsState.value = TtsState(
|
_ttsState.value = TtsState(
|
||||||
isLoading = true,
|
isLoading = true,
|
||||||
bookTitle = bookTitle,
|
bookTitle = bookTitle,
|
||||||
|
chapterTitle = chapterTitle,
|
||||||
chapterIndex = chapterIndex,
|
chapterIndex = chapterIndex,
|
||||||
|
totalChapters = totalChapters,
|
||||||
|
currentChunkIndex = -1,
|
||||||
|
totalChunks = chunks.size,
|
||||||
|
bookProgressPercent = calculateBookProgressPercent(-1),
|
||||||
speakerId = speakerId,
|
speakerId = speakerId,
|
||||||
playbackSource = playbackSource,
|
playbackSource = playbackSource,
|
||||||
ttsMode = ttsMode.name
|
ttsMode = ttsMode.name,
|
||||||
|
currentText = if (continueSession) _ttsState.value.currentText else null
|
||||||
)
|
)
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"TTS state set to loading. bookProgress=${_ttsState.value.bookProgressPercent}, currentTextRetained=${_ttsState.value.currentText != null}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (continueSession) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Continuation start. Cancelling prefetch/tracking but keeping player session alive until replacement media is ready.")
|
||||||
|
preparationJob?.cancel()
|
||||||
|
wordTrackingJob?.cancel()
|
||||||
|
prefetchLoopJob?.cancel()
|
||||||
|
prefetchingJobs.values.forEach { it.cancel() }
|
||||||
|
prefetchingJobs.clear()
|
||||||
|
clearPlaylistForContinuation()
|
||||||
|
}
|
||||||
|
|
||||||
currentAuthToken = authToken
|
currentAuthToken = authToken
|
||||||
preparationJob = scope.launch {
|
preparationJob = scope.launch {
|
||||||
|
|
@ -411,6 +460,73 @@ class TtsPlaybackManager(
|
||||||
Timber.d("Speaker changed to $newSpeakerId (pending next start)")
|
Timber.d("Speaker changed to $newSpeakerId (pending next start)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun currentChunkIndexFromPlayer(): Int {
|
||||||
|
return player.currentMediaItem?.mediaId?.toIntOrNull()
|
||||||
|
?: player.currentMediaItemIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateBookProgressPercent(chunkIndex: Int): Int? {
|
||||||
|
val chapter = chapterIndex ?: return null
|
||||||
|
val chapterCount = totalChapters?.takeIf { it > 0 } ?: return null
|
||||||
|
val safeChunkProgress = if (textChunks.isNotEmpty() && chunkIndex >= 0) {
|
||||||
|
((chunkIndex + 1).toDouble() / textChunks.size.toDouble()).coerceIn(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
return (((chapter.toDouble() + safeChunkProgress) / chapterCount.toDouble()) * 100.0)
|
||||||
|
.roundToInt()
|
||||||
|
.coerceIn(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markSessionFinishedNaturally(chunkIndex: Int) {
|
||||||
|
val currentState = _ttsState.value
|
||||||
|
if (currentState.isLoading && currentState.currentChunkIndex == -1) {
|
||||||
|
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
|
||||||
|
"Ignoring stale streamed completion while a continuation session is loading."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val safeChunkIndex = if (textChunks.isNotEmpty()) {
|
||||||
|
chunkIndex.coerceIn(0, textChunks.lastIndex)
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i(
|
||||||
|
"Setting sessionFinished = true for naturally completed streamed TTS. chunk=$safeChunkIndex, totalChunks=${textChunks.size}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_ttsState.value = _ttsState.value.copy(
|
||||||
|
isPlaying = false,
|
||||||
|
isLoading = false,
|
||||||
|
currentChunkIndex = safeChunkIndex,
|
||||||
|
totalChunks = textChunks.size,
|
||||||
|
bookProgressPercent = calculateBookProgressPercent(safeChunkIndex),
|
||||||
|
currentWordSourceCfi = null,
|
||||||
|
currentWordStartOffset = -1,
|
||||||
|
sessionFinished = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearPlaylistForContinuation() {
|
||||||
|
val filesToDelete = audioFiles.values.toList()
|
||||||
|
val streamsToRemove = chunkStreamIds.values.toList()
|
||||||
|
audioFiles.clear()
|
||||||
|
chunkStreamIds.clear()
|
||||||
|
loadedChunks.clear()
|
||||||
|
|
||||||
|
lastPrefetchIndex = -1
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"Cleared continuation temp resources. oldFiles=${filesToDelete.size}, oldStreams=${streamsToRemove.size}"
|
||||||
|
)
|
||||||
|
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
filesToDelete.forEach { deleteTempFile(it) }
|
||||||
|
streamsToRemove.forEach { StreamRegistry.remove(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) {
|
private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) {
|
||||||
val firstChunk = textChunks.getOrNull(startAtIndex)
|
val firstChunk = textChunks.getOrNull(startAtIndex)
|
||||||
if (firstChunk == null) {
|
if (firstChunk == null) {
|
||||||
|
|
@ -420,6 +536,9 @@ class TtsPlaybackManager(
|
||||||
|
|
||||||
val chunkStartTime = System.currentTimeMillis()
|
val chunkStartTime = System.currentTimeMillis()
|
||||||
Timber.tag("TTS_CLOUD_DIAG").i("Starting audio generation for first chunk (index=$startAtIndex).")
|
Timber.tag("TTS_CLOUD_DIAG").i("Starting audio generation for first chunk (index=$startAtIndex).")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"Preparing first chunk. startAtIndex=$startAtIndex, playWhenReady=$playWhenReady"
|
||||||
|
)
|
||||||
|
|
||||||
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, firstChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
|
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, firstChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
|
||||||
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
|
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
|
||||||
|
|
@ -464,17 +583,30 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
player.playWhenReady = playWhenReady
|
player.playWhenReady = playWhenReady
|
||||||
Timber.tag("TTS_CLOUD_DIAG").i("ExoPlayer setMediaItem & prepare called in ${System.currentTimeMillis() - prepStartTime}ms")
|
Timber.tag("TTS_CLOUD_DIAG").i("ExoPlayer setMediaItem & prepare called in ${System.currentTimeMillis() - prepStartTime}ms")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"Player prepared for TTS. mediaId=${mediaItem.mediaId}, title='${mediaItem.mediaMetadata.title}', playWhenReady=${player.playWhenReady}, playbackState=${player.playbackState}, mediaItems=${player.mediaItemCount}"
|
||||||
|
)
|
||||||
_ttsState.value = _ttsState.value.copy(
|
_ttsState.value = _ttsState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isPlaying = playWhenReady,
|
isPlaying = playWhenReady,
|
||||||
currentText = serverText,
|
currentText = serverText,
|
||||||
|
chapterTitle = chapterTitle,
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
|
totalChapters = totalChapters,
|
||||||
|
currentChunkIndex = startAtIndex,
|
||||||
|
totalChunks = textChunks.size,
|
||||||
|
bookProgressPercent = calculateBookProgressPercent(startAtIndex),
|
||||||
|
sessionFinished = false,
|
||||||
sourceCfi = updatedChunk.sourceCfi,
|
sourceCfi = updatedChunk.sourceCfi,
|
||||||
startOffsetInSource = updatedChunk.startOffsetInSource
|
startOffsetInSource = updatedChunk.startOffsetInSource
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
prefetchNextChunkAudio(startAtIndex)
|
prefetchNextChunkAudio(startAtIndex)
|
||||||
} else {
|
} else {
|
||||||
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.")
|
_ttsState.value = _ttsState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = ttsAudioData.error ?: "Failed to load audio."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,6 +641,9 @@ class TtsPlaybackManager(
|
||||||
|
|
||||||
private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) {
|
private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) {
|
||||||
Timber.tag("TTS_CLOUD_DIAG").d("handleStopTts called. clearState=$clearState, userInitiated=$userInitiated")
|
Timber.tag("TTS_CLOUD_DIAG").d("handleStopTts called. clearState=$clearState, userInitiated=$userInitiated")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"handleStopTts. clearState=$clearState, userInitiated=$userInitiated"
|
||||||
|
)
|
||||||
onResetContext()
|
onResetContext()
|
||||||
preparationJob?.cancel()
|
preparationJob?.cancel()
|
||||||
wordTrackingJob?.cancel()
|
wordTrackingJob?.cancel()
|
||||||
|
|
@ -527,6 +662,11 @@ class TtsPlaybackManager(
|
||||||
player.stop()
|
player.stop()
|
||||||
player.clearMediaItems()
|
player.clearMediaItems()
|
||||||
textChunks = emptyList()
|
textChunks = emptyList()
|
||||||
|
bookTitle = null
|
||||||
|
chapterTitle = null
|
||||||
|
coverImageUri = null
|
||||||
|
chapterIndex = null
|
||||||
|
totalChapters = null
|
||||||
lastPrefetchIndex = -1
|
lastPrefetchIndex = -1
|
||||||
prefetchLoopJob?.cancel()
|
prefetchLoopJob?.cancel()
|
||||||
prefetchingJobs.values.forEach { it.cancel() }
|
prefetchingJobs.values.forEach { it.cancel() }
|
||||||
|
|
@ -541,17 +681,27 @@ class TtsPlaybackManager(
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
val newPlaylistIndex = player.currentMediaItemIndex
|
val newPlaylistIndex = player.currentMediaItemIndex
|
||||||
Timber.tag("TTS_CLOUD_DIAG").d("onMediaItemTransition to playlistIndex: $newPlaylistIndex, mediaId: ${mediaItem?.mediaId}, reason: $reason")
|
Timber.tag("TTS_CLOUD_DIAG").d("onMediaItemTransition to playlistIndex: $newPlaylistIndex, mediaId: ${mediaItem?.mediaId}, reason: $reason")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"onMediaItemTransition. playlistIndex=$newPlaylistIndex, mediaId=${mediaItem?.mediaId}, reason=$reason, title='${mediaItem?.mediaMetadata?.title}', playbackState=${player.playbackState}, isPlaying=${player.isPlaying}"
|
||||||
|
)
|
||||||
if (newPlaylistIndex == C.INDEX_UNSET) return
|
if (newPlaylistIndex == C.INDEX_UNSET) return
|
||||||
|
|
||||||
val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return
|
val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return
|
||||||
|
|
||||||
val newText = mediaItem.mediaMetadata.subtitle?.toString()
|
|
||||||
val extras = mediaItem.mediaMetadata.extras
|
val extras = mediaItem.mediaMetadata.extras
|
||||||
|
val newText = extras?.getString("ttsText") ?: mediaItem.mediaMetadata.subtitle?.toString()
|
||||||
val sourceCfi = extras?.getString("sourceCfi")
|
val sourceCfi = extras?.getString("sourceCfi")
|
||||||
val startOffset = extras?.getInt("startOffset", -1) ?: -1
|
val startOffset = extras?.getInt("startOffset", -1) ?: -1
|
||||||
|
|
||||||
_ttsState.value = _ttsState.value.copy(
|
_ttsState.value = _ttsState.value.copy(
|
||||||
currentText = newText,
|
currentText = newText,
|
||||||
|
chapterTitle = chapterTitle,
|
||||||
|
chapterIndex = chapterIndex,
|
||||||
|
totalChapters = totalChapters,
|
||||||
|
currentChunkIndex = currentChunkIndex,
|
||||||
|
totalChunks = textChunks.size,
|
||||||
|
bookProgressPercent = calculateBookProgressPercent(currentChunkIndex),
|
||||||
|
sessionFinished = false,
|
||||||
sourceCfi = sourceCfi,
|
sourceCfi = sourceCfi,
|
||||||
startOffsetInSource = startOffset
|
startOffsetInSource = startOffset
|
||||||
)
|
)
|
||||||
|
|
@ -582,6 +732,9 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"onIsPlayingChanged. isPlaying=$isPlaying, playbackState=${player.playbackState}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
|
||||||
|
)
|
||||||
var nextState = _ttsState.value.copy(isPlaying = isPlaying)
|
var nextState = _ttsState.value.copy(isPlaying = isPlaying)
|
||||||
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
|
|
@ -599,14 +752,22 @@ class TtsPlaybackManager(
|
||||||
currentWordStartOffset = -1
|
currentWordStartOffset = -1
|
||||||
)
|
)
|
||||||
|
|
||||||
val currentChunkIndex = player.currentMediaItemIndex
|
val currentChunkIndex = currentChunkIndexFromPlayer()
|
||||||
val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1
|
val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1
|
||||||
|
|
||||||
if (player.playbackState == Player.STATE_ENDED) {
|
if (player.playbackState == Player.STATE_ENDED) {
|
||||||
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ExoPlayer STATE_ENDED. currentChunkIndex: $currentChunkIndex, isLastChunk: $isLastChunkInSession, totalChunks: ${textChunks.size}")
|
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ExoPlayer STATE_ENDED. currentChunkIndex: $currentChunkIndex, isLastChunk: $isLastChunkInSession, totalChunks: ${textChunks.size}")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"Player reached ENDED. currentChunkIndex=$currentChunkIndex, isLastChunk=$isLastChunkInSession, totalChunks=${textChunks.size}, sessionFinishedWillBeSet=${isLastChunkInSession || textChunks.isEmpty()}"
|
||||||
|
)
|
||||||
if (isLastChunkInSession || textChunks.isEmpty()) {
|
if (isLastChunkInSession || textChunks.isEmpty()) {
|
||||||
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("Setting sessionFinished = true")
|
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("Setting sessionFinished = true")
|
||||||
nextState = nextState.copy(sessionFinished = true)
|
nextState = nextState.copy(
|
||||||
|
currentChunkIndex = currentChunkIndex,
|
||||||
|
totalChunks = textChunks.size,
|
||||||
|
bookProgressPercent = calculateBookProgressPercent(currentChunkIndex),
|
||||||
|
sessionFinished = true
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
val nextIdx = currentChunkIndex + 1
|
val nextIdx = currentChunkIndex + 1
|
||||||
val isPrefetching = prefetchingJobs.containsKey(nextIdx)
|
val isPrefetching = prefetchingJobs.containsKey(nextIdx)
|
||||||
|
|
@ -625,6 +786,7 @@ class TtsPlaybackManager(
|
||||||
if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
|
if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
|
||||||
if (!nextState.sessionEndedByStop && !nextState.isLoading && preparationJob?.isActive != true) {
|
if (!nextState.sessionEndedByStop && !nextState.isLoading && preparationJob?.isActive != true) {
|
||||||
Timber.tag("TTS_CLOUD_DIAG").d("Auto-stopping TTS from onIsPlayingChanged (IDLE and not loading)")
|
Timber.tag("TTS_CLOUD_DIAG").d("Auto-stopping TTS from onIsPlayingChanged (IDLE and not loading)")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Auto-stopping from IDLE/not-loading path.")
|
||||||
handleStopTts(userInitiated = true)
|
handleStopTts(userInitiated = true)
|
||||||
} else {
|
} else {
|
||||||
Timber.tag("TTS_CLOUD_DIAG").d("Ignoring STATE_IDLE in onIsPlayingChanged because isLoading=${nextState.isLoading}, preparationJob.isActive=${preparationJob?.isActive}")
|
Timber.tag("TTS_CLOUD_DIAG").d("Ignoring STATE_IDLE in onIsPlayingChanged because isLoading=${nextState.isLoading}, preparationJob.isActive=${preparationJob?.isActive}")
|
||||||
|
|
@ -634,6 +796,7 @@ class TtsPlaybackManager(
|
||||||
|
|
||||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||||
Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}")
|
Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(error, "Player error. code=${error.errorCodeName}, message=${error.message}")
|
||||||
_ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}")
|
_ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}")
|
||||||
handleStopTts(userInitiated = true)
|
handleStopTts(userInitiated = true)
|
||||||
}
|
}
|
||||||
|
|
@ -719,10 +882,13 @@ class TtsPlaybackManager(
|
||||||
player.addMediaItem(insertPosition, nextMediaItem)
|
player.addMediaItem(insertPosition, nextMediaItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && targetIndex == player.currentMediaItemIndex + 1) {
|
val currentChunkIndex = currentChunkIndexFromPlayer()
|
||||||
|
val isImmediateNextChunk = targetIndex == currentChunkIndex + 1
|
||||||
|
|
||||||
|
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) {
|
||||||
player.seekToNextMediaItem()
|
player.seekToNextMediaItem()
|
||||||
player.play()
|
player.play()
|
||||||
} else if (wasLoading && targetIndex == player.currentMediaItemIndex + 1) {
|
} else if (wasLoading && isImmediateNextChunk) {
|
||||||
_ttsState.value = _ttsState.value.copy(isLoading = false)
|
_ttsState.value = _ttsState.value.copy(isLoading = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -768,7 +934,10 @@ class TtsPlaybackManager(
|
||||||
if (player.hasNextMediaItem()) {
|
if (player.hasNextMediaItem()) {
|
||||||
player.seekToNextMediaItem()
|
player.seekToNextMediaItem()
|
||||||
} else {
|
} else {
|
||||||
player.stop()
|
val finishedChunkIndex = currentMediaItem.mediaId.toIntOrNull()
|
||||||
|
?: currentChunkIndexFromPlayer()
|
||||||
|
markSessionFinishedNaturally(finishedChunkIndex)
|
||||||
|
player.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -811,7 +980,37 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem {
|
private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem {
|
||||||
|
val progress = calculateBookProgressPercent(index)
|
||||||
|
val chunkLabel = if (textChunks.isNotEmpty()) {
|
||||||
|
"Chunk ${index + 1}/${textChunks.size}"
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val chapterLabel = buildString {
|
||||||
|
val chapter = chapterIndex
|
||||||
|
val chapterCount = totalChapters
|
||||||
|
if (chapter != null && chapterCount != null) {
|
||||||
|
append("Chapter ${chapter + 1} of $chapterCount")
|
||||||
|
if (!chapterTitle.isNullOrBlank()) append(": $chapterTitle")
|
||||||
|
} else if (!chapterTitle.isNullOrBlank()) {
|
||||||
|
append(chapterTitle)
|
||||||
|
}
|
||||||
|
if (progress != null) {
|
||||||
|
if (isNotEmpty()) append(" - ")
|
||||||
|
append("$progress%")
|
||||||
|
}
|
||||||
|
if (chunkLabel != null) {
|
||||||
|
if (isNotEmpty()) append(" - ")
|
||||||
|
append(chunkLabel)
|
||||||
|
}
|
||||||
|
}.ifBlank { chapterTitle ?: chunkLabel ?: "TTS" }
|
||||||
|
val chunkPreview = text
|
||||||
|
.replace(Regex("\\s+"), " ")
|
||||||
|
.trim()
|
||||||
|
.take(180)
|
||||||
|
|
||||||
val extras = Bundle().apply {
|
val extras = Bundle().apply {
|
||||||
|
putString("ttsText", text)
|
||||||
putString("sourceCfi", chunk.sourceCfi)
|
putString("sourceCfi", chunk.sourceCfi)
|
||||||
putInt("startOffset", chunk.startOffsetInSource)
|
putInt("startOffset", chunk.startOffsetInSource)
|
||||||
if (chunk.timedWords.isNotEmpty()) {
|
if (chunk.timedWords.isNotEmpty()) {
|
||||||
|
|
@ -823,9 +1022,11 @@ class TtsPlaybackManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
val metadata = MediaMetadata.Builder()
|
val metadata = MediaMetadata.Builder()
|
||||||
.setArtist(bookTitle)
|
.setTitle(bookTitle ?: chapterLabel)
|
||||||
.setTitle(chapterTitle)
|
.setDisplayTitle(bookTitle ?: chapterLabel)
|
||||||
.setSubtitle(text)
|
.setArtist(chapterLabel)
|
||||||
|
.setSubtitle(chunkPreview)
|
||||||
|
.setDescription(chunkPreview)
|
||||||
.setArtworkUri(coverImageUri?.toUri())
|
.setArtworkUri(coverImageUri?.toUri())
|
||||||
.setTrackNumber(index + 1)
|
.setTrackNumber(index + 1)
|
||||||
.setTotalTrackCount(textChunks.size)
|
.setTotalTrackCount(textChunks.size)
|
||||||
|
|
@ -865,7 +1066,12 @@ class TtsPlaybackManager(
|
||||||
putBoolean("isLoading", state.isLoading)
|
putBoolean("isLoading", state.isLoading)
|
||||||
putString("errorMessage", state.errorMessage)
|
putString("errorMessage", state.errorMessage)
|
||||||
putString("bookTitle", state.bookTitle)
|
putString("bookTitle", state.bookTitle)
|
||||||
|
putString("chapterTitle", state.chapterTitle)
|
||||||
putInt("chapterIndex", state.chapterIndex ?: -1)
|
putInt("chapterIndex", state.chapterIndex ?: -1)
|
||||||
|
putInt("totalChapters", state.totalChapters ?: -1)
|
||||||
|
putInt("currentChunkIndex", state.currentChunkIndex)
|
||||||
|
putInt("totalChunks", state.totalChunks)
|
||||||
|
putInt("bookProgressPercent", state.bookProgressPercent ?: -1)
|
||||||
putString("speakerId", state.speakerId)
|
putString("speakerId", state.speakerId)
|
||||||
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
|
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
|
||||||
putString("currentWordSourceCfi", state.currentWordSourceCfi)
|
putString("currentWordSourceCfi", state.currentWordSourceCfi)
|
||||||
|
|
@ -905,5 +1111,8 @@ class TtsPlaybackManager(
|
||||||
else -> "UNKNOWN"
|
else -> "UNKNOWN"
|
||||||
}
|
}
|
||||||
Timber.tag("TTS_CLOUD_DIAG").d("ExoPlayer playback state changed: $stateName")
|
Timber.tag("TTS_CLOUD_DIAG").d("ExoPlayer playback state changed: $stateName")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"onPlaybackStateChanged. state=$stateName, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import androidx.media3.session.MediaSessionService
|
import androidx.media3.session.MediaSessionService
|
||||||
|
import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL
|
||||||
|
import com.aryan.reader.isByokCloudTtsAvailable
|
||||||
|
import com.aryan.reader.loadAiByokSettings
|
||||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
|
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -236,16 +239,31 @@ class TtsService : MediaSessionService() {
|
||||||
private lateinit var cacheManager: TtsCacheManager
|
private lateinit var cacheManager: TtsCacheManager
|
||||||
|
|
||||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||||
|
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||||
|
val playerState = if (::player.isInitialized) {
|
||||||
|
"playbackState=${player.playbackState}, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
|
||||||
|
} else {
|
||||||
|
"player=uninitialized"
|
||||||
|
}
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"onUpdateNotification called. startInForegroundRequired=$startInForegroundRequired, hasPostNotifications=$hasNotificationPermission, $playerState"
|
||||||
|
)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
if (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) {
|
||||||
|
|
||||||
if (startInForegroundRequired) {
|
if (startInForegroundRequired) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing while foreground is required. Calling stopSelf().")
|
||||||
stopSelf()
|
stopSelf()
|
||||||
|
} else {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing. Skipping notification update.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.")
|
||||||
super.onUpdateNotification(session, startInForegroundRequired)
|
super.onUpdateNotification(session, startInForegroundRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,7 +297,12 @@ class TtsService : MediaSessionService() {
|
||||||
data class Error(val message: String) : GeminiWsEvent()
|
data class Error(val message: String) : GeminiWsEvent()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun ensureConnected(serverUrl: String, speaker: String, authToken: String?) = connectionMutex.withLock {
|
suspend fun ensureConnected(
|
||||||
|
serverUrl: String,
|
||||||
|
speaker: String,
|
||||||
|
authToken: String?,
|
||||||
|
directGeminiApiKey: String? = null
|
||||||
|
) = connectionMutex.withLock {
|
||||||
if (webSocket != null) {
|
if (webSocket != null) {
|
||||||
if (connectedSpeaker == speaker) {
|
if (connectedSpeaker == speaker) {
|
||||||
val isSetup = try { setupDeferred.await() } catch(_: Exception) { false }
|
val isSetup = try { setupDeferred.await() } catch(_: Exception) { false }
|
||||||
|
|
@ -290,11 +313,15 @@ class TtsService : MediaSessionService() {
|
||||||
webSocket = null
|
webSocket = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val url = if (!directGeminiApiKey.isNullOrBlank()) {
|
||||||
|
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$directGeminiApiKey"
|
||||||
|
} else {
|
||||||
val sanitizedUrl = serverUrl.removeSuffix("/")
|
val sanitizedUrl = serverUrl.removeSuffix("/")
|
||||||
val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://")
|
val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://")
|
||||||
val url = "$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}"
|
"$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}"
|
||||||
|
}
|
||||||
|
|
||||||
Timber.tag("TTS_CLOUD_DIAG").d("Connecting to WS: $url")
|
Timber.tag("TTS_CLOUD_DIAG").d("Connecting to WS: ${if (!directGeminiApiKey.isNullOrBlank()) "Gemini BYOK" else url}")
|
||||||
val request = Request.Builder().url(url).build()
|
val request = Request.Builder().url(url).build()
|
||||||
val connectedDeferred = CompletableDeferred<Boolean>()
|
val connectedDeferred = CompletableDeferred<Boolean>()
|
||||||
|
|
||||||
|
|
@ -316,7 +343,7 @@ class TtsService : MediaSessionService() {
|
||||||
|
|
||||||
val setupMsg = JSONObject().apply {
|
val setupMsg = JSONObject().apply {
|
||||||
put("setup", JSONObject().apply {
|
put("setup", JSONObject().apply {
|
||||||
put("model", "models/gemini-3.1-flash-live-preview")
|
put("model", "models/$GEMINI_CLOUD_TTS_MODEL")
|
||||||
put("systemInstruction", JSONObject().apply {
|
put("systemInstruction", JSONObject().apply {
|
||||||
put("parts", org.json.JSONArray().apply {
|
put("parts", org.json.JSONArray().apply {
|
||||||
put(JSONObject().apply {
|
put(JSONObject().apply {
|
||||||
|
|
@ -552,8 +579,17 @@ class TtsService : MediaSessionService() {
|
||||||
TtsAudioData(audioFile = cachedFile, serverText = text, wordTimings = emptyList(), error = null, streamUri = null)
|
TtsAudioData(audioFile = cachedFile, serverText = text, wordTimings = emptyList(), error = null, streamUri = null)
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken)
|
val directGeminiApiKey = if (isByokCloudTtsAvailable(this@TtsService)) {
|
||||||
|
loadAiByokSettings(this@TtsService).geminiKey
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (directGeminiApiKey.isNullOrBlank() && googleCloudWorkerTtsUrl.isBlank()) {
|
||||||
|
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = "Cloud TTS is not configured.")
|
||||||
|
} else {
|
||||||
|
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken, directGeminiApiKey)
|
||||||
liveClient.generateChunk(text, cachedFile)
|
liveClient.generateChunk(text, cachedFile)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("TTS_CLOUD_DIAG").e(e, "Cloud TTS generation failed")
|
Timber.tag("TTS_CLOUD_DIAG").e(e, "Cloud TTS generation failed")
|
||||||
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = e.message ?: "Failed to connect to TTS service")
|
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = e.message ?: "Failed to connect to TTS service")
|
||||||
|
|
@ -567,6 +603,11 @@ class TtsService : MediaSessionService() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Timber.d("TtsService created.")
|
Timber.d("TtsService created.")
|
||||||
|
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"TtsService onCreate. sdk=${Build.VERSION.SDK_INT}, hasPostNotifications=$hasNotificationPermission"
|
||||||
|
)
|
||||||
|
|
||||||
cacheManager = TtsCacheManager(this)
|
cacheManager = TtsCacheManager(this)
|
||||||
|
|
||||||
|
|
@ -622,6 +663,7 @@ class TtsService : MediaSessionService() {
|
||||||
.setHandleAudioBecomingNoisy(true)
|
.setHandleAudioBecomingNoisy(true)
|
||||||
.setMediaSourceFactory(androidx.media3.exoplayer.source.DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory))
|
.setMediaSourceFactory(androidx.media3.exoplayer.source.DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory))
|
||||||
.build()
|
.build()
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("ExoPlayer created for TTS service.")
|
||||||
|
|
||||||
playbackManager = TtsPlaybackManager(
|
playbackManager = TtsPlaybackManager(
|
||||||
player = player,
|
player = player,
|
||||||
|
|
@ -634,21 +676,30 @@ class TtsService : MediaSessionService() {
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
mediaSession?.let { playbackManager.setMediaSession(it) }
|
mediaSession?.let { playbackManager.setMediaSession(it) }
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("MediaSession created and attached to playback manager. sessionAvailable=${mediaSession != null}")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"onTaskRemoved. playWhenReady=${if (::player.isInitialized) player.playWhenReady else null}, isPlaying=${if (::player.isInitialized) player.isPlaying else null}"
|
||||||
|
)
|
||||||
if (!player.playWhenReady) {
|
if (!player.playWhenReady) {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().")
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
Timber.d("onTaskRemoved called, stopping service.")
|
Timber.d("onTaskRemoved called, stopping service.")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||||
|
"onGetSession. package=${controllerInfo.packageName}, sessionAvailable=${mediaSession != null}"
|
||||||
|
)
|
||||||
return mediaSession
|
return mediaSession
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Timber.d("TtsService is being destroyed.")
|
Timber.d("TtsService is being destroyed.")
|
||||||
|
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.")
|
||||||
baseTtsSynthesizer.shutdown()
|
baseTtsSynthesizer.shutdown()
|
||||||
playbackManager.release()
|
playbackManager.release()
|
||||||
mediaSession?.run {
|
mediaSession?.run {
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@
|
||||||
<!-- "Google Drive" is a brand name — do not translate. -->
|
<!-- "Google Drive" is a brand name — do not translate. -->
|
||||||
<string name="drawer_backup_desc">Upload books from your synced folders to Google Drive.</string>
|
<string name="drawer_backup_desc">Upload books from your synced folders to Google Drive.</string>
|
||||||
<string name="drawer_custom_fonts">Custom Fonts</string>
|
<string name="drawer_custom_fonts">Custom Fonts</string>
|
||||||
|
<string name="drawer_support_project">Support the Project</string>
|
||||||
<string name="drawer_help_feedback">Help & Feedback</string>
|
<string name="drawer_help_feedback">Help & Feedback</string>
|
||||||
<string name="drawer_sign_out">Sign Out</string>
|
<string name="drawer_sign_out">Sign Out</string>
|
||||||
|
|
||||||
|
|
@ -317,6 +318,15 @@
|
||||||
<string name="email_support">Email Support</string>
|
<string name="email_support">Email Support</string>
|
||||||
<string name="email_support_desc">Contact us directly via email for any other inquiries.</string>
|
<string name="email_support_desc">Contact us directly via email for any other inquiries.</string>
|
||||||
|
|
||||||
|
<!-- Support Project -->
|
||||||
|
<string name="support_project_title">Support the Project</string>
|
||||||
|
<string name="support_project_heading">Help keep Episteme moving</string>
|
||||||
|
<string name="support_project_desc">Your support helps me keep maintaining and Improving Episteme for everyone!!!</string>
|
||||||
|
<string name="support_github_sponsor">Sponsor on GitHub</string>
|
||||||
|
<string name="support_github_sponsor_desc">Support development directly through GitHub Sponsors. As a thank you, you get a README shoutout in the project repo.</string>
|
||||||
|
<string name="support_patreon">Join on Patreon</string>
|
||||||
|
<string name="support_patreon_desc">As a thank you for backing the app, Patreon supporters get extra content and benefits: sneak peeks at what I am working on, early screenshots and updates, votes that help shape how new features should look and work, and a README shoutout in the project repo.</string>
|
||||||
|
|
||||||
<!-- Dialogs -->
|
<!-- Dialogs -->
|
||||||
<!-- "Episteme Pro" is the product tier name — do not translate "Episteme". -->
|
<!-- "Episteme Pro" is the product tier name — do not translate "Episteme". -->
|
||||||
<string name="dialog_unlock_pro">Unlock Episteme Pro</string>
|
<string name="dialog_unlock_pro">Unlock Episteme Pro</string>
|
||||||
|
|
@ -573,10 +583,14 @@
|
||||||
<!-- Common.kt: ReaderThemePanel & ThemeBuilderView -->
|
<!-- Common.kt: ReaderThemePanel & ThemeBuilderView -->
|
||||||
<string name="reading_themes">Reading Themes</string>
|
<string name="reading_themes">Reading Themes</string>
|
||||||
<string name="theme_presets">Presets</string>
|
<string name="theme_presets">Presets</string>
|
||||||
|
<string name="theme_textured_presets">Textured Presets</string>
|
||||||
<string name="theme_my_themes">My Themes</string>
|
<string name="theme_my_themes">My Themes</string>
|
||||||
|
<string name="theme_my_textured_themes">My Textured Themes</string>
|
||||||
<!-- "+" is a literal plus icon reference in the description. -->
|
<!-- "+" is a literal plus icon reference in the description. -->
|
||||||
<string name="theme_no_custom">No custom themes yet. Tap \'+\' to create one.</string>
|
<string name="theme_no_custom">No custom themes yet. Tap \'+\' to create one.</string>
|
||||||
|
<string name="theme_no_textured_custom">No custom textured themes yet.</string>
|
||||||
<string name="theme_new">New Theme</string>
|
<string name="theme_new">New Theme</string>
|
||||||
|
<string name="theme_new_textured">New Textured Theme</string>
|
||||||
<string name="theme_edit">Edit Theme</string>
|
<string name="theme_edit">Edit Theme</string>
|
||||||
<string name="theme_name">Theme Name</string>
|
<string name="theme_name">Theme Name</string>
|
||||||
<!-- Short motivational quote used as preview text in the theme builder. -->
|
<!-- Short motivational quote used as preview text in the theme builder. -->
|
||||||
|
|
@ -587,6 +601,10 @@
|
||||||
<string name="theme_low_contrast_warning">⚠️ Low contrast! This might cause eye strain.</string>
|
<string name="theme_low_contrast_warning">⚠️ Low contrast! This might cause eye strain.</string>
|
||||||
<string name="theme_page_color">Page Color</string>
|
<string name="theme_page_color">Page Color</string>
|
||||||
<string name="theme_text_color">Text Color</string>
|
<string name="theme_text_color">Text Color</string>
|
||||||
|
<string name="theme_texture">Texture</string>
|
||||||
|
<string name="theme_texture_none">None</string>
|
||||||
|
<string name="theme_texture_upload">Upload</string>
|
||||||
|
<string name="theme_texture_transparency">Texture Transparency</string>
|
||||||
<string name="theme_color_live_preview">Live Preview</string>
|
<string name="theme_color_live_preview">Live Preview</string>
|
||||||
<!-- Short phrase shown in the live color preview area of the theme builder. -->
|
<!-- Short phrase shown in the live color preview area of the theme builder. -->
|
||||||
<string name="theme_color_preview_text">Reading is dreaming.</string>
|
<string name="theme_color_preview_text">Reading is dreaming.</string>
|
||||||
|
|
@ -808,7 +826,8 @@
|
||||||
<string name="visual_options_system_ui">System UI (Status & Navigation Bars)</string>
|
<string name="visual_options_system_ui">System UI (Status & Navigation Bars)</string>
|
||||||
<string name="visual_options_system_ui_desc">Control the visibility of the device\'s system bars.</string>
|
<string name="visual_options_system_ui_desc">Control the visibility of the device\'s system bars.</string>
|
||||||
<string name="visual_options_progress_bar">Progress Bar</string>
|
<string name="visual_options_progress_bar">Progress Bar</string>
|
||||||
<string name="visual_options_progress_bar_desc">The reading progress and chapter indicator at the bottom of the screen.</string>
|
<string name="visual_options_progress_bar_desc">The reading progress and chapter indicator on the reading screen.</string>
|
||||||
|
<string name="visual_options_progress_bar_position">Position</string>
|
||||||
<!-- "Seamless Chapter Transition" is a reading feature name. -->
|
<!-- "Seamless Chapter Transition" is a reading feature name. -->
|
||||||
<string name="visual_options_seamless_chapter">Seamless Chapter Transition</string>
|
<string name="visual_options_seamless_chapter">Seamless Chapter Transition</string>
|
||||||
<string name="visual_options_seamless_chapter_desc">Instantly load the next/previous chapter when scrolling past the end, without the pull-to-refresh animation.</string>
|
<string name="visual_options_seamless_chapter_desc">Instantly load the next/previous chapter when scrolling past the end, without the pull-to-refresh animation.</string>
|
||||||
|
|
@ -871,6 +890,7 @@
|
||||||
<string name="label_paragraph_gap">Paragraph Gap</string>
|
<string name="label_paragraph_gap">Paragraph Gap</string>
|
||||||
<string name="label_image_size">Image Size</string>
|
<string name="label_image_size">Image Size</string>
|
||||||
<string name="label_horizontal_margin">Horizontal Margin</string>
|
<string name="label_horizontal_margin">Horizontal Margin</string>
|
||||||
|
<string name="label_vertical_margin">Vertical Margin</string>
|
||||||
<string name="label_none">None</string>
|
<string name="label_none">None</string>
|
||||||
<!-- Short label for the "Original" font option in the reader settings. "Orig" is an abbreviation. -->
|
<!-- Short label for the "Original" font option in the reader settings. "Orig" is an abbreviation. -->
|
||||||
<string name="label_original">Orig</string>
|
<string name="label_original">Orig</string>
|
||||||
|
|
@ -1214,6 +1234,7 @@
|
||||||
<string name="msg_downloading_bubble_zoom_model_progress">Downloading Bubble Zoom model… %1$d%%</string>
|
<string name="msg_downloading_bubble_zoom_model_progress">Downloading Bubble Zoom model… %1$d%%</string>
|
||||||
<string name="content_desc_exit_slider_navigation">Exit slider navigation</string>
|
<string name="content_desc_exit_slider_navigation">Exit slider navigation</string>
|
||||||
<string name="content_desc_jump_back">Jump Back</string>
|
<string name="content_desc_jump_back">Jump Back</string>
|
||||||
|
<string name="content_desc_jump_forward">Jump Forward</string>
|
||||||
<string name="content_desc_scroll_to_reading_page">Scroll to reading page</string>
|
<string name="content_desc_scroll_to_reading_page">Scroll to reading page</string>
|
||||||
<string name="content_desc_annotated_page">Annotated Page</string>
|
<string name="content_desc_annotated_page">Annotated Page</string>
|
||||||
<string name="content_desc_close_image">Close Image</string>
|
<string name="content_desc_close_image">Close Image</string>
|
||||||
|
|
@ -1261,9 +1282,9 @@
|
||||||
<string name="label_short">Short</string>
|
<string name="label_short">Short</string>
|
||||||
<string name="label_long">Long</string>
|
<string name="label_long">Long</string>
|
||||||
<!-- Compact TTS speed label. %1$s = formatted multiplier, e.g. 1.2. -->
|
<!-- Compact TTS speed label. %1$s = formatted multiplier, e.g. 1.2. -->
|
||||||
<string name="tts_speed_short">Spd: %1$sx</string>
|
<string name="tts_speed_short">Speed: %1$sx</string>
|
||||||
<!-- Compact TTS pitch label. %1$s = formatted multiplier, e.g. 1.0. -->
|
<!-- Compact TTS pitch label. %1$s = formatted multiplier, e.g. 1.0. -->
|
||||||
<string name="tts_pitch_short">Ptch: %1$sx</string>
|
<string name="tts_pitch_short">Pitch: %1$sx</string>
|
||||||
<string name="content_desc_play_pause">Play/Pause</string>
|
<string name="content_desc_play_pause">Play/Pause</string>
|
||||||
<string name="content_desc_reset_speed">Reset Speed</string>
|
<string name="content_desc_reset_speed">Reset Speed</string>
|
||||||
<string name="content_desc_reset_pitch">Reset Pitch</string>
|
<string name="content_desc_reset_pitch">Reset Pitch</string>
|
||||||
|
|
|
||||||
28
app/src/test/java/com/aryan/reader/FileTypeResolverTest.kt
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.aryan.reader
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FileTypeResolverTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `transparent txt suffix preserves supported inner extension`() {
|
||||||
|
assertEquals(FileType.MD, resolveFileTypeFromName("notes.md.txt"))
|
||||||
|
assertEquals(FileType.HTML, resolveFileTypeFromName("chapter.html.txt"))
|
||||||
|
assertEquals(FileType.HTML, resolveFileTypeFromName("snippet.js.txt"))
|
||||||
|
assertEquals(FileType.EPUB, resolveFileTypeFromName("book.epub.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `plain txt remains txt when inner extension is unsupported`() {
|
||||||
|
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))
|
||||||
|
assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extension suffix preserves transparent txt wrapper`() {
|
||||||
|
assertEquals(".md.txt", resolveFileExtensionSuffixFromName("notes.md.txt"))
|
||||||
|
assertEquals(".html.txt", resolveFileExtensionSuffixFromName("chapter.html.txt"))
|
||||||
|
assertEquals(".txt", resolveFileExtensionSuffixFromName("notes.txt"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
// Top-level build file where you can add configuration options common to all subprojects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.compose.multiplatform) apply false
|
||||||
}
|
}
|
||||||
55
desktopApp/build.gradle.kts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import org.gradle.api.tasks.JavaExec
|
||||||
|
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.compose.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvm("desktop")
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
val desktopMain by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":shared"))
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
|
implementation(compose.material3)
|
||||||
|
implementation(compose.materialIconsExtended)
|
||||||
|
implementation("io.github.kevinnzou:compose-webview-multiplatform:2.0.3")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
|
implementation("net.java.dev.jna:jna:5.17.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compose.desktop {
|
||||||
|
application {
|
||||||
|
mainClass = "com.aryan.reader.desktop.MainKt"
|
||||||
|
|
||||||
|
jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
|
||||||
|
jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
|
||||||
|
|
||||||
|
nativeDistributions {
|
||||||
|
targetFormats(TargetFormat.Exe, TargetFormat.Msi)
|
||||||
|
packageName = "Episteme"
|
||||||
|
packageVersion = "1.0.0"
|
||||||
|
description = "Episteme desktop shell"
|
||||||
|
vendor = "Aryan Reader"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
tasks.withType<JavaExec>().configureEach {
|
||||||
|
jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
|
||||||
|
jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
|
||||||
|
if (System.getProperty("os.name").contains("Mac")) {
|
||||||
|
jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED")
|
||||||
|
jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.aryan.reader.paginatedreader.CssParser
|
||||||
|
import com.aryan.reader.paginatedreader.OptimizedCssRules
|
||||||
|
import com.aryan.reader.paginatedreader.UserAgentStylesheet
|
||||||
|
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
|
||||||
|
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||||
|
import com.aryan.reader.shared.reader.SharedEpubChapter
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
|
object DesktopEpubLoader {
|
||||||
|
fun load(file: File): SharedEpubBook {
|
||||||
|
ZipFile(file).use { zip ->
|
||||||
|
val container = zip.readText("META-INF/container.xml")
|
||||||
|
val opfPath = container
|
||||||
|
.substringAfter("full-path=\"", missingDelimiterValue = "")
|
||||||
|
.substringBefore("\"")
|
||||||
|
.ifBlank { error("EPUB container does not point to an OPF package.") }
|
||||||
|
val opf = zip.readText(opfPath)
|
||||||
|
val basePath = opfPath.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
|
.let { if (it.isBlank()) "" else "$it/" }
|
||||||
|
|
||||||
|
val title = opf.tagText("title").ifBlank { file.nameWithoutExtension }
|
||||||
|
val author = opf.tagText("creator").ifBlank { null }
|
||||||
|
val manifest = parseManifest(opf)
|
||||||
|
val cssByPath = loadCss(zip, manifest, basePath)
|
||||||
|
val cssRules = parseCssRules(cssByPath)
|
||||||
|
val spine = Regex("<itemref[^>]*idref=[\"']([^\"']+)[\"'][^>]*/?>")
|
||||||
|
.findAll(opf)
|
||||||
|
.mapNotNull { match -> manifest[match.groupValues[1]] }
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
val chapterPaths = spine.ifEmpty {
|
||||||
|
manifest.values.filter { it.endsWith(".xhtml", ignoreCase = true) || it.endsWith(".html", ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val chapters = chapterPaths.mapIndexedNotNull { index, href ->
|
||||||
|
val path = normalizeZipPath(basePath + href)
|
||||||
|
val html = zip.readTextOrNull(path) ?: return@mapIndexedNotNull null
|
||||||
|
val resourceReadyHtml = html.sanitizeReaderHtml().withEmbeddedResources(zip, path)
|
||||||
|
val text = htmlToText(html)
|
||||||
|
val semanticBlocks = runCatching {
|
||||||
|
htmlToSemanticBlocks(
|
||||||
|
html = resourceReadyHtml,
|
||||||
|
cssRules = cssRules,
|
||||||
|
textStyle = TextStyle(fontSize = 18.sp),
|
||||||
|
chapterAbsPath = path,
|
||||||
|
extractionBasePath = "",
|
||||||
|
density = Density(1f),
|
||||||
|
fontFamilyMap = emptyMap(),
|
||||||
|
constraints = Constraints(maxWidth = 980, maxHeight = 720)
|
||||||
|
)
|
||||||
|
}.getOrElse { emptyList() }
|
||||||
|
if (text.isBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
SharedEpubChapter(
|
||||||
|
id = "chapter_$index",
|
||||||
|
title = html.tagText("h1")
|
||||||
|
.ifBlank { html.tagText("h2") }
|
||||||
|
.ifBlank { html.tagText("title") }
|
||||||
|
.ifBlank { "Chapter ${index + 1}" },
|
||||||
|
plainText = text,
|
||||||
|
semanticBlocks = semanticBlocks,
|
||||||
|
htmlContent = resourceReadyHtml.extractBodyOrSelf(),
|
||||||
|
baseHref = path.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SharedEpubBook(
|
||||||
|
id = file.absolutePath,
|
||||||
|
fileName = file.name,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
css = cssByPath,
|
||||||
|
chapters = chapters.ifEmpty {
|
||||||
|
listOf(
|
||||||
|
SharedEpubChapter(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
title = title,
|
||||||
|
plainText = "This EPUB opened, but no readable spine text was found by the lightweight desktop loader."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseManifest(opf: String): Map<String, String> {
|
||||||
|
return Regex("<item\\s+[^>]*>").findAll(opf).mapNotNull { match ->
|
||||||
|
val item = match.value
|
||||||
|
val id = item.attr("id")
|
||||||
|
val href = item.attr("href")
|
||||||
|
if (id.isBlank() || href.isBlank()) null else id to href
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCss(zip: ZipFile, manifest: Map<String, String>, basePath: String): Map<String, String> {
|
||||||
|
return manifest.values
|
||||||
|
.filter { it.endsWith(".css", ignoreCase = true) }
|
||||||
|
.mapNotNull { href ->
|
||||||
|
val path = normalizeZipPath(basePath + href)
|
||||||
|
val css = zip.readTextOrNull(path)?.withEmbeddedCssResources(zip, path).orEmpty()
|
||||||
|
if (css.isBlank()) null else path to css
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCssRules(cssByPath: Map<String, String>): OptimizedCssRules {
|
||||||
|
val constraints = Constraints(maxWidth = 980, maxHeight = 720)
|
||||||
|
val baseRules = CssParser.parse(
|
||||||
|
cssContent = UserAgentStylesheet.default,
|
||||||
|
cssPath = null,
|
||||||
|
baseFontSizeSp = 18f,
|
||||||
|
density = 1f,
|
||||||
|
constraints = constraints,
|
||||||
|
isDarkTheme = false
|
||||||
|
).rules
|
||||||
|
|
||||||
|
return cssByPath.entries
|
||||||
|
.fold(baseRules) { rules, (path, css) ->
|
||||||
|
if (css.isBlank()) {
|
||||||
|
rules
|
||||||
|
} else {
|
||||||
|
rules.merge(
|
||||||
|
CssParser.parse(
|
||||||
|
cssContent = css,
|
||||||
|
cssPath = path,
|
||||||
|
baseFontSizeSp = 18f,
|
||||||
|
density = 1f,
|
||||||
|
constraints = constraints,
|
||||||
|
isDarkTheme = false
|
||||||
|
).rules
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipFile.readText(path: String): String {
|
||||||
|
val entry = getEntry(path) ?: error("Missing EPUB entry: $path")
|
||||||
|
return getInputStream(entry).bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipFile.readTextOrNull(path: String): String? {
|
||||||
|
val entry = getEntry(path) ?: return null
|
||||||
|
return getInputStream(entry).bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.attr(name: String): String {
|
||||||
|
return Regex("""\b$name=["']([^"']+)["']""").find(this)?.groupValues?.get(1).orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.tagText(tag: String): String {
|
||||||
|
return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>", RegexOption.IGNORE_CASE)
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
?.let(::htmlToText)
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeZipPath(path: String): String {
|
||||||
|
val parts = ArrayDeque<String>()
|
||||||
|
path.split('/').forEach { part ->
|
||||||
|
when (part) {
|
||||||
|
"", "." -> Unit
|
||||||
|
".." -> if (parts.isNotEmpty()) parts.removeLast()
|
||||||
|
else -> parts.addLast(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.joinToString("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.withEmbeddedResources(zip: ZipFile, chapterPath: String): String {
|
||||||
|
return replace(Regex("""(?i)\b(src|href)=["']([^"']+)["']""")) { match ->
|
||||||
|
val attr = match.groupValues[1]
|
||||||
|
val raw = match.groupValues[2]
|
||||||
|
if (attr.equals("href", ignoreCase = true) && !raw.looksLikeEmbeddableResource()) {
|
||||||
|
return@replace match.value
|
||||||
|
}
|
||||||
|
val dataUri = zip.toDataUri(raw, chapterPath)
|
||||||
|
if (dataUri != null) "$attr=\"$dataUri\"" else match.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.looksLikeEmbeddableResource(): Boolean {
|
||||||
|
return substringBefore('#')
|
||||||
|
.substringBefore('?')
|
||||||
|
.substringAfterLast('.', "")
|
||||||
|
.lowercase() in setOf("css", "jpg", "jpeg", "png", "gif", "svg", "webp", "ttf", "otf", "woff", "woff2")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.sanitizeReaderHtml(): String {
|
||||||
|
return replace(Regex("(?is)<script\\b.*?</script>"), "")
|
||||||
|
.replace(Regex("(?is)<object\\b.*?</object>"), "")
|
||||||
|
.replace(Regex("(?is)<embed\\b[^>]*>"), "")
|
||||||
|
.replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.withEmbeddedCssResources(zip: ZipFile, cssPath: String): String {
|
||||||
|
return replace(Regex("""url\((['"]?)([^)'"]+)\1\)""", RegexOption.IGNORE_CASE)) { match ->
|
||||||
|
val raw = match.groupValues[2].trim()
|
||||||
|
val dataUri = zip.toDataUri(raw, cssPath)
|
||||||
|
if (dataUri != null) "url('$dataUri')" else match.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipFile.toDataUri(rawRef: String, ownerPath: String): String? {
|
||||||
|
val ref = rawRef.substringBefore('#').trim()
|
||||||
|
if (ref.isBlank() || ref.startsWith("data:", ignoreCase = true)) return null
|
||||||
|
if (ref.startsWith("http://", ignoreCase = true) || ref.startsWith("https://", ignoreCase = true)) return null
|
||||||
|
val base = ownerPath.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
|
val path = normalizeZipPath(if (base.isBlank()) ref else "$base/$ref")
|
||||||
|
val entry = getEntry(path) ?: return null
|
||||||
|
val bytes = getInputStream(entry).use { it.readBytes() }
|
||||||
|
return "data:${mimeType(path)};base64,${Base64.getEncoder().encodeToString(bytes)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mimeType(path: String): String {
|
||||||
|
return when (path.substringAfterLast('.', "").lowercase()) {
|
||||||
|
"jpg", "jpeg" -> "image/jpeg"
|
||||||
|
"png" -> "image/png"
|
||||||
|
"gif" -> "image/gif"
|
||||||
|
"svg" -> "image/svg+xml"
|
||||||
|
"webp" -> "image/webp"
|
||||||
|
"ttf" -> "font/ttf"
|
||||||
|
"otf" -> "font/otf"
|
||||||
|
"woff" -> "font/woff"
|
||||||
|
"woff2" -> "font/woff2"
|
||||||
|
"css" -> "text/css"
|
||||||
|
"js" -> "text/javascript"
|
||||||
|
else -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.extractBodyOrSelf(): String {
|
||||||
|
return Regex("(?is)<body\\b[^>]*>(.*?)</body>")
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
?.trim()
|
||||||
|
?: this
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun htmlToText(html: String): String {
|
||||||
|
return html
|
||||||
|
.replace(Regex("(?is)<script.*?</script>"), "")
|
||||||
|
.replace(Regex("(?is)<style.*?</style>"), "")
|
||||||
|
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||||
|
.replace(Regex("(?i)</p\\s*>"), "\n\n")
|
||||||
|
.replace(Regex("(?i)</h[1-6]\\s*>"), "\n\n")
|
||||||
|
.replace(Regex("<[^>]+>"), " ")
|
||||||
|
.decodeEntities()
|
||||||
|
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
|
||||||
|
.replace(Regex(" *\\n *"), "\n")
|
||||||
|
.replace(Regex("\\n{3,}"), "\n\n")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.decodeEntities(): String {
|
||||||
|
return replace(" ", " ")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
||||||
|
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
||||||
|
}
|
||||||
|
.replace(Regex("&#(\\d+);")) { match ->
|
||||||
|
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import com.aryan.reader.shared.BookItem
|
||||||
|
import com.aryan.reader.shared.BookShelfRef
|
||||||
|
import com.aryan.reader.shared.FileType
|
||||||
|
import com.aryan.reader.shared.ShelfRecord
|
||||||
|
import com.aryan.reader.shared.Tag
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
|
import kotlinx.serialization.json.floatOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
data class DesktopLibrarySnapshot(
|
||||||
|
val books: List<BookItem> = emptyList(),
|
||||||
|
val shelfRecords: List<ShelfRecord> = emptyList(),
|
||||||
|
val shelfRefs: List<BookShelfRef> = emptyList(),
|
||||||
|
val tags: List<Tag> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
class DesktopLibraryDatabase(
|
||||||
|
private val databaseFile: File = defaultDatabaseFile()
|
||||||
|
) {
|
||||||
|
private val json = Json {
|
||||||
|
prettyPrint = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(): DesktopLibrarySnapshot {
|
||||||
|
if (!databaseFile.exists()) return DesktopLibrarySnapshot()
|
||||||
|
val root = runCatching {
|
||||||
|
json.parseToJsonElement(databaseFile.readText()).jsonObject
|
||||||
|
}.getOrNull() ?: return DesktopLibrarySnapshot()
|
||||||
|
|
||||||
|
return DesktopLibrarySnapshot(
|
||||||
|
books = root.array("books").mapNotNull { it.asBookItemOrNull() },
|
||||||
|
shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() },
|
||||||
|
shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() },
|
||||||
|
tags = root.array("tags").mapNotNull { it.asTagOrNull() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(snapshot: DesktopLibrarySnapshot) {
|
||||||
|
databaseFile.parentFile?.mkdirs()
|
||||||
|
val root = JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"schemaVersion" to JsonPrimitive(1),
|
||||||
|
"books" to JsonArray(snapshot.books.map { it.toJsonObject() }),
|
||||||
|
"shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }),
|
||||||
|
"bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }),
|
||||||
|
"tags" to JsonArray(snapshot.tags.map { it.toJsonObject() })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
databaseFile.writeText(root.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun defaultDatabaseFile(): File {
|
||||||
|
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||||
|
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
||||||
|
return File(baseDir, "Episteme/library.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.array(name: String): List<JsonElement> {
|
||||||
|
return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.string(name: String): String? {
|
||||||
|
return this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.long(name: String, fallback: Long = 0L): Long {
|
||||||
|
return this[name]?.jsonPrimitive?.longOrNull ?: fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.float(name: String): Float? {
|
||||||
|
return this[name]?.jsonPrimitive?.floatOrNull
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.double(name: String): Double? {
|
||||||
|
return this[name]?.jsonPrimitive?.doubleOrNull
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean {
|
||||||
|
return this[name]?.jsonPrimitive?.booleanOrNull ?: fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.asBookItemOrNull(): BookItem? {
|
||||||
|
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||||
|
val id = obj.string("id") ?: return null
|
||||||
|
val displayName = obj.string("displayName") ?: return null
|
||||||
|
val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN
|
||||||
|
return BookItem(
|
||||||
|
id = id,
|
||||||
|
path = obj.string("path"),
|
||||||
|
type = type,
|
||||||
|
displayName = displayName,
|
||||||
|
timestamp = obj.long("timestamp"),
|
||||||
|
title = obj.string("title"),
|
||||||
|
author = obj.string("author"),
|
||||||
|
progressPercentage = obj.float("progressPercentage"),
|
||||||
|
isRecent = obj.boolean("isRecent", true),
|
||||||
|
fileSize = obj.long("fileSize"),
|
||||||
|
sourceFolder = obj.string("sourceFolder"),
|
||||||
|
seriesName = obj.string("seriesName"),
|
||||||
|
seriesIndex = obj.double("seriesIndex"),
|
||||||
|
tags = obj.array("tags").mapNotNull { it.asTagOrNull() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? {
|
||||||
|
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||||
|
return ShelfRecord(
|
||||||
|
id = obj.string("id") ?: return null,
|
||||||
|
name = obj.string("name") ?: return null,
|
||||||
|
isSmart = obj.boolean("isSmart", false),
|
||||||
|
smartRulesJson = obj.string("smartRulesJson")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? {
|
||||||
|
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||||
|
return BookShelfRef(
|
||||||
|
bookId = obj.string("bookId") ?: return null,
|
||||||
|
shelfId = obj.string("shelfId") ?: return null,
|
||||||
|
addedAt = obj.long("addedAt")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.asTagOrNull(): Tag? {
|
||||||
|
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||||
|
return Tag(
|
||||||
|
id = obj.string("id") ?: return null,
|
||||||
|
name = obj.string("name") ?: return null,
|
||||||
|
color = obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BookItem.toJsonObject(): JsonObject {
|
||||||
|
return JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"id" to JsonPrimitive(id),
|
||||||
|
"path" to path.asJson(),
|
||||||
|
"type" to JsonPrimitive(type.name),
|
||||||
|
"displayName" to JsonPrimitive(displayName),
|
||||||
|
"timestamp" to JsonPrimitive(timestamp),
|
||||||
|
"title" to title.asJson(),
|
||||||
|
"author" to author.asJson(),
|
||||||
|
"progressPercentage" to progressPercentage.asJson(),
|
||||||
|
"isRecent" to JsonPrimitive(isRecent),
|
||||||
|
"fileSize" to JsonPrimitive(fileSize),
|
||||||
|
"sourceFolder" to sourceFolder.asJson(),
|
||||||
|
"seriesName" to seriesName.asJson(),
|
||||||
|
"seriesIndex" to seriesIndex.asJson(),
|
||||||
|
"tags" to JsonArray(tags.map { it.toJsonObject() })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ShelfRecord.toJsonObject(): JsonObject {
|
||||||
|
return JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"id" to JsonPrimitive(id),
|
||||||
|
"name" to JsonPrimitive(name),
|
||||||
|
"isSmart" to JsonPrimitive(isSmart),
|
||||||
|
"smartRulesJson" to smartRulesJson.asJson()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BookShelfRef.toJsonObject(): JsonObject {
|
||||||
|
return JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"bookId" to JsonPrimitive(bookId),
|
||||||
|
"shelfId" to JsonPrimitive(shelfId),
|
||||||
|
"addedAt" to JsonPrimitive(addedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Tag.toJsonObject(): JsonObject {
|
||||||
|
return JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"id" to JsonPrimitive(id),
|
||||||
|
"name" to JsonPrimitive(name),
|
||||||
|
"color" to color.asJson()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||||
|
private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||||
|
private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||||
|
private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
package com.aryan.reader.desktop
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
|
import com.aryan.reader.shared.pdf.PdfZoomSpec
|
||||||
|
import com.sun.jna.Library
|
||||||
|
import com.sun.jna.Memory
|
||||||
|
import com.sun.jna.Native
|
||||||
|
import com.sun.jna.Pointer
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
data class DesktopPdfDocument(
|
||||||
|
val path: String,
|
||||||
|
val title: String,
|
||||||
|
val pageCount: Int,
|
||||||
|
val pageSizes: List<DesktopPdfPageSize>,
|
||||||
|
val textPages: List<String>
|
||||||
|
) {
|
||||||
|
fun close() {
|
||||||
|
DesktopPdfium.closeDocument(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DesktopPdfPageSize(
|
||||||
|
val width: Float,
|
||||||
|
val height: Float
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DesktopPdfPageRender(
|
||||||
|
val image: ImageBitmap,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
object DesktopPdfium {
|
||||||
|
private const val FPDF_ANNOT = 0x01
|
||||||
|
private const val FPDF_LCD_TEXT = 0x02
|
||||||
|
private const val FPDF_RENDER_NO_SMOOTHTEXT = 0x1000
|
||||||
|
private const val FPDF_BITMAP_BGRA = 4
|
||||||
|
|
||||||
|
private val pdfiumDll: File by lazy(::resolvePdfiumDll)
|
||||||
|
private val zoomSpec = PdfZoomSpec()
|
||||||
|
private val api: PdfiumLibrary by lazy {
|
||||||
|
require(pdfiumDll.exists()) {
|
||||||
|
"Missing Pdfium DLL. Expected pdfium-v8-win-x64 under third_party/pdfium/win-x64-v8/bin/pdfium.dll."
|
||||||
|
}
|
||||||
|
Native.load(pdfiumDll.absolutePath, PdfiumLibrary::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var initialized = false
|
||||||
|
private val openDocuments = LinkedHashMap<String, Pointer>()
|
||||||
|
|
||||||
|
fun isAvailable(): Boolean = pdfiumDll.exists()
|
||||||
|
|
||||||
|
fun load(file: File, password: String? = null): DesktopPdfDocument {
|
||||||
|
initLibrary()
|
||||||
|
val document = api.FPDF_LoadDocument(file.absolutePath, password)
|
||||||
|
?: error("Pdfium could not open ${file.name}. It may be encrypted or unsupported.")
|
||||||
|
val pageCount = api.FPDF_GetPageCount(document)
|
||||||
|
openDocuments[file.absolutePath] = document
|
||||||
|
|
||||||
|
val pageSizes = (0 until pageCount).map { pageIndex ->
|
||||||
|
loadPage(document, pageIndex).usePointer { page ->
|
||||||
|
DesktopPdfPageSize(
|
||||||
|
width = api.FPDF_GetPageWidthF(page),
|
||||||
|
height = api.FPDF_GetPageHeightF(page)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val textPages = (0 until pageCount).map { pageIndex ->
|
||||||
|
extractPageText(document, pageIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DesktopPdfDocument(
|
||||||
|
path = file.absolutePath,
|
||||||
|
title = file.nameWithoutExtension,
|
||||||
|
pageCount = pageCount,
|
||||||
|
pageSizes = pageSizes,
|
||||||
|
textPages = textPages
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeDocument(path: String) {
|
||||||
|
openDocuments.remove(path)?.let(api::FPDF_CloseDocument)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderPage(
|
||||||
|
document: DesktopPdfDocument,
|
||||||
|
pageIndex: Int,
|
||||||
|
scale: Float,
|
||||||
|
renderAnnotations: Boolean = true
|
||||||
|
): DesktopPdfPageRender {
|
||||||
|
val nativeDocument = openDocuments[document.path] ?: error("PDF document is not open.")
|
||||||
|
val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.")
|
||||||
|
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
|
||||||
|
val width = (pageSize.width * safeScale).roundToInt().coerceAtLeast(1)
|
||||||
|
val height = (pageSize.height * safeScale).roundToInt().coerceAtLeast(1)
|
||||||
|
val stride = width * 4
|
||||||
|
val memory = Memory((stride * height).toLong())
|
||||||
|
memory.clear(memory.size())
|
||||||
|
|
||||||
|
val bitmap = api.FPDFBitmap_CreateEx(width, height, FPDF_BITMAP_BGRA, memory, stride)
|
||||||
|
?: error("Pdfium could not allocate render bitmap.")
|
||||||
|
|
||||||
|
try {
|
||||||
|
api.FPDFBitmap_FillRect(bitmap, 0, 0, width, height, -1)
|
||||||
|
loadPage(nativeDocument, pageIndex).usePointer { page ->
|
||||||
|
val flags = FPDF_LCD_TEXT or
|
||||||
|
(if (renderAnnotations) FPDF_ANNOT else FPDF_RENDER_NO_SMOOTHTEXT)
|
||||||
|
api.FPDF_RenderPageBitmap(bitmap, page, 0, 0, width, height, 0, flags)
|
||||||
|
}
|
||||||
|
return DesktopPdfPageRender(
|
||||||
|
image = memory.toBufferedImage(width, height, stride).toComposeImageBitmap(),
|
||||||
|
width = width,
|
||||||
|
height = height
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
api.FPDFBitmap_Destroy(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractPageText(document: Pointer, pageIndex: Int): String {
|
||||||
|
return runCatching {
|
||||||
|
loadPage(document, pageIndex).usePointer { page ->
|
||||||
|
val textPage = api.FPDFText_LoadPage(page) ?: return@usePointer ""
|
||||||
|
try {
|
||||||
|
val charCount = api.FPDFText_CountChars(textPage)
|
||||||
|
if (charCount <= 0) return@usePointer ""
|
||||||
|
val buffer = Memory(((charCount + 1) * 2L))
|
||||||
|
val written = api.FPDFText_GetText(textPage, 0, charCount, buffer)
|
||||||
|
if (written <= 0) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
buffer.getCharArray(0, written).concatToString().trimEnd('\u0000')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
api.FPDFText_ClosePage(textPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrDefault("")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPage(document: Pointer, pageIndex: Int): PointerResource {
|
||||||
|
val page = api.FPDF_LoadPage(document, pageIndex)
|
||||||
|
?: error("Pdfium could not open page ${pageIndex + 1}.")
|
||||||
|
return PointerResource(page, api::FPDF_ClosePage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initLibrary() {
|
||||||
|
if (!initialized) {
|
||||||
|
api.FPDF_InitLibrary()
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolvePdfiumDll(): File {
|
||||||
|
val overridePath = System.getProperty("reader.pdfium.dll")
|
||||||
|
?: System.getenv("READER_PDFIUM_DLL")
|
||||||
|
if (!overridePath.isNullOrBlank()) {
|
||||||
|
return File(overridePath).absoluteFile
|
||||||
|
}
|
||||||
|
|
||||||
|
val relativePath = listOf("third_party", "pdfium", "win-x64-v8", "bin", "pdfium.dll")
|
||||||
|
.joinToString(File.separator)
|
||||||
|
val roots = generateSequence(File(System.getProperty("user.dir")).absoluteFile) { it.parentFile }
|
||||||
|
.take(6)
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
return roots
|
||||||
|
.map { File(it, relativePath).absoluteFile }
|
||||||
|
.firstOrNull { it.exists() }
|
||||||
|
?: File(File(System.getProperty("user.dir")).absoluteFile, relativePath).absoluteFile
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Memory.toBufferedImage(width: Int, height: Int, stride: Int): BufferedImage {
|
||||||
|
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
|
||||||
|
val buffer = getByteBuffer(0, size()).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
val pixels = IntArray(width * height)
|
||||||
|
for (y in 0 until height) {
|
||||||
|
buffer.position(y * stride)
|
||||||
|
for (x in 0 until width) {
|
||||||
|
val b = buffer.get().toInt() and 0xFF
|
||||||
|
val g = buffer.get().toInt() and 0xFF
|
||||||
|
val r = buffer.get().toInt() and 0xFF
|
||||||
|
val a = buffer.get().toInt() and 0xFF
|
||||||
|
pixels[y * width + x] = (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
image.setRGB(0, 0, width, height, pixels, 0, width)
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
private class PointerResource(
|
||||||
|
private val pointer: Pointer,
|
||||||
|
private val closer: (Pointer) -> Unit
|
||||||
|
) {
|
||||||
|
fun <T> usePointer(block: (Pointer) -> T): T {
|
||||||
|
try {
|
||||||
|
return block(pointer)
|
||||||
|
} finally {
|
||||||
|
closer(pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("FunctionName")
|
||||||
|
private interface PdfiumLibrary : Library {
|
||||||
|
fun FPDF_InitLibrary()
|
||||||
|
fun FPDF_LoadDocument(filePath: String, password: String?): Pointer?
|
||||||
|
fun FPDF_CloseDocument(document: Pointer)
|
||||||
|
fun FPDF_GetPageCount(document: Pointer): Int
|
||||||
|
fun FPDF_LoadPage(document: Pointer, pageIndex: Int): Pointer?
|
||||||
|
fun FPDF_ClosePage(page: Pointer)
|
||||||
|
fun FPDF_GetPageWidthF(page: Pointer): Float
|
||||||
|
fun FPDF_GetPageHeightF(page: Pointer): Float
|
||||||
|
fun FPDFBitmap_CreateEx(width: Int, height: Int, format: Int, firstScan: Pointer, stride: Int): Pointer?
|
||||||
|
fun FPDFBitmap_FillRect(bitmap: Pointer, left: Int, top: Int, width: Int, height: Int, color: Int)
|
||||||
|
fun FPDFBitmap_Destroy(bitmap: Pointer)
|
||||||
|
fun FPDF_RenderPageBitmap(
|
||||||
|
bitmap: Pointer,
|
||||||
|
page: Pointer,
|
||||||
|
startX: Int,
|
||||||
|
startY: Int,
|
||||||
|
sizeX: Int,
|
||||||
|
sizeY: Int,
|
||||||
|
rotate: Int,
|
||||||
|
flags: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
fun FPDFText_LoadPage(page: Pointer): Pointer?
|
||||||
|
fun FPDFText_ClosePage(textPage: Pointer)
|
||||||
|
fun FPDFText_CountChars(textPage: Pointer): Int
|
||||||
|
fun FPDFText_GetText(textPage: Pointer, startIndex: Int, count: Int, result: Pointer): Int
|
||||||
|
}
|
||||||
|
}
|
||||||
2429
desktopApp/src/desktopMain/kotlin/com/aryan/reader/desktop/Main.kt
Normal file
|
|
@ -21,7 +21,7 @@ androidxAnnotationJvm = "1.9.1"
|
||||||
androidxTestRunner = "1.6.2"
|
androidxTestRunner = "1.6.2"
|
||||||
material3WindowSizeClassAndroid = "1.3.2"
|
material3WindowSizeClassAndroid = "1.3.2"
|
||||||
credentials = "1.5.0"
|
credentials = "1.5.0"
|
||||||
|
composeMultiplatform = "1.8.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
|
@ -59,8 +59,10 @@ androidx-credentials = { group = "androidx.credentials", name = "credentials", v
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
kotlin-ksp = { id = "com.google.devtools.ksp", version = "2.3.2" }
|
kotlin-ksp = { id = "com.google.devtools.ksp", version = "2.3.2" }
|
||||||
|
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
||||||
|
|
||||||
# Add plugins required by pdfiumandroid
|
# Add plugins required by pdfiumandroid
|
||||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,12 @@ dependencyResolutionManagement {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven("https://jitpack.io")
|
maven("https://jitpack.io")
|
||||||
|
maven("https://jogamp.org/deployment/maven")
|
||||||
|
maven("https.jitpack.io")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "Reader"
|
rootProject.name = "Reader"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
include(":shared")
|
||||||
|
include(":desktopApp")
|
||||||
|
|
|
||||||
49
shared/build.gradle.kts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
id("com.android.library")
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.compose.multiplatform)
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
androidTarget()
|
||||||
|
jvm("desktop")
|
||||||
|
jvmToolchain(21)
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
val commonMain by getting
|
||||||
|
val androidMain by getting
|
||||||
|
val desktopMain by getting
|
||||||
|
val readerJvmMain by creating {
|
||||||
|
dependsOn(commonMain)
|
||||||
|
dependencies {
|
||||||
|
implementation("org.jsoup:jsoup:1.17.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidMain.dependsOn(readerJvmMain)
|
||||||
|
desktopMain.dependsOn(readerJvmMain)
|
||||||
|
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(compose.foundation)
|
||||||
|
implementation(compose.material3)
|
||||||
|
implementation(compose.materialIconsExtended)
|
||||||
|
implementation(compose.ui)
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.7.3")
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.aryan.reader.shared"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package com.aryan.reader.shared
|
||||||
|
|
||||||
|
actual fun currentTimestamp(): Long = System.currentTimeMillis()
|
||||||
|
|
@ -21,7 +21,6 @@ package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.text.ParagraphStyle
|
import androidx.compose.ui.text.ParagraphStyle
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
|
@ -35,23 +34,45 @@ import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.em
|
import androidx.compose.ui.unit.em
|
||||||
import androidx.compose.ui.unit.isSpecified
|
import androidx.compose.ui.unit.isSpecified
|
||||||
import timber.log.Timber
|
|
||||||
import java.io.File
|
|
||||||
import java.util.regex.Pattern
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private const val IMPORTANT_SPECIFICITY_BOOST = 10_000
|
private const val IMPORTANT_SPECIFICITY_BOOST = 10_000
|
||||||
|
|
||||||
|
private object ReaderCssLog {
|
||||||
|
fun d(@Suppress("UNUSED_PARAMETER") message: String) = Unit
|
||||||
|
fun w(@Suppress("UNUSED_PARAMETER") message: String) = Unit
|
||||||
|
fun w(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit
|
||||||
|
fun e(@Suppress("UNUSED_PARAMETER") throwable: Throwable, @Suppress("UNUSED_PARAMETER") message: String) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
private fun Color.luminance(): Float {
|
private fun Color.luminance(): Float {
|
||||||
if (!this.isSpecified) return 0f
|
if (!this.isSpecified) return 0f
|
||||||
return (0.299f * red + 0.587f * green + 0.114f * blue)
|
return (0.299f * red + 0.587f * green + 0.114f * blue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveCssRelativePath(cssPath: String, rawSrc: String): String {
|
||||||
|
if (rawSrc.startsWith("/") || rawSrc.contains("://")) return rawSrc
|
||||||
|
val normalizedBase = cssPath.replace('\\', '/').substringBeforeLast('/', "")
|
||||||
|
val parts = ArrayDeque<String>()
|
||||||
|
(if (normalizedBase.isBlank()) rawSrc else "$normalizedBase/$rawSrc")
|
||||||
|
.replace('\\', '/')
|
||||||
|
.split('/')
|
||||||
|
.forEach { part ->
|
||||||
|
when (part) {
|
||||||
|
"", "." -> Unit
|
||||||
|
".." -> if (parts.isNotEmpty()) parts.removeLast()
|
||||||
|
else -> parts.addLast(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.joinToString("/")
|
||||||
|
}
|
||||||
|
|
||||||
object CssParser {
|
object CssParser {
|
||||||
private val FONT_FACE_REGEX = "@font-face\\s*\\{([^}]+)\\}".toRegex(RegexOption.DOT_MATCHES_ALL)
|
private val FONT_FACE_REGEX = "@font-face\\s*\\{([^}]+)\\}".toRegex(RegexOption.DOT_MATCHES_ALL)
|
||||||
private val URL_REGEX = "url\\((['\"]?)(.*?)\\1\\)".toRegex()
|
private val URL_REGEX = "url\\((['\"]?)(.*?)\\1\\)".toRegex()
|
||||||
private val ID_SELECTOR_PATTERN = Pattern.compile("#[^\\s,]+")
|
private val ID_SELECTOR_REGEX = Regex("#[^\\s,]+")
|
||||||
private val CLASS_ATTRIBUTE_SELECTOR_PATTERN = Pattern.compile("\\.[^\\s,]+|\\[[^]]+]|:(?!:)[^\\s,]+")
|
private val CLASS_ATTRIBUTE_SELECTOR_REGEX = Regex("\\.[^\\s,]+|\\[[^]]+]|:(?!:)[^\\s,]+")
|
||||||
private val TYPE_PSEUDO_ELEMENT_SELECTOR_PATTERN = Pattern.compile("(?<![.#\\[])\\b[a-zA-Z-]+|::[a-zA-Z-]+")
|
private val TYPE_PSEUDO_ELEMENT_SELECTOR_REGEX = Regex("(?<![.#\\[])\\b[a-zA-Z-]+|::[a-zA-Z-]+")
|
||||||
private data class FontSource(val url: String, val format: String?)
|
private data class FontSource(val url: String, val format: String?)
|
||||||
|
|
||||||
// Regex to identify simple, single-part selectors for fast categorization
|
// Regex to identify simple, single-part selectors for fast categorization
|
||||||
|
|
@ -65,7 +86,7 @@ object CssParser {
|
||||||
"thick" to 5.dp
|
"thick" to 5.dp
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun adaptColorForTheme(
|
fun adaptColorForTheme(
|
||||||
color: Color,
|
color: Color,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
isBackground: Boolean,
|
isBackground: Boolean,
|
||||||
|
|
@ -113,16 +134,7 @@ object CssParser {
|
||||||
return color
|
return color
|
||||||
}
|
}
|
||||||
|
|
||||||
val hsl = FloatArray(3)
|
return themeText.takeIf { it.isSpecified } ?: color
|
||||||
androidx.core.graphics.ColorUtils.colorToHSL(color.toArgb(), hsl)
|
|
||||||
|
|
||||||
if (bgLuminance < 0.5f) {
|
|
||||||
hsl[2] = hsl[2].coerceAtLeast(0.7f)
|
|
||||||
} else {
|
|
||||||
hsl[2] = hsl[2].coerceAtMost(0.3f)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Color(androidx.core.graphics.ColorUtils.HSLToColor(hsl))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +155,7 @@ object CssParser {
|
||||||
reassembled = true
|
reassembled = true
|
||||||
}
|
}
|
||||||
if (reassembled) {
|
if (reassembled) {
|
||||||
Timber.d("Reassembled declaration. Original: '$originalCurrent'. Final: '$current'")
|
ReaderCssLog.d("Reassembled declaration. Original: '$originalCurrent'. Final: '$current'")
|
||||||
}
|
}
|
||||||
result.add(current)
|
result.add(current)
|
||||||
}
|
}
|
||||||
|
|
@ -151,21 +163,9 @@ object CssParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateSpecificity(selector: String): Int {
|
private fun calculateSpecificity(selector: String): Int {
|
||||||
val ids = ID_SELECTOR_PATTERN.matcher(selector).run {
|
val ids = ID_SELECTOR_REGEX.findAll(selector).count()
|
||||||
var count = 0
|
val classesAndAttributes = CLASS_ATTRIBUTE_SELECTOR_REGEX.findAll(selector).count()
|
||||||
while (find()) count++
|
val elementsAndPseudos = TYPE_PSEUDO_ELEMENT_SELECTOR_REGEX.findAll(selector).count()
|
||||||
count
|
|
||||||
}
|
|
||||||
val classesAndAttributes = CLASS_ATTRIBUTE_SELECTOR_PATTERN.matcher(selector).run {
|
|
||||||
var count = 0
|
|
||||||
while (find()) count++
|
|
||||||
count
|
|
||||||
}
|
|
||||||
val elementsAndPseudos = TYPE_PSEUDO_ELEMENT_SELECTOR_PATTERN.matcher(selector).run {
|
|
||||||
var count = 0
|
|
||||||
while (find()) count++
|
|
||||||
count
|
|
||||||
}
|
|
||||||
val specificity = ids * 100 + classesAndAttributes * 10 + elementsAndPseudos
|
val specificity = ids * 100 + classesAndAttributes * 10 + elementsAndPseudos
|
||||||
return specificity
|
return specificity
|
||||||
}
|
}
|
||||||
|
|
@ -200,13 +200,13 @@ object CssParser {
|
||||||
}
|
}
|
||||||
cleanedCss = mediaQueryRegex.replace(cleanedCss, "")
|
cleanedCss = mediaQueryRegex.replace(cleanedCss, "")
|
||||||
|
|
||||||
Timber.d("CssParser: Checking for @font-face rules...")
|
ReaderCssLog.d("CssParser: Checking for @font-face rules...")
|
||||||
val fontFaceMatches = FONT_FACE_REGEX.findAll(cleanedCss)
|
val fontFaceMatches = FONT_FACE_REGEX.findAll(cleanedCss)
|
||||||
if (!fontFaceMatches.any()) {
|
if (!fontFaceMatches.any()) {
|
||||||
Timber.d("CssParser: No @font-face rules found by regex.")
|
ReaderCssLog.d("CssParser: No @font-face rules found by regex.")
|
||||||
}
|
}
|
||||||
fontFaceMatches.forEach { match ->
|
fontFaceMatches.forEach { match ->
|
||||||
Timber.d("CssParser: Found a @font-face block. Parsing its properties.")
|
ReaderCssLog.d("CssParser: Found a @font-face block. Parsing its properties.")
|
||||||
val properties = match.groupValues[1]
|
val properties = match.groupValues[1]
|
||||||
parseFontFace(properties, cssPath)?.let { fontFaces.add(it) }
|
parseFontFace(properties, cssPath)?.let { fontFaces.add(it) }
|
||||||
}
|
}
|
||||||
|
|
@ -269,10 +269,10 @@ object CssParser {
|
||||||
|
|
||||||
val fontFamily = propsMap["font-family"]?.removeSurrounding("\"")?.removeSurrounding("'")?.lowercase()
|
val fontFamily = propsMap["font-family"]?.removeSurrounding("\"")?.removeSurrounding("'")?.lowercase()
|
||||||
val srcString = propsMap["src"]
|
val srcString = propsMap["src"]
|
||||||
Timber.d("Parsing font-face for family: $fontFamily. Raw src string: $srcString")
|
ReaderCssLog.d("Parsing font-face for family: $fontFamily. Raw src string: $srcString")
|
||||||
|
|
||||||
if (fontFamily == null || srcString == null) {
|
if (fontFamily == null || srcString == null) {
|
||||||
Timber.w("Incomplete @font-face rule: missing font-family or src.")
|
ReaderCssLog.w("Incomplete @font-face rule: missing font-family or src.")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,25 +280,25 @@ object CssParser {
|
||||||
|
|
||||||
val sources = srcString.split(Regex(",(?=\\s*url\\()")).mapNotNull { part ->
|
val sources = srcString.split(Regex(",(?=\\s*url\\()")).mapNotNull { part ->
|
||||||
val trimmedPart = part.trim()
|
val trimmedPart = part.trim()
|
||||||
Timber.d("Processing src part: '$trimmedPart'")
|
ReaderCssLog.d("Processing src part: '$trimmedPart'")
|
||||||
|
|
||||||
urlWithFormatRegex.find(trimmedPart)?.let {
|
urlWithFormatRegex.find(trimmedPart)?.let {
|
||||||
Timber.d("Matched url with format(). URL: ${it.groupValues[2]}, Format: ${it.groupValues[4]}")
|
ReaderCssLog.d("Matched url with format(). URL: ${it.groupValues[2]}, Format: ${it.groupValues[4]}")
|
||||||
FontSource(url = it.groupValues[2], format = it.groupValues[4].lowercase().removeSurrounding("'"))
|
FontSource(url = it.groupValues[2], format = it.groupValues[4].lowercase().removeSurrounding("'"))
|
||||||
} ?: URL_REGEX.find(trimmedPart)?.let {
|
} ?: URL_REGEX.find(trimmedPart)?.let {
|
||||||
val url = it.groupValues[2]
|
val url = it.groupValues[2]
|
||||||
Timber.d("Matched url() only. URL: '$url'")
|
ReaderCssLog.d("Matched url() only. URL: '$url'")
|
||||||
val format = when {
|
val format = when {
|
||||||
url.startsWith("data:", ignoreCase = true) -> {
|
url.startsWith("data:", ignoreCase = true) -> {
|
||||||
val mediaType = url.substringAfter("data:").substringBefore(';')
|
val mediaType = url.substringAfter("data:").substringBefore(';')
|
||||||
Timber.d("Data URI detected. Media type: '$mediaType'")
|
ReaderCssLog.d("Data URI detected. Media type: '$mediaType'")
|
||||||
when {
|
when {
|
||||||
mediaType.contains("opentype") -> "opentype"
|
mediaType.contains("opentype") -> "opentype"
|
||||||
mediaType.contains("truetype") -> "truetype"
|
mediaType.contains("truetype") -> "truetype"
|
||||||
mediaType.contains("woff2") -> "woff2"
|
mediaType.contains("woff2") -> "woff2"
|
||||||
mediaType.contains("woff") -> "woff"
|
mediaType.contains("woff") -> "woff"
|
||||||
else -> {
|
else -> {
|
||||||
Timber.w("Unknown data URI media type: $mediaType")
|
ReaderCssLog.w("Unknown data URI media type: $mediaType")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -308,11 +308,11 @@ object CssParser {
|
||||||
url.endsWith(".otf", ignoreCase = true) -> "opentype"
|
url.endsWith(".otf", ignoreCase = true) -> "opentype"
|
||||||
url.endsWith(".ttf", ignoreCase = true) -> "truetype"
|
url.endsWith(".ttf", ignoreCase = true) -> "truetype"
|
||||||
else -> {
|
else -> {
|
||||||
Timber.w("Could not determine format from URL: $url")
|
ReaderCssLog.w("Could not determine format from URL: $url")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Timber.d("Determined format: '$format'")
|
ReaderCssLog.d("Determined format: '$format'")
|
||||||
if (format != null) {
|
if (format != null) {
|
||||||
FontSource(url = url, format = format)
|
FontSource(url = url, format = format)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -322,7 +322,7 @@ object CssParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sources.isEmpty()) {
|
if (sources.isEmpty()) {
|
||||||
Timber.w("Could not parse any valid source from @font-face src: $srcString")
|
ReaderCssLog.w("Could not parse any valid source from @font-face src: $srcString")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -337,14 +337,13 @@ object CssParser {
|
||||||
}!!
|
}!!
|
||||||
|
|
||||||
val rawSrc = preferredSource.url
|
val rawSrc = preferredSource.url
|
||||||
Timber.d("Selected font source for '$fontFamily': '${preferredSource.url}' with format '${preferredSource.format}'")
|
ReaderCssLog.d("Selected font source for '$fontFamily': '${preferredSource.url}' with format '${preferredSource.format}'")
|
||||||
|
|
||||||
val finalSrc = if (cssPath != null && !rawSrc.startsWith("data:")) {
|
val finalSrc = if (cssPath != null && !rawSrc.startsWith("data:")) {
|
||||||
try {
|
try {
|
||||||
val cssParentDir = File(cssPath).parent ?: ""
|
resolveCssRelativePath(cssPath, rawSrc)
|
||||||
File(cssParentDir, rawSrc).normalize().path
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Could not resolve font path for src '$rawSrc' in css '$cssPath'")
|
ReaderCssLog.e(e, "Could not resolve font path for src '$rawSrc' in css '$cssPath'")
|
||||||
rawSrc // Fallback to the raw path on error
|
rawSrc // Fallback to the raw path on error
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1038,10 +1037,20 @@ object CssParser {
|
||||||
val r = (colorLong and 0xF00) shr 8
|
val r = (colorLong and 0xF00) shr 8
|
||||||
val g = (colorLong and 0x0F0) shr 4
|
val g = (colorLong and 0x0F0) shr 4
|
||||||
val b = colorLong and 0x00F
|
val b = colorLong and 0x00F
|
||||||
Color(Color(0xFF000000 or ((r * 17) shl 16) or ((g * 17) shl 8) or (b * 17)).toArgb())
|
Color((r * 17).toInt(), (g * 17).toInt(), (b * 17).toInt(), 255)
|
||||||
}
|
}
|
||||||
6 -> Color(Color(0xFF000000 or colorLong).toArgb()) // #RRGGBB
|
6 -> Color(
|
||||||
8 -> Color(Color(colorLong).toArgb()) // #AARRGGBB
|
((colorLong shr 16) and 0xFF).toInt(),
|
||||||
|
((colorLong shr 8) and 0xFF).toInt(),
|
||||||
|
(colorLong and 0xFF).toInt(),
|
||||||
|
255
|
||||||
|
) // #RRGGBB
|
||||||
|
8 -> Color(
|
||||||
|
((colorLong shr 16) and 0xFF).toInt(),
|
||||||
|
((colorLong shr 8) and 0xFF).toInt(),
|
||||||
|
(colorLong and 0xFF).toInt(),
|
||||||
|
((colorLong shr 24) and 0xFF).toInt()
|
||||||
|
) // #AARRGGBB
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package com.aryan.reader.paginatedreader
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared mapper for generic CSS font-family names.
|
||||||
|
*
|
||||||
|
* Platform-specific font loaders can still resolve embedded/custom font files and add their own
|
||||||
|
* FontFamily instances, but generic CSS families should behave consistently everywhere.
|
||||||
|
*/
|
||||||
|
object FontFamilyMapper {
|
||||||
|
private val genericFontMap = mapOf(
|
||||||
|
"serif" to FontFamily.Serif,
|
||||||
|
"sans-serif" to FontFamily.SansSerif,
|
||||||
|
"monospace" to FontFamily.Monospace,
|
||||||
|
"cursive" to FontFamily.Cursive,
|
||||||
|
"default" to FontFamily.Default,
|
||||||
|
"system-ui" to FontFamily.Default,
|
||||||
|
"ui-sans-serif" to FontFamily.Default,
|
||||||
|
"ui-serif" to FontFamily.Default,
|
||||||
|
"ui-monospace" to FontFamily.Default,
|
||||||
|
"ui-rounded" to FontFamily.Default
|
||||||
|
)
|
||||||
|
|
||||||
|
fun nameToFontFamily(name: String): FontFamily? {
|
||||||
|
return genericFontMap[name.trim().lowercase()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fontFamilyToName(fontFamily: FontFamily): String? {
|
||||||
|
return when (fontFamily) {
|
||||||
|
FontFamily.Serif -> "serif"
|
||||||
|
FontFamily.SansSerif -> "sans-serif"
|
||||||
|
FontFamily.Monospace -> "monospace"
|
||||||
|
FontFamily.Cursive -> "cursive"
|
||||||
|
FontFamily.Default -> "default"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||