diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0ea1194..d6813ed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { applicationId = "com.aryan.reader" minSdk = 26 targetSdk = 35 - versionCode = 45 - versionName = "1.0.45" + versionCode = 46 + versionName = "1.0.46" resourceConfigurations += setOf("en", "ar", "de", "tr") @@ -139,6 +139,17 @@ android { version = "3.22.1" } } + testOptions { + unitTests.isReturnDefaultValues = true + unitTests.all { + it.jvmArgs("-Xss2m") + } + } + configurations { + named("testImplementation") { + exclude(group = "org.slf4j", module = "slf4j-android") + } + } } //noinspection UseTomlInstead dependencies { @@ -230,12 +241,12 @@ dependencies { 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") + + testImplementation("junit:junit:4.13.2") + testImplementation("io.mockk:mockk-android:1.14.9") + testImplementation(libs.kotlinx.coroutines.test) + testImplementation("org.slf4j:slf4j-nop:2.0.17") } spotless { diff --git a/app/libs/custom-onnxruntime-arm64.aar b/app/libs/custom-onnxruntime-arm64.aar new file mode 100644 index 0000000..378f85c Binary files /dev/null and b/app/libs/custom-onnxruntime-arm64.aar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ec86a8b..0135e2c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -82,4 +82,9 @@ -keep class com.aryan.reader.pdf.NativePdfiumBridge { *; -} \ No newline at end of file +} + +# Preserve ONNX Runtime Java classes +-keep class ai.onnxruntime.** { *; } +-keepnames class ai.onnxruntime.** { *; } +-keepclassmembers class ai.onnxruntime.** { *; } \ No newline at end of file diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index ba8d471..994a086 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -79,7 +79,7 @@ } img, svg, video, canvas { - max-width: 100%; width: 100%; height: auto; display: block; margin-left: auto; margin-right: auto; background-color: transparent; object-fit: contain; + max-width: 100%; width: auto; height: auto; display: block; margin-left: auto; margin-right: auto; background-color: transparent; object-fit: contain; } figure img { @@ -481,10 +481,18 @@ if (anchor) { var href = anchor.getAttribute('href'); var epubType = anchor.getAttribute('epub:type'); + var linkText = (anchor.textContent || '').trim().substring(0, 80); + + console.log("LINK_NAV: [JS-CLICK] href='" + href + "', epub:type='" + epubType + "', label='" + linkText + "'"); + + if (window.LinkNavBridge && window.LinkNavBridge.onLinkClicked) { + window.LinkNavBridge.onLinkClicked(href || '', epubType || '', linkText); + } console.log("FootnoteDiag: Link clicked. href: '" + href + "', epub:type: '" + epubType + "'"); - if ((href && href.startsWith('#')) || epubType === 'noteref') { + if ((href && href.startsWith('#')) || epubType === 'noteref') { + console.log("LINK_NAV: [JS-CLASSIFY] type=FRAGMENT_OR_FOOTNOTE, href='" + href + "'"); var targetId = href ? href.substring(1) : null; console.log("FootnoteDiag: Extracted targetId: '" + targetId + "'"); @@ -504,10 +512,12 @@ } } } + } else { + console.log("LINK_NAV: [JS-NO-ANCHOR] No tag found in click target hierarchy"); } }, true); - window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap) { + window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap, imageSize, horizontalMargin) { var logTag = "ReaderFontDiagnosis"; console.log( logTag + @@ -517,11 +527,15 @@ lineHeight + ", Font: '" + fontFamily + - "', Align: '" + - textAlign + - "', Gap: " + - paragraphGap - ); + "', Align: '" + + textAlign + + "', Gap: " + + paragraphGap + + ", ImageSize: " + + imageSize + + ", HorizontalMargin: " + + horizontalMargin + ); var dynamicStyleId = "dynamicReaderStyles"; var dynamicStyleElement = document.getElementById(dynamicStyleId); @@ -535,10 +549,14 @@ var newFontSize = parseFloat(fontSizeEm); var newLineHeight = parseFloat(lineHeight); var newGap = parseFloat(paragraphGap); + var newImageSize = parseFloat(imageSize); + var newHorizontalMargin = parseFloat(horizontalMargin); if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0; if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.0; if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0; + if (isNaN(newImageSize) || newImageSize < 0.5 || newImageSize > 2.0) newImageSize = 1.0; + if (isNaN(newHorizontalMargin) || newHorizontalMargin < 0.0 || newHorizontalMargin > 3.0) newHorizontalMargin = 1.0; var fontCss = ""; if (fontFamily && fontFamily !== "Original" && fontFamily !== "") { @@ -590,7 +608,31 @@ `; } - dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss].join("\n"); + var horizontalPaddingPx = Math.max(0, 16 * newHorizontalMargin); + var horizontalMarginCss = ` + body { + box-sizing: border-box !important; + padding-left: ${horizontalPaddingPx}px !important; + padding-right: ${horizontalPaddingPx}px !important; + } + `; + + var imageCss = ` + :root { + --reader-image-size: ${newImageSize}; + } + body img, + body svg, + body video, + body canvas, + body image { + width: min(100%, calc(100% * var(--reader-image-size))) !important; + max-width: 100% !important; + height: auto !important; + } + `; + + dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n"); setTimeout( function () { diff --git a/app/src/main/assets/google_fonts.json b/app/src/main/assets/google_fonts.json new file mode 100644 index 0000000..52a81bc --- /dev/null +++ b/app/src/main/assets/google_fonts.json @@ -0,0 +1,2083 @@ +[ + "42dot Sans", + "ABeeZee", + "ADLaM Display", + "AR One Sans", + "Abel", + "Abhaya Libre", + "Aboreto", + "Abril Fatface", + "Abyssinica SIL", + "Aclonica", + "Acme", + "Actor", + "Adamina", + "Advent Pro", + "Adwaita Mono", + "Adwaita Sans", + "Afacad", + "Afacad Flux", + "Agbalumo", + "Agdasima", + "Agu Display", + "Aguafina Script", + "Aileron", + "Akatab", + "Akaya Kanadaka", + "Akaya Telivigala", + "Akronim", + "Akshar", + "Aladin", + "Alan Sans", + "Alata", + "Alatsi", + "Albert Sans", + "Aldrich", + "Alef", + "Alegreya", + "Alegreya SC", + "Alegreya Sans", + "Alegreya Sans SC", + "Aleo", + "Alex Brush", + "Alexandria", + "Alfa Slab One", + "Alice", + "Alike", + "Alike Angular", + "Alkalami", + "Alkatra", + "Allan", + "Allerta", + "Allerta Stencil", + "Allison", + "Allkin", + "Allura", + "Almarai", + "Almendra", + "Almendra Display", + "Almendra SC", + "Alumni Sans", + "Alumni Sans Collegiate One", + "Alumni Sans Inline One", + "Alumni Sans Pinstripe", + "Alumni Sans SC", + "Alyamama", + "Amarante", + "Amaranth", + "Amarna", + "Amatic SC", + "Amethysta", + "Amiko", + "Amiri", + "Amiri Quran", + "Amita", + "Anaheim", + "Ancizar Sans", + "Ancizar Serif", + "Andada Pro", + "Andika", + "Anek Bangla", + "Anek Devanagari", + "Anek Gujarati", + "Anek Gurmukhi", + "Anek Kannada", + "Anek Latin", + "Anek Malayalam", + "Anek Odia", + "Anek Tamil", + "Anek Telugu", + "Angkor", + "Annapurna SIL", + "Annie Use Your Telescope", + "Anonymous Pro", + "Anta", + "Antic", + "Antic Didone", + "Antic Slab", + "Anton", + "Anton SC", + "Antonio", + "Anuphan", + "Anybody", + "Aoboshi One", + "Apfel Grotezk", + "Arapey", + "Arbutus", + "Arbutus Slab", + "Architects Daughter", + "Archivo", + "Archivo Black", + "Archivo Narrow", + "Are You Serious", + "Aref Ruqaa", + "Aref Ruqaa Ink", + "Argentum Sans", + "Arima", + "Arima Madurai", + "Arimo", + "Arizonia", + "Armata", + "Arsenal", + "Arsenal SC", + "Artifika", + "Arvo", + "Arya", + "Asap", + "Asap Condensed", + "Asar", + "Asimovian", + "Asset", + "Assistant", + "Asta Sans", + "Astloch", + "Asul", + "Athiti", + "Atkinson Hyperlegible", + "Atkinson Hyperlegible Mono", + "Atkinson Hyperlegible Next", + "Atma", + "Atomic Age", + "Aubrey", + "Audiowide", + "Autour One", + "Average", + "Average Sans", + "Averia Gruesa Libre", + "Averia Libre", + "Averia Sans Libre", + "Averia Serif Libre", + "Azeret Mono", + "B612", + "B612 Mono", + "BBH Bartle", + "BBH Bogle", + "BBH Hegarty", + "BBH Sans Bartle", + "BBH Sans Bogle", + "BBH Sans Hegarty", + "BIZ UDGothic", + "BIZ UDMincho", + "BIZ UDPGothic", + "BIZ UDPMincho", + "BJ Cree", + "BJCree", + "Babylonica", + "Bacasime Antique", + "Bad Script", + "Badeen Display", + "Bagel Fat One", + "Bagnard", + "Bagnard Sans", + "Bahiana", + "Bahianita", + "Bai Jamjuree", + "Bakbak One", + "Ballet", + "Baloo 2", + "Baloo Bhai 2", + "Baloo Bhaijaan 2", + "Baloo Bhaina 2", + "Baloo Chettan 2", + "Baloo Da 2", + "Baloo Paaji 2", + "Baloo Tamma 2", + "Baloo Tammudu 2", + "Baloo Thambi 2", + "Balsamiq Sans", + "Balthazar", + "Bangers", + "Barlow", + "Barlow Condensed", + "Barlow Semi Condensed", + "Barriecito", + "Barrio", + "Basic", + "Baskervville", + "Baskervville SC", + "Battambang", + "Baumans", + "Bayon", + "Be Vietnam Pro", + "Beau Rivage", + "Bebas Neue", + "Beiruti", + "Belanosima", + "Belgrano", + "Bellefair", + "Belleza", + "Bellota", + "Bellota Text", + "BenchNine", + "Benne", + "Bentham", + "Berkshire Swash", + "Besley", + "Betania Patmos", + "Betania Patmos GDL", + "Betania Patmos In", + "Betania Patmos In GDL", + "Beth Ellen", + "Bevan", + "BhuTuka Expanded One", + "Big Shoulders", + "Big Shoulders Display", + "Big Shoulders Inline", + "Big Shoulders Inline Display", + "Big Shoulders Inline Text", + "Big Shoulders Stencil", + "Big Shoulders Stencil Display", + "Big Shoulders Stencil Text", + "Big Shoulders Text", + "Bigelow Rules", + "Bigshot One", + "Bilbo", + "Bilbo Swash Caps", + "BioRhyme", + "BioRhyme Expanded", + "Birthstone", + "Birthstone Bounce", + "Biryani", + "Bitcount", + "Bitcount Grid Double", + "Bitcount Grid Double Ink", + "Bitcount Grid Single", + "Bitcount Grid Single Ink", + "Bitcount Ink", + "Bitcount Prop Double", + "Bitcount Prop Double Ink", + "Bitcount Prop Single", + "Bitcount Prop Single Ink", + "Bitcount Single", + "Bitcount Single Ink", + "Bitter", + "Black And White Picture", + "Black Han Sans", + "Black Ops One", + "Blackout Midnight", + "Blackout Sunrise", + "Blackout Two AM", + "Blaka", + "Blaka Hollow", + "Blaka Ink", + "Blinker", + "Bluu Next", + "Bodoni Moda", + "Bodoni Moda SC", + "Bokor", + "Boldonse", + "Bona Nova", + "Bona Nova SC", + "Bonbon", + "Bonheur Royale", + "Boogaloo", + "Borel", + "Bowlby One", + "Bowlby One SC", + "Bpmf Huninn", + "Bpmf Iansui", + "Bpmf Zihi Kai Std", + "Braah One", + "Bravura", + "Bravura Text", + "Brawler", + "Bree Serif", + "Bricolage Grotesque", + "Briem Hand", + "Bruno Ace", + "Bruno Ace SC", + "Brygada 1918", + "Bubblegum Sans", + "Bubbler One", + "Buda", + "Buenard", + "Bungee", + "Bungee Hairline", + "Bungee Inline", + "Bungee Outline", + "Bungee Shade", + "Bungee Spice", + "Bungee Tint", + "Butcherman", + "Butterfly Kids", + "Bytesized", + "Cabin", + "Cabin Condensed", + "Cabin Sketch", + "Cactus Classical Serif", + "Caesar Dressing", + "Cagliostro", + "Cairo", + "Cairo Play", + "Cal Sans", + "Caladea", + "Calistoga", + "Calligraffitti", + "Cambay", + "Cambo", + "Candal", + "Cantarell", + "Cantata One", + "Cantora One", + "Caprasimo", + "Capriola", + "Caramel", + "Carattere", + "Cardo", + "Carlito", + "Carme", + "Carrois Gothic", + "Carrois Gothic SC", + "Carter One", + "Cascadia Code", + "Cascadia Mono", + "Castoro", + "Castoro Titling", + "Catamaran", + "Caudex", + "Cause", + "Caveat", + "Caveat Brush", + "Cedarville Cursive", + "Ceviche One", + "Chakra Petch", + "Changa", + "Changa One", + "Chango", + "Charis SIL", + "Charm", + "Charmonman", + "Chathura", + "Chau Philomene One", + "Chela One", + "Chelsea Market", + "Chenla", + "Cherish", + "Cherry Bomb One", + "Cherry Cream Soda", + "Cherry Swash", + "Chewy", + "Chicle", + "Chilanka", + "Chiron GoRound TC", + "Chiron Hei HK", + "Chiron Sung HK", + "Chivo", + "Chivo Mono", + "Chocolate Classical Sans", + "Chokokutai", + "Chonburi", + "Chunk Five", + "Cinzel", + "Cinzel Decorative", + "Clear Sans", + "Clicker Script", + "Climate Crisis", + "Coda", + "Coda Caption", + "Codystar", + "Coiny", + "Combo", + "Comfortaa", + "Comforter", + "Comforter Brush", + "Comic Mono", + "Comic Neue", + "Comic Relief", + "Coming Soon", + "Comme", + "Commissioner", + "Commit Mono", + "Concert One", + "Condiment", + "Content", + "Contrail One", + "Convergence", + "Cookie", + "Cooper Hewitt", + "Copse", + "Coral Pixels", + "Corben", + "Corinthia", + "Cormorant", + "Cormorant Garamond", + "Cormorant Infant", + "Cormorant SC", + "Cormorant Unicase", + "Cormorant Upright", + "Cossette Texte", + "Cossette Titre", + "Courgette", + "Courier Prime", + "Cousine", + "Coustard", + "Covered By Your Grace", + "Crafty Girls", + "Creepster", + "Crete Round", + "Crimson Pro", + "Crimson Text", + "Croissant One", + "Crushed", + "Cuprum", + "Cute Font", + "Cutive", + "Cutive Mono", + "DM Mono", + "DM Sans", + "DM Serif Display", + "DM Serif Text", + "DSEG Weather", + "DSEG14 Classic", + "DSEG14 Classic Mini", + "DSEG14 Modern", + "DSEG14 Modern Mini", + "DSEG7 Classic", + "DSEG7 Classic Mini", + "DSEG7 Modern", + "DSEG7 Modern Mini", + "DSEG7 SEGG CHAN", + "DSEG7 SEGG CHAN Mini", + "Dai Banna SIL", + "Damion", + "Dancing Script", + "Danfo", + "Dangrek", + "Darker Grotesque", + "Darumadrop One", + "Datatype", + "David Libre", + "Dawning of a New Day", + "Days One", + "DejaVu Math", + "DejaVu Mono", + "DejaVu Sans", + "DejaVu Serif", + "Dekko", + "Dela Gothic One", + "Delicious Handrawn", + "Delius", + "Delius Swash Caps", + "Delius Unicase", + "Della Respira", + "Denk One", + "Devonshire", + "Dhurjati", + "Didact Gothic", + "Diphylleia", + "Diplomata", + "Diplomata SC", + "Do Hyeon", + "Dokdo", + "Domine", + "Donegal One", + "Dongle", + "Doppio One", + "Dorsa", + "Dosis", + "DotGothic16", + "Doto", + "Dr Sugiyama", + "Duru Sans", + "DynaPuff", + "Dynalight", + "EB Garamond", + "Eagle Lake", + "East Sea Dokdo", + "Eater", + "Economica", + "Eczar", + "Edu AU VIC WA NT Arrows", + "Edu AU VIC WA NT Dots", + "Edu AU VIC WA NT Guides", + "Edu AU VIC WA NT Hand", + "Edu AU VIC WA NT Pre", + "Edu NSW ACT Cursive", + "Edu NSW ACT Foundation", + "Edu NSW ACT Hand Pre", + "Edu QLD Beginner", + "Edu QLD Hand", + "Edu SA Beginner", + "Edu SA Hand", + "Edu TAS Beginner", + "Edu VIC WA NT Beginner", + "Edu VIC WA NT Hand", + "Edu VIC WA NT Hand Pre", + "El Messiri", + "Electrolize", + "Elms Sans", + "Elsie", + "Elsie Swash Caps", + "Emblema One", + "Emilys Candy", + "Encode Sans", + "Encode Sans Condensed", + "Encode Sans Expanded", + "Encode Sans SC", + "Encode Sans Semi Condensed", + "Encode Sans Semi Expanded", + "Engagement", + "Englebert", + "Enriqueta", + "Ephesis", + "Epilogue", + "Epunda Sans", + "Epunda Slab", + "Erica One", + "Esteban", + "Estonia", + "Euphoria Script", + "Ewert", + "Exile", + "Exo", + "Exo 2", + "Expletus Sans", + "Explora", + "Faculty Glyphic", + "Fahkwang", + "Familjen Grotesk", + "Fanwood Text", + "Farro", + "Farsan", + "Fascinate", + "Fascinate Inline", + "Faster One", + "Fasthand", + "Fauna One", + "Faustina", + "Federant", + "Federo", + "Felipa", + "Fenix", + "Festive", + "Figtree", + "Finger Paint", + "Finlandica", + "Fira Code", + "Fira Mono", + "Fira Sans", + "Fira Sans Condensed", + "Fira Sans Extra Condensed", + "FiraGO", + "Fjalla One", + "Fjord One", + "Flamenco", + "Flavors", + "Fleur De Leah", + "Flow Block", + "Flow Circular", + "Flow Rounded", + "Foldit", + "Fondamento", + "Fontdiner Swanky", + "Forum", + "Fragment Mono", + "Francois One", + "Frank Ruhl Libre", + "Fraunces", + "Freckle Face", + "Fredericka the Great", + "Fredoka", + "Fredoka One", + "Freehand", + "Freeman", + "Fresca", + "Frijole", + "Fruktur", + "Fugaz One", + "Fuggles", + "Funnel Display", + "Funnel Sans", + "Fusion Kai G", + "Fusion Kai J", + "Fusion Kai T", + "Fusion Pixel 10px Monospaced JP", + "Fusion Pixel 10px Monospaced KR", + "Fusion Pixel 10px Monospaced SC", + "Fusion Pixel 10px Monospaced TC", + "Fusion Pixel 10px Proportional JP", + "Fusion Pixel 10px Proportional KR", + "Fusion Pixel 10px Proportional SC", + "Fusion Pixel 10px Proportional TC", + "Fusion Pixel 12px Monospaced JP", + "Fusion Pixel 12px Monospaced KR", + "Fusion Pixel 12px Monospaced SC", + "Fusion Pixel 12px Monospaced TC", + "Fusion Pixel 12px Proportional JP", + "Fusion Pixel 12px Proportional KR", + "Fusion Pixel 12px Proportional SC", + "Fusion Pixel 12px Proportional TC", + "Fusion Pixel 8px Monospaced JP", + "Fusion Pixel 8px Monospaced KR", + "Fusion Pixel 8px Monospaced SC", + "Fusion Pixel 8px Monospaced TC", + "Fusion Pixel 8px Proportional JP", + "Fusion Pixel 8px Proportional KR", + "Fusion Pixel 8px Proportional SC", + "Fusion Pixel 8px Proportional TC", + "Fustat", + "Fuzzy Bubbles", + "GFS Didot", + "GFS Neohellenic", + "Ga Maamli", + "Gabarito", + "Gabriela", + "Gaegu", + "Gafata", + "Gajraj One", + "Galada", + "Galdeano", + "Galindo", + "Gamja Flower", + "Gantari", + "Gasoek One", + "Gayathri", + "Geist", + "Geist Mono", + "Geist Sans", + "Gelasio", + "Gemunu Libre", + "Genjyuu Gothic", + "Genos", + "Gentium Book Basic", + "Gentium Book Plus", + "Gentium Plus", + "Geo", + "Geologica", + "Geom", + "Georama", + "Geostar", + "Geostar Fill", + "Germania One", + "Gideon Roman", + "Gidole", + "Gidugu", + "Gilda Display", + "Girassol", + "Give You Glory", + "Glass Antiqua", + "Glegoo", + "Gloock", + "Gloria Hallelujah", + "Glory", + "Gluten", + "Goblin One", + "Gochi Hand", + "Goldman", + "Golos Text", + "Google Sans", + "Google Sans Code", + "Google Sans Flex", + "Gorditas", + "Gothic A1", + "Gotu", + "Goudy Bookletter 1911", + "Gowun Batang", + "Gowun Dodum", + "Graduate", + "Grand Hotel", + "Grandiflora One", + "Grandstander", + "Grape Nuts", + "Gravitas One", + "Great Vibes", + "Grechen Fuemen", + "Grenze", + "Grenze Gotisch", + "Grey Qo", + "Griffy", + "Gruppo", + "Gudea", + "Gugi", + "Gulzar", + "Gupter", + "Gurajada", + "Gveret Levin", + "Gwendolyn", + "Habibi", + "Hachi Maru Pop", + "Hahmlet", + "Halant", + "Hammersmith One", + "Hanalei", + "Hanalei Fill", + "Handjet", + "Handlee", + "Hanken Grotesk", + "Hanuman", + "Happy Monkey", + "Harmattan", + "Hauora Sans", + "Headland One", + "Hedvig Letters Sans", + "Hedvig Letters Serif", + "Heebo", + "Henny Penny", + "Hepta Slab", + "Herr Von Muellerhoff", + "Hi Melody", + "Hina Mincho", + "Hind", + "Hind Guntur", + "Hind Madurai", + "Hind Mysuru", + "Hind Siliguri", + "Hind Vadodara", + "Holtwood One SC", + "Homemade Apple", + "Homenaje", + "Honk", + "Host Grotesk", + "Hubballi", + "Hubot Sans", + "Huninn", + "Hurricane", + "IBM Plex Mono", + "IBM Plex Sans", + "IBM Plex Sans Arabic", + "IBM Plex Sans Condensed", + "IBM Plex Sans Devanagari", + "IBM Plex Sans Hebrew", + "IBM Plex Sans JP", + "IBM Plex Sans KR", + "IBM Plex Sans Thai", + "IBM Plex Sans Thai Looped", + "IBM Plex Serif", + "IM Fell DW Pica", + "IM Fell DW Pica SC", + "IM Fell Double Pica", + "IM Fell Double Pica SC", + "IM Fell English", + "IM Fell English SC", + "IM Fell French Canon", + "IM Fell French Canon SC", + "IM Fell Great Primer", + "IM Fell Great Primer SC", + "Iansui", + "Ibarra Real Nova", + "Iceberg", + "Iceland", + "Idiqlat", + "Imbue", + "Imperial Script", + "Imprima", + "Inclusive Sans", + "Inconsolata", + "Inder", + "Indie Flower", + "Ingrid Darling", + "Inika", + "Inknut Antiqua", + "Inria Sans", + "Inria Serif", + "Inspiration", + "Instrument Sans", + "Instrument Serif", + "Intel One Mono", + "Inter", + "Inter Tight", + "Iosevka", + "Iosevka Aile", + "Iosevka Charon", + "Iosevka Charon Mono", + "Iosevka Curly", + "Iosevka Curly Slab", + "Iosevka Etoile", + "Irish Grover", + "Island Moments", + "Istok Web", + "Italiana", + "Italianno", + "Itim", + "Jacquard 12", + "Jacquard 12 Charted", + "Jacquard 24", + "Jacquard 24 Charted", + "Jacquarda Bastarda 9", + "Jacquarda Bastarda 9 Charted", + "Jacques Francois", + "Jacques Francois Shadow", + "Jaini", + "Jaini Purva", + "Jaldi", + "Jaro", + "Jersey 10", + "Jersey 10 Charted", + "Jersey 15", + "Jersey 15 Charted", + "Jersey 20", + "Jersey 20 Charted", + "Jersey 25", + "Jersey 25 Charted", + "JetBrains Mono", + "Jim Nightshade", + "Joan", + "Jockey One", + "Jolly Lodger", + "Jomhuria", + "Jomolhari", + "Josefin Sans", + "Josefin Slab", + "Jost", + "Joti One", + "Jua", + "Judson", + "Julee", + "Julius Sans One", + "Junction", + "Junge", + "Jura", + "Just Another Hand", + "Just Me Again Down Here", + "K2D", + "Kablammo", + "Kadwa", + "Kaisei Decol", + "Kaisei HarunoUmi", + "Kaisei Opti", + "Kaisei Tokumin", + "Kalam", + "Kalnia", + "Kalnia Glaze", + "Kameron", + "Kanchenjunga", + "Kanit", + "Kantumruy", + "Kantumruy Pro", + "Kapakana", + "Karantina", + "Karla", + "Karla Tamil Inclined", + "Karla Tamil Upright", + "Karma", + "Karmilla", + "Katibeh", + "Kaushan Script", + "Kavivanar", + "Kavoon", + "Kay Pho Du", + "Kdam Thmor Pro", + "Keania One", + "Kedebideri", + "Kelly Slab", + "Kenia", + "Khand", + "Khmer", + "Khula", + "Kings", + "Kirang Haerang", + "Kite One", + "Kiwi Maru", + "Klee One", + "Knewave", + "KoHo", + "Kodchasan", + "Kode Mono", + "Koh Santepheap", + "Kolker Brush", + "Konkhmer Sleokchher", + "Kosugi", + "Kosugi Maru", + "Kotta One", + "Koulen", + "Kranky", + "Kreon", + "Kristi", + "Krona One", + "Krub", + "Kufam", + "Kulim Park", + "Kumar One", + "Kumar One Outline", + "Kumbh Sans", + "Kurale", + "LINE Seed JP", + "LXGW Marker Gothic", + "LXGW WenKai", + "LXGW WenKai Mono TC", + "LXGW WenKai TC", + "La Belle Aurore", + "Labrada", + "Lacquer", + "Laila", + "Lakki Reddy", + "Lalezar", + "Lancelot", + "Langar", + "Lateef", + "Lato", + "Lavishly Yours", + "League Gothic", + "League Mono", + "League Script", + "League Spartan", + "Leckerli One", + "Ledger", + "Lekton", + "Lemon", + "Lemonada", + "Lexend", + "Lexend Deca", + "Lexend Exa", + "Lexend Giga", + "Lexend Mega", + "Lexend Peta", + "Lexend Tera", + "Lexend Zetta", + "Lextrall", + "Libertinus Keyboard", + "Libertinus Math", + "Libertinus Mono", + "Libertinus Sans", + "Libertinus Serif", + "Libertinus Serif Display", + "Libre Barcode 128", + "Libre Barcode 128 Text", + "Libre Barcode 39", + "Libre Barcode 39 Extended", + "Libre Barcode 39 Extended Text", + "Libre Barcode 39 Text", + "Libre Barcode EAN13 Text", + "Libre Baskerville", + "Libre Bodoni", + "Libre Caslon Condensed", + "Libre Caslon Display", + "Libre Caslon Text", + "Libre Franklin", + "Licorice", + "Life Savers", + "Lilex", + "Lilita One", + "Lily Script One", + "Limelight", + "Linden Hill", + "Linefont", + "Lisu Bosa", + "Liter", + "Literata", + "Liu Jian Mao Cao", + "Livvic", + "Lobster", + "Lobster Two", + "Londrina Outline", + "Londrina Shadow", + "Londrina Sketch", + "Londrina Solid", + "Long Cang", + "Lora", + "Love Light", + "Love Ya Like A Sister", + "Loved by the King", + "Lovers Quarrel", + "Luckiest Guy", + "Lugrasimo", + "Lumanosimo", + "Lunasima", + "Lusitana", + "Lustria", + "Luxurious Roman", + "Luxurious Script", + "M PLUS 1", + "M PLUS 1 Code", + "M PLUS 1p", + "M PLUS 2", + "M PLUS Code Latin", + "M PLUS Rounded 1c", + "Ma Shan Zheng", + "Macondo", + "Macondo Swash Caps", + "Mada", + "Madimi One", + "Magra", + "Maiden Orange", + "Maitree", + "Major Mono Display", + "Mako", + "Mali", + "Mallanna", + "Maname", + "Mandali", + "Manjari", + "Manrope", + "Mansalva", + "Manuale", + "Manufacturing Consent", + "Maple Mono", + "Marcellus", + "Marcellus SC", + "Marck Script", + "Margarine", + "Marhey", + "Markazi Text", + "Marko One", + "Marmelad", + "Martel", + "Martel Sans", + "Martian Mono", + "Marvel", + "Matangi", + "Mate", + "Mate SC", + "Matemasie", + "Material Icons", + "Material Icons Outlined", + "Material Icons Round", + "Material Icons Sharp", + "Material Icons Two Tone", + "Material Symbols", + "Material Symbols Outlined", + "Material Symbols Rounded", + "Material Symbols Sharp", + "Maven Pro", + "McLaren", + "Mea Culpa", + "Meddon", + "MedievalSharp", + "Medula One", + "Meera Inimai", + "Megrim", + "Meie Script", + "Menbere", + "Meow Script", + "Merienda", + "Merienda One", + "Merriweather", + "Merriweather Sans", + "Metal", + "Metal Mania", + "Metamorphous", + "Metrophobic", + "Metropolis", + "Michroma", + "Micro 5", + "Micro 5 Charted", + "Milonga", + "Miltonian", + "Miltonian Tattoo", + "Mina", + "Mingzat", + "Miniver", + "Miranda Sans", + "Miriam Libre", + "Mirza", + "Miss Fajardose", + "Mitr", + "Mochiy Pop One", + "Mochiy Pop P One", + "Modak", + "Modern Antiqua", + "Moderustic", + "Mogra", + "Mohave", + "Moirai One", + "Molengo", + "Molle", + "Momo Signature", + "Momo Trust Display", + "Momo Trust Sans", + "Mona Sans", + "Monaspace Argon", + "Monaspace Krypton", + "Monaspace Neon", + "Monaspace Radon", + "Monaspace Xenon", + "Monda", + "Monofett", + "Monomakh", + "Monomaniac One", + "Mononoki", + "Monoton", + "Monsieur La Doulaise", + "Montaga", + "Montagu Slab", + "MonteCarlo", + "Montez", + "Montserrat", + "Montserrat Alternates", + "Montserrat Subrayada", + "Montserrat Underline", + "Moo Lah Lah", + "Mooli", + "Moon Dance", + "Moul", + "Moulpali", + "Mountains of Christmas", + "Mouse Memoirs", + "Mozilla Headline", + "Mozilla Text", + "Mr Bedfort", + "Mr Dafoe", + "Mr De Haviland", + "Mrs Saint Delafield", + "Mrs Sheppards", + "Ms Madi", + "Mukta", + "Mukta Mahee", + "Mukta Malar", + "Mukta Vaani", + "Mulish", + "Murecho", + "MuseoModerno", + "My Soul", + "Mynerve", + "Mystery Quest", + "NTR", + "Nabla", + "Namdhinggo", + "Nanum Brush Script", + "Nanum Gothic", + "Nanum Gothic Coding", + "Nanum Myeongjo", + "Nanum Pen Script", + "Narnoor", + "Nata Sans", + "National Park", + "Nebula Sans", + "Neonderthaw", + "Nerko One", + "Neucha", + "Neuton", + "New Amsterdam", + "New Rocker", + "New Tegomin", + "News Cycle", + "Newsreader", + "Niconne", + "Niramit", + "Nixie One", + "Nobile", + "Nokora", + "Norican", + "Norwester", + "Nosifer", + "Notable", + "Nothing You Could Do", + "Noticia Text", + "Noto Color Emoji", + "Noto Emoji", + "Noto Kufi Arabic", + "Noto Mono", + "Noto Music", + "Noto Naskh Arabic", + "Noto Nastaliq Urdu", + "Noto Rashi Hebrew", + "Noto Sans", + "Noto Sans Adlam", + "Noto Sans Adlam Unjoined", + "Noto Sans Anatolian Hieroglyphs", + "Noto Sans Arabic", + "Noto Sans Armenian", + "Noto Sans Avestan", + "Noto Sans Balinese", + "Noto Sans Bamum", + "Noto Sans Bassa Vah", + "Noto Sans Batak", + "Noto Sans Bengali", + "Noto Sans Bhaiksuki", + "Noto Sans Brahmi", + "Noto Sans Buginese", + "Noto Sans Buhid", + "Noto Sans Canadian Aboriginal", + "Noto Sans Carian", + "Noto Sans Caucasian Albanian", + "Noto Sans Chakma", + "Noto Sans Cham", + "Noto Sans Cherokee", + "Noto Sans Chorasmian", + "Noto Sans Coptic", + "Noto Sans Cuneiform", + "Noto Sans Cypriot", + "Noto Sans Cypro Minoan", + "Noto Sans Deseret", + "Noto Sans Devanagari", + "Noto Sans Display", + "Noto Sans Duployan", + "Noto Sans Egyptian Hieroglyphs", + "Noto Sans Elbasan", + "Noto Sans Elymaic", + "Noto Sans Ethiopic", + "Noto Sans Georgian", + "Noto Sans Glagolitic", + "Noto Sans Gothic", + "Noto Sans Grantha", + "Noto Sans Gujarati", + "Noto Sans Gunjala Gondi", + "Noto Sans Gurmukhi", + "Noto Sans HK", + "Noto Sans Hanifi Rohingya", + "Noto Sans Hanunoo", + "Noto Sans Hatran", + "Noto Sans Hebrew", + "Noto Sans Imperial Aramaic", + "Noto Sans Indic Siyaq Numbers", + "Noto Sans Inscriptional Pahlavi", + "Noto Sans Inscriptional Parthian", + "Noto Sans JP", + "Noto Sans Javanese", + "Noto Sans KR", + "Noto Sans Kaithi", + "Noto Sans Kannada", + "Noto Sans Kawi", + "Noto Sans Kayah Li", + "Noto Sans Kharoshthi", + "Noto Sans Khmer", + "Noto Sans Khojki", + "Noto Sans Khudawadi", + "Noto Sans Lao", + "Noto Sans Lao Looped", + "Noto Sans Lepcha", + "Noto Sans Limbu", + "Noto Sans Linear A", + "Noto Sans Linear B", + "Noto Sans Lisu", + "Noto Sans Lycian", + "Noto Sans Lydian", + "Noto Sans Mahajani", + "Noto Sans Malayalam", + "Noto Sans Mandaic", + "Noto Sans Manichaean", + "Noto Sans Marchen", + "Noto Sans Masaram Gondi", + "Noto Sans Math", + "Noto Sans Mayan Numerals", + "Noto Sans Medefaidrin", + "Noto Sans Meetei Mayek", + "Noto Sans Mende Kikakui", + "Noto Sans Meroitic", + "Noto Sans Miao", + "Noto Sans Modi", + "Noto Sans Mongolian", + "Noto Sans Mono", + "Noto Sans Mro", + "Noto Sans Multani", + "Noto Sans Myanmar", + "Noto Sans NKo", + "Noto Sans NKo Unjoined", + "Noto Sans Nabataean", + "Noto Sans Nag Mundari", + "Noto Sans Nandinagari", + "Noto Sans New Tai Lue", + "Noto Sans Newa", + "Noto Sans Nushu", + "Noto Sans Ogham", + "Noto Sans Ol Chiki", + "Noto Sans Old Hungarian", + "Noto Sans Old Italic", + "Noto Sans Old North Arabian", + "Noto Sans Old Permic", + "Noto Sans Old Persian", + "Noto Sans Old Sogdian", + "Noto Sans Old South Arabian", + "Noto Sans Old Turkic", + "Noto Sans Oriya", + "Noto Sans Osage", + "Noto Sans Osmanya", + "Noto Sans Pahawh Hmong", + "Noto Sans Palmyrene", + "Noto Sans Pau Cin Hau", + "Noto Sans Phags Pa", + "Noto Sans PhagsPa", + "Noto Sans Phoenician", + "Noto Sans Psalter Pahlavi", + "Noto Sans Rejang", + "Noto Sans Runic", + "Noto Sans SC", + "Noto Sans Samaritan", + "Noto Sans Saurashtra", + "Noto Sans Sharada", + "Noto Sans Shavian", + "Noto Sans Siddham", + "Noto Sans SignWriting", + "Noto Sans Sinhala", + "Noto Sans Sogdian", + "Noto Sans Sora Sompeng", + "Noto Sans Soyombo", + "Noto Sans Sundanese", + "Noto Sans Sunuwar", + "Noto Sans Syloti Nagri", + "Noto Sans Symbols", + "Noto Sans Symbols 2", + "Noto Sans Syriac", + "Noto Sans Syriac Eastern", + "Noto Sans Syriac Western", + "Noto Sans TC", + "Noto Sans Tagalog", + "Noto Sans Tagbanwa", + "Noto Sans Tai Le", + "Noto Sans Tai Tham", + "Noto Sans Tai Viet", + "Noto Sans Takri", + "Noto Sans Tamil", + "Noto Sans Tamil Supplement", + "Noto Sans Tangsa", + "Noto Sans Telugu", + "Noto Sans Thaana", + "Noto Sans Thai", + "Noto Sans Thai Looped", + "Noto Sans Tifinagh", + "Noto Sans Tirhuta", + "Noto Sans Ugaritic", + "Noto Sans Vai", + "Noto Sans Vithkuqi", + "Noto Sans Wancho", + "Noto Sans Warang Citi", + "Noto Sans Yi", + "Noto Sans Zanabazar Square", + "Noto Serif", + "Noto Serif Ahom", + "Noto Serif Armenian", + "Noto Serif Balinese", + "Noto Serif Bengali", + "Noto Serif Devanagari", + "Noto Serif Display", + "Noto Serif Dives Akuru", + "Noto Serif Dogra", + "Noto Serif Ethiopic", + "Noto Serif Georgian", + "Noto Serif Grantha", + "Noto Serif Gujarati", + "Noto Serif Gurmukhi", + "Noto Serif HK", + "Noto Serif Hebrew", + "Noto Serif Hentaigana", + "Noto Serif JP", + "Noto Serif KR", + "Noto Serif Kannada", + "Noto Serif Khitan Small Script", + "Noto Serif Khmer", + "Noto Serif Khojki", + "Noto Serif Lao", + "Noto Serif Makasar", + "Noto Serif Malayalam", + "Noto Serif Myanmar", + "Noto Serif NP Hmong", + "Noto Serif Old Uyghur", + "Noto Serif Oriya", + "Noto Serif Ottoman Siyaq", + "Noto Serif SC", + "Noto Serif Sinhala", + "Noto Serif TC", + "Noto Serif Tamil", + "Noto Serif Tangut", + "Noto Serif Telugu", + "Noto Serif Thai", + "Noto Serif Tibetan", + "Noto Serif Todhri", + "Noto Serif Toto", + "Noto Serif Vithkuqi", + "Noto Serif Yezidi", + "Noto Traditional Nushu", + "Noto Znamenny Musical Notation", + "Nova Cut", + "Nova Flat", + "Nova Mono", + "Nova Oval", + "Nova Round", + "Nova Script", + "Nova Slim", + "Nova Square", + "Numans", + "Nunito", + "Nunito Sans", + "Nuosu SIL", + "Odibee Sans", + "Odor Mean Chey", + "Offside", + "Oi", + "Ojuju", + "Old Standard TT", + "Oldenburg", + "Ole", + "Oleo Script", + "Oleo Script Swash Caps", + "Onest", + "Oooh Baby", + "Open Runde", + "Open Sans", + "Open Sauce One", + "Open Sauce Sans", + "Open Sauce Two", + "OpenDyslexic", + "Oranienbaum", + "Orbit", + "Orbitron", + "Oregano", + "Orelega One", + "Orienta", + "Original Surfer", + "Ostrich Sans", + "Oswald", + "Outfit", + "Over the Rainbow", + "Overlock", + "Overlock SC", + "Overpass", + "Overpass Mono", + "Ovo", + "Oxanium", + "Oxygen", + "Oxygen Mono", + "PT Mono", + "PT Sans", + "PT Sans Caption", + "PT Sans Narrow", + "PT Serif", + "PT Serif Caption", + "Pacifico", + "Padauk", + "Padyakke Expanded One", + "Palanquin", + "Palanquin Dark", + "Palette Mosaic", + "Pangolin", + "Paprika", + "Parastoo", + "Parisienne", + "Parkinsans", + "Passero One", + "Passion One", + "Passions Conflict", + "Pathway Extreme", + "Pathway Gothic One", + "Patrick Hand", + "Patrick Hand SC", + "Pattaya", + "Patua One", + "Pavanam", + "Paytone One", + "Peace Sans", + "Peddana", + "Peralta", + "Permanent Marker", + "Petemoss", + "Petit Formal Script", + "Petrona", + "Phetsarath", + "Philosopher", + "Phudu", + "Piazzolla", + "Piedra", + "Pinyon Script", + "Pirata One", + "Pitagon Sans", + "Pitagon Sans Mono", + "Pitagon Sans Text", + "Pitagon Serif", + "Pixelify Sans", + "Plaster", + "Platypi", + "Play", + "Playball", + "Playfair", + "Playfair Display", + "Playfair Display SC", + "Playpen Sans", + "Playpen Sans Arabic", + "Playpen Sans Deva", + "Playpen Sans Hebrew", + "Playpen Sans Thai", + "Playwrite AR", + "Playwrite AR Guides", + "Playwrite AT", + "Playwrite AT Guides", + "Playwrite AU NSW", + "Playwrite AU NSW Guides", + "Playwrite AU QLD", + "Playwrite AU QLD Guides", + "Playwrite AU SA", + "Playwrite AU SA Guides", + "Playwrite AU TAS", + "Playwrite AU TAS Guides", + "Playwrite AU VIC", + "Playwrite AU VIC Guides", + "Playwrite BE VLG", + "Playwrite BE VLG Guides", + "Playwrite BE WAL", + "Playwrite BE WAL Guides", + "Playwrite BR", + "Playwrite BR Guides", + "Playwrite CA", + "Playwrite CA Guides", + "Playwrite CL", + "Playwrite CL Guides", + "Playwrite CO", + "Playwrite CO Guides", + "Playwrite CU", + "Playwrite CU Guides", + "Playwrite CZ", + "Playwrite CZ Guides", + "Playwrite DE Grund", + "Playwrite DE Grund Guides", + "Playwrite DE LA", + "Playwrite DE LA Guides", + "Playwrite DE SAS", + "Playwrite DE SAS Guides", + "Playwrite DE VA", + "Playwrite DE VA Guides", + "Playwrite DK Loopet", + "Playwrite DK Loopet Guides", + "Playwrite DK Uloopet", + "Playwrite DK Uloopet Guides", + "Playwrite ES", + "Playwrite ES Deco", + "Playwrite ES Deco Guides", + "Playwrite ES Guides", + "Playwrite FR Moderne", + "Playwrite FR Moderne Guides", + "Playwrite FR Trad", + "Playwrite FR Trad Guides", + "Playwrite GB J", + "Playwrite GB J Guides", + "Playwrite GB S", + "Playwrite GB S Guides", + "Playwrite HR", + "Playwrite HR Guides", + "Playwrite HR Lijeva", + "Playwrite HR Lijeva Guides", + "Playwrite HU", + "Playwrite HU Guides", + "Playwrite ID", + "Playwrite ID Guides", + "Playwrite IE", + "Playwrite IE Guides", + "Playwrite IN", + "Playwrite IN Guides", + "Playwrite IS", + "Playwrite IS Guides", + "Playwrite IT Moderna", + "Playwrite IT Moderna Guides", + "Playwrite IT Trad", + "Playwrite IT Trad Guides", + "Playwrite MX", + "Playwrite MX Guides", + "Playwrite NG Modern", + "Playwrite NG Modern Guides", + "Playwrite NL", + "Playwrite NL Guides", + "Playwrite NO", + "Playwrite NO Guides", + "Playwrite NZ", + "Playwrite NZ Basic", + "Playwrite NZ Basic Guides", + "Playwrite NZ Guides", + "Playwrite PE", + "Playwrite PE Guides", + "Playwrite PL", + "Playwrite PL Guides", + "Playwrite PT", + "Playwrite PT Guides", + "Playwrite RO", + "Playwrite RO Guides", + "Playwrite SK", + "Playwrite SK Guides", + "Playwrite TZ", + "Playwrite TZ Guides", + "Playwrite US Modern", + "Playwrite US Modern Guides", + "Playwrite US Trad", + "Playwrite US Trad Guides", + "Playwrite VN", + "Playwrite VN Guides", + "Playwrite ZA", + "Playwrite ZA Guides", + "Plus Jakarta Sans", + "Pochaevsk", + "Podkova", + "Poetsen One", + "Poiret One", + "Poller One", + "Poltawski Nowy", + "Poly", + "Pompiere", + "Ponnala", + "Ponomar", + "Pontano Sans", + "Poor Story", + "Poppins", + "Port Lligat Sans", + "Port Lligat Slab", + "Potta One", + "Pragati Narrow", + "Praise", + "Prata", + "Preahvihear", + "Press Start 2P", + "Pretendard", + "Pridi", + "Princess Sofia", + "Prociono", + "Prompt", + "Prosto One", + "Protest Guerrilla", + "Protest Revolution", + "Protest Riot", + "Protest Strike", + "Proza Libre", + "Public Sans", + "Puppies Play", + "Puritan", + "Purple Purse", + "Pushster", + "Qahiri", + "Quando", + "Quantico", + "Quattrocento", + "Quattrocento Sans", + "Questrial", + "Quicksand", + "Quintessential", + "Qwigley", + "Qwitcher Grypen", + "REM", + "Racing Sans One", + "Radio Canada", + "Radio Canada Big", + "Radley", + "Rajdhani", + "Rakkas", + "Raleway", + "Raleway Dots", + "Ramabhadra", + "Ramaraja", + "Rambla", + "Rammetto One", + "Rampart One", + "Ramsina", + "Ranchers", + "Rancho", + "Ranga", + "Rasa", + "Rationale", + "Ravi Prakash", + "Readex Pro", + "Recursive", + "Red Hat Display", + "Red Hat Mono", + "Red Hat Text", + "Red Rose", + "Redacted", + "Redacted Script", + "Redaction", + "Redaction 10", + "Redaction 100", + "Redaction 20", + "Redaction 35", + "Redaction 50", + "Redaction 70", + "Reddit Mono", + "Reddit Sans", + "Reddit Sans Condensed", + "Redressed", + "Reem Kufi", + "Reem Kufi Fun", + "Reem Kufi Ink", + "Reenie Beanie", + "Reggae One", + "Rethink Sans", + "Revalia", + "Rhodium Libre", + "Ribeye", + "Ribeye Marrow", + "Righteous", + "Risque", + "Road Rage", + "Roboto", + "Roboto Condensed", + "Roboto Flex", + "Roboto Mono", + "Roboto Serif", + "Roboto Slab", + "Rochester", + "Rock 3D", + "Rock Salt", + "RocknRoll One", + "Rokkitt", + "Romanesco", + "Ropa Sans", + "Rosario", + "Rosarivo", + "Rouge Script", + "Rowdies", + "Rozha One", + "Rubik", + "Rubik 80s Fade", + "Rubik Beastly", + "Rubik Broken Fax", + "Rubik Bubbles", + "Rubik Burned", + "Rubik Dirt", + "Rubik Distressed", + "Rubik Doodle Shadow", + "Rubik Doodle Triangles", + "Rubik Gemstones", + "Rubik Glitch", + "Rubik Glitch Pop", + "Rubik Iso", + "Rubik Lines", + "Rubik Maps", + "Rubik Marker Hatch", + "Rubik Maze", + "Rubik Microbe", + "Rubik Mono One", + "Rubik Moonrocks", + "Rubik One", + "Rubik Pixels", + "Rubik Puddles", + "Rubik Scribble", + "Rubik Spray Paint", + "Rubik Storm", + "Rubik Vinyl", + "Rubik Wet Paint", + "Ruda", + "Rufina", + "Ruge Boogie", + "Ruluko", + "Rum Raisin", + "Ruslan Display", + "Russo One", + "Ruthie", + "Ruwudu", + "Rye", + "SN Pro", + "STIX Two Text", + "SUSE", + "SUSE Mono", + "Sacramento", + "Sahitya", + "Sail", + "Saira", + "Saira Condensed", + "Saira Extra Condensed", + "Saira Semi Condensed", + "Saira Stencil", + "Saira Stencil One", + "Salsa", + "Sanchez", + "Sancreek", + "Sankofa Display", + "Sansation", + "Sansita", + "Sansita Swashed", + "Sarabun", + "Sarala", + "Sarina", + "Sarpanch", + "Sassy Frass", + "Satisfy", + "Savate", + "Sawarabi Gothic", + "Sawarabi Mincho", + "Scada", + "Scheherazade New", + "Schibsted Grotesk", + "Schoolbell", + "Science Gothic", + "Scope One", + "Seaweed Script", + "Secular One", + "Sedan", + "Sedan SC", + "Sedgwick Ave", + "Sedgwick Ave Display", + "Sekuya", + "Sen", + "Send Flowers", + "Sevillana", + "Seymour One", + "Shadows Into Light", + "Shadows Into Light Two", + "Shafarik", + "Shalimar", + "Shantell Sans", + "Shanti", + "Share", + "Share Tech", + "Share Tech Mono", + "Shippori Antique", + "Shippori Antique B1", + "Shippori Mincho", + "Shippori Mincho B1", + "Shizuru", + "Shojumaru", + "Short Stack", + "Shrikhand", + "Siemreap", + "Sigmar", + "Sigmar One", + "Signika", + "Signika Negative", + "Silkscreen", + "Simonetta", + "Single Day", + "Sintony", + "Sirin Stencil", + "Sirivennela", + "Six Caps", + "Sixtyfour", + "Sixtyfour Convergence", + "Skranji", + "Slabo 13px", + "Slabo 27px", + "Slackey", + "Slackside One", + "Smokum", + "Smooch", + "Smooch Sans", + "Smythe", + "Sniglet", + "Snippet", + "Snowburst One", + "Sofadi One", + "Sofia", + "Sofia Sans", + "Sofia Sans Condensed", + "Sofia Sans Extra Condensed", + "Sofia Sans Semi Condensed", + "Solitreo", + "Solway", + "Sometype Mono", + "Song Myung", + "Sono", + "Sonsie One", + "Sora", + "Sorts Mill Goudy", + "Sour Gummy", + "Source Code Pro", + "Source Sans 3", + "Source Sans Pro", + "Source Serif 4", + "Source Serif Pro", + "Space Grotesk", + "Space Mono", + "Special Elite", + "Special Gothic", + "Special Gothic Condensed One", + "Special Gothic Expanded One", + "Spectral", + "Spectral SC", + "Spicy Rice", + "Spinnaker", + "Spirax", + "Splash", + "Spline Sans", + "Spline Sans Mono", + "Squada One", + "Square Peg", + "Sree Krushnadevaraya", + "Sriracha", + "Srisakdi", + "Staatliches", + "Stack Sans Headline", + "Stack Sans Notch", + "Stack Sans Text", + "Stalemate", + "Stalinist One", + "Stardos Stencil", + "Stick", + "Stick No Bills", + "Stint Ultra Condensed", + "Stint Ultra Expanded", + "Stoke", + "Story Script", + "Strait", + "Style Script", + "Stylish", + "Sue Ellen Francisco", + "Suez One", + "Sulphur Point", + "Sumana", + "Sunflower", + "Sunshiney", + "Supermercado One", + "Sura", + "Suranna", + "Suravaram", + "Suwannaphum", + "Swanky and Moo Moo", + "Syncopate", + "Syne", + "Syne Italic", + "Syne Mono", + "Syne Tactile", + "TASA Explorer", + "TASA Orbiter", + "Tac One", + "Tagesschrift", + "Tai Heritage Pro", + "Tajawal", + "Tangerine", + "Tapestry", + "Taprom", + "Tauri", + "Taviraj", + "Teachers", + "Teko", + "Tektur", + "Telex", + "Tenali Ramakrishna", + "Tenor Sans", + "Text Me One", + "Texturina", + "Thasadith", + "The Girl Next Door", + "The Nautigal", + "Tienne", + "TikTok Sans", + "Tillana", + "Tilt Neon", + "Tilt Prism", + "Tilt Warp", + "Timmana", + "Tinos", + "Tiny5", + "Tiro Bangla", + "Tiro Devanagari Hindi", + "Tiro Devanagari Marathi", + "Tiro Devanagari Sanskrit", + "Tiro Gurmukhi", + "Tiro Kannada", + "Tiro Tamil", + "Tiro Telugu", + "Tirra", + "Titan One", + "Titillium Web", + "Tomorrow", + "Tourney", + "Trade Winds", + "Train One", + "Triodion", + "Trirong", + "Trispace", + "Trocchi", + "Trochut", + "Truculenta", + "Trykker", + "Tsukimi Rounded", + "Tuffy", + "Tulpen One", + "Turret Road", + "Twinkle Star", + "Ubuntu", + "Ubuntu Condensed", + "Ubuntu Mono", + "Ubuntu Sans", + "Ubuntu Sans Mono", + "Uchen", + "Ultra", + "Unbounded", + "Uncial Antiqua", + "Uncut Sans", + "Underdog", + "Unica One", + "Unifont", + "UnifontEX", + "UnifrakturCook", + "UnifrakturMaguntia", + "Unkempt", + "Unlock", + "Unna", + "UoqMunThenKhung", + "Updock", + "Urbanist", + "VT323", + "Vampiro One", + "Varela", + "Varela Round", + "Varta", + "Vast Shadow", + "Vazirmatn", + "Vend Sans", + "Vesper Libre", + "Viaoda Libre", + "Vibes", + "Vibur", + "Victor Mono", + "Vidaloka", + "Viga", + "Vina Sans", + "Voces", + "Volkhov", + "Vollkorn", + "Vollkorn SC", + "Voltaire", + "Vujahday Script", + "WDXL Lubrifont JP N", + "WDXL Lubrifont SC", + "WDXL Lubrifont TC", + "WIN95FA", + "Waiting for the Sunrise", + "Wallpoet", + "Walter Turncoat", + "Warnes", + "Water Brush", + "Waterfall", + "Wavefont", + "Wellfleet", + "Wendy One", + "Whisper", + "WindSong", + "Winky Rough", + "Winky Sans", + "Wire One", + "Wittgenstein", + "Wix Madefor Display", + "Wix Madefor Text", + "Work Sans", + "Workbench", + "Xanh Mono", + "YakuHanJP", + "YakuHanJPs", + "YakuHanMP", + "YakuHanMPs", + "YakuHanRP", + "YakuHanRPs", + "Yaldevi", + "Yanone Kaffeesatz", + "Yantramanav", + "Yarndings 12", + "Yarndings 12 Charted", + "Yarndings 20", + "Yarndings 20 Charted", + "Yatra One", + "Yellowtail", + "Yeon Sung", + "Yeseva One", + "Yesteryear", + "Yomogi", + "Young Serif", + "Yrsa", + "Ysabeau", + "Ysabeau Infant", + "Ysabeau Office", + "Ysabeau SC", + "Yuji Boku", + "Yuji Hentaigana Akari", + "Yuji Hentaigana Akebono", + "Yuji Mai", + "Yuji Syuku", + "Yusei Magic", + "ZCOOL KuaiLe", + "ZCOOL QingKe HuangYou", + "ZCOOL XiaoWei", + "Zain", + "Zalando Sans", + "Zalando Sans Expanded", + "Zalando Sans SemiExpanded", + "Zen Antique", + "Zen Antique Soft", + "Zen Dots", + "Zen Kaku Gothic Antique", + "Zen Kaku Gothic New", + "Zen Kurenaido", + "Zen Loop", + "Zen Maru Gothic", + "Zen Old Mincho", + "Zen Tokyo Zoo", + "Zeyada", + "Zhi Mang Xing", + "Zilla Slab", + "Zilla Slab Highlight", + "iA Writer Duo", + "iA Writer Mono", + "iA Writer Quattro" +] \ No newline at end of file diff --git a/app/src/main/cpp/pdfium_bridge.cpp b/app/src/main/cpp/pdfium_bridge.cpp index a1969c3..028ab80 100644 --- a/app/src/main/cpp/pdfium_bridge.cpp +++ b/app/src/main/cpp/pdfium_bridge.cpp @@ -300,14 +300,18 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectCount(JNIEnv *env, jcl extern "C" JNIEXPORT jint JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectType(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { - if (!init_pdfium() || !get_object_func || !get_object_type_func) return 0; + if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || pagePtr == 0 || index < 0) return 0; + const int object_count = count_objects_func(reinterpret_cast(pagePtr)); + if (index >= object_count) return 0; void* obj = get_object_func(reinterpret_cast(pagePtr), index); return obj ? get_object_type_func(obj) : 0; } extern "C" JNIEXPORT jboolean JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jfloatArray outRect) { - if (!init_pdfium() || !get_object_func || !get_object_bounds_func) return JNI_FALSE; + if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_bounds_func || pagePtr == 0 || index < 0 || outRect == nullptr) return JNI_FALSE; + const int object_count = count_objects_func(reinterpret_cast(pagePtr)); + if (index >= object_count) return JNI_FALSE; void* obj = get_object_func(reinterpret_cast(pagePtr), index); if (!obj) return JNI_FALSE; @@ -322,7 +326,9 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *en extern "C" JNIEXPORT jintArray JNICALL Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jintArray dimens) { - if (!init_pdfium() || !get_object_func || !get_image_bitmap_func || !bitmap_get_buffer_func) return nullptr; + if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || !get_image_bitmap_func || !bitmap_get_buffer_func || pagePtr == 0 || index < 0 || dimens == nullptr) return nullptr; + const int object_count = count_objects_func(reinterpret_cast(pagePtr)); + if (index >= object_count) return nullptr; void* obj = get_object_func(reinterpret_cast(pagePtr), index); if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE @@ -605,4 +611,4 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getLinkInfoAtPoint(JNIEnv *env, jcl LOGI("PdfLinkDiagnostic: Link found but payload was empty or unsupported."); return nullptr; -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt index cbc138b..bc25e61 100644 --- a/app/src/main/java/com/aryan/reader/AppNavigation.kt +++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt @@ -59,6 +59,16 @@ object AppDestinations { const val FONTS_SCREEN_ROUTE = "fonts_screen_route" } +private fun NavHostController.navigateSingleTopTo(route: String) { + navigate(route) { + launchSingleTop = true + restoreState = true + popUpTo(graph.startDestinationId) { + saveState = true + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable @@ -77,25 +87,21 @@ fun AppNavigation( FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> { if (uiState.selectedPdfUri != null) { if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) { - navController.navigate(AppDestinations.PDF_VIEWER_ROUTE) { - popUpTo(AppDestinations.MAIN_ROUTE) - } + navController.navigateSingleTopTo(AppDestinations.PDF_VIEWER_ROUTE) } } } FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> { if (uiState.selectedEpubBook != null) { if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) { - navController.navigate(AppDestinations.EPUB_READER_ROUTE) { - popUpTo(AppDestinations.MAIN_ROUTE) - } + navController.navigateSingleTopTo(AppDestinations.EPUB_READER_ROUTE) } } } null -> { val currentRoute = navController.currentBackStackEntry?.destination?.route if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) { - navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false) + navController.navigateSingleTopTo(AppDestinations.MAIN_ROUTE) } } } @@ -286,4 +292,4 @@ fun AppNavigation( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index 756b663..6cd8416 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -185,6 +185,7 @@ import timber.log.Timber import java.io.File import java.net.HttpURLConnection import java.net.URL +import java.util.Locale import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt @@ -1284,6 +1285,21 @@ fun DeviceVoicesTab( .clickable(enabled = !isTtsActive && isBaseMode) { savedVoiceName = null saveNativeVoice(context, null) + ttsEngine?.apply { + try { + val defaultLocale = Locale.getDefault() + language = defaultLocale + val fallbackVoice = + defaultVoice ?: voices.firstOrNull { voice -> + voice.locale == defaultLocale && !voice.isNetworkConnectionRequired + } ?: voices.firstOrNull { voice -> + voice.locale == defaultLocale + } + fallbackVoice?.let { voice = it } + } catch (e: Exception) { + Timber.tag("TTS_DIAGNOSE").w(e, "Failed to reset preview engine to system default voice") + } + } } ) { Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { @@ -1361,7 +1377,7 @@ fun DeviceVoicesTab( enabled = !isTtsActive, onClick = { ttsEngine?.apply { - language = voice.locale + this.voice = voice speak("This is a voice sample.", TextToSpeech.QUEUE_FLUSH, null, "sample_${voice.name}") } } @@ -3004,4 +3020,4 @@ fun ManageCacheTab(bookTitle: String, summaryCacheManager: SummaryCacheManager, } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index b1b304e..57bd4b7 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -37,7 +37,10 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import androidx.core.content.edit import com.aryan.reader.data.LocalSyncUtils +import com.aryan.reader.data.FolderBookMetadata import java.io.File +import android.provider.DocumentsContract +import java.security.MessageDigest class FolderSyncWorker( private val appContext: Context, @@ -138,17 +141,17 @@ class FolderSyncWorker( LocalSyncUtils.migrateLegacySidecarsToSubfolder(appContext, documentTree) Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...") - val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri) + val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap() Timber.tag("FolderSync").d("Phase 1.5: Preloading annotation sidecars...") - val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree) + val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree).toMutableMap() folderMetadataMap.forEach { (bookId, remoteMeta) -> val existingItem = recentFilesRepository.getFileByBookId(bookId) if (existingItem != null) { if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) { - Timber.tag("FolderSync").d("Applying remote update for $bookId (Progress: ${remoteMeta.progressPercentage}%)") + Timber.tag("PdfPositionDebug").w("FolderSyncWorker applies remote progress for $bookId | Local Page: ${existingItem.lastPage} -> Remote Page: ${remoteMeta.lastPage}") val itemToUpdate = existingItem.copy( lastChapterIndex = remoteMeta.lastChapterIndex, lastPage = remoteMeta.lastPage, @@ -164,6 +167,8 @@ class FolderSyncWorker( timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp ) recentFilesRepository.addRecentFile(itemToUpdate) + } else { + Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}") } } } @@ -202,39 +207,39 @@ class FolderSyncWorker( val contentResolver = appContext.contentResolver val foundBookIds = mutableSetOf() val newOrUpdatedItems = mutableListOf() - val existingItemsMap = existingFolderBooks.associateBy { it.bookId } + val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap() - val rootDocId = android.provider.DocumentsContract.getTreeDocumentId(folderUri) + val rootDocId = DocumentsContract.getTreeDocumentId(folderUri) val dirQueue = ArrayDeque() dirQueue.add(rootDocId) val projection = arrayOf( - android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID, - android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME, - android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE, - android.provider.DocumentsContract.Document.COLUMN_SIZE, - android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED ) while (dirQueue.isNotEmpty()) { if (isStopped) break val currentDocId = dirQueue.removeFirst() - val childrenUri = android.provider.DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId) try { contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor -> - val idCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID) - val nameCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME) - val mimeCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE) - val sizeCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_SIZE) - val modCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED) + val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) + val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) + val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) while (cursor.moveToNext() && !isStopped) { val docId = cursor.getString(idCol) val name = cursor.getString(nameCol) ?: "" val mimeType = cursor.getString(mimeCol) - if (mimeType == android.provider.DocumentsContract.Document.MIME_TYPE_DIR) { + if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { if (!name.startsWith(".") && name != "EpistemeSyncData") { dirQueue.add(docId) } @@ -244,29 +249,49 @@ class FolderSyncWorker( val type = getFileType(name, mimeType) if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) { - val stableId = "local_$name" + val stableId = buildStableBookId(name, rootDocId, docId) foundBookIds.add(stableId) - val docUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(folderUri, docId) + val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId) var existingItem = existingItemsMap[stableId] + if (existingItem != null && existingItem.uriString != docUri.toString()) { + val collidedItem = existingItem + val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId) + if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) { + Timber.tag("FolderSync").i("Resolving folder ID collision for ${collidedItem.displayName}: ${collidedItem.bookId} -> $collidedStableId") + migrateFolderBookId( + folderUriString = folderUriString, + oldId = collidedItem.bookId, + newId = collidedStableId, + folderMetadataMap = folderMetadataMap, + preloadedSidecars = preloadedSidecars, + existingItemsMap = existingItemsMap + ) + existingItem = existingItemsMap[stableId] + } + } + if (existingItem == null) { - val oldItem = existingItemsMap.values.find { it.bookId.startsWith("local_${name}_") && it.bookId != stableId } + val oldItem = existingItemsMap.values.find { + it.bookId != stableId && ( + it.uriString == docUri.toString() || + it.bookId.startsWith("local_${name}_") + ) + } if (oldItem != null) { val oldId = oldItem.bookId Timber.tag("FolderSync").i("Migrating book ID for $name from $oldId to $stableId") - recentFilesRepository.migrateBookIdLocally(oldId, stableId) - existingItem = recentFilesRepository.getFileByBookId(stableId) - - try { - val syncDir = documentTree.findFile("EpistemeSyncData") - if (syncDir != null) { - syncDir.findFile(".$oldId.json")?.delete() - syncDir.findFile("$oldId.json")?.delete() - syncDir.findFile(".$oldId" + "_annotations.json")?.delete() - } - } catch (_: Exception) { Timber.tag("FolderSync").e("Failed to clean up orphaned SAF sidecars.") } + migrateFolderBookId( + folderUriString = folderUriString, + oldId = oldId, + newId = stableId, + folderMetadataMap = folderMetadataMap, + preloadedSidecars = preloadedSidecars, + existingItemsMap = existingItemsMap + ) + existingItem = existingItemsMap[stableId] } } @@ -410,4 +435,78 @@ class FolderSyncWorker( else -> null } } -} \ No newline at end of file + + private fun buildStableBookId(name: String, rootDocId: String, docId: String): String { + val relativePath = buildRelativePath(rootDocId, docId, name) + if (relativePath.equals(name, ignoreCase = true)) { + return "local_$name" + } + return "local_${name}_${shortHash(relativePath.lowercase())}" + } + + private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String { + val rootPath = rootDocId.substringAfter(':', "") + val docPath = docId.substringAfter(':', "") + if (docPath.isBlank()) return fallbackName + val relative = if (rootPath.isNotBlank() && docPath.startsWith(rootPath)) { + docPath.removePrefix(rootPath).trimStart('/') + } else { + docPath.substringAfterLast('/', fallbackName) + } + return relative.ifBlank { fallbackName } + } + + private fun shortHash(value: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) }.take(12) + } + + private fun computeStableIdForStoredItem(item: RecentFileItem, rootDocId: String): String? { + val uriString = item.uriString ?: return null + return try { + val docId = DocumentsContract.getDocumentId(uriString.toUri()) + buildStableBookId(item.displayName, rootDocId, docId) + } catch (_: Exception) { + null + } + } + + private suspend fun migrateFolderBookId( + folderUriString: String, + oldId: String, + newId: String, + folderMetadataMap: MutableMap, + preloadedSidecars: MutableMap>, + existingItemsMap: MutableMap + ) { + if (oldId == newId) return + + recentFilesRepository.migrateBookIdLocally(oldId, newId) + + val oldMetadata = folderMetadataMap.remove(oldId) + if (oldMetadata != null && newId !in folderMetadataMap) { + val migratedMetadata = oldMetadata.copy(bookId = newId) + LocalSyncUtils.saveMetadataToFolder(appContext, folderUriString.toUri(), migratedMetadata) + folderMetadataMap[newId] = migratedMetadata + } + + val oldSidecar = preloadedSidecars.remove(oldId) + if (oldSidecar != null && newId !in preloadedSidecars) { + LocalSyncUtils.saveAnnotationSidecar( + context = appContext, + sourceFolderUri = folderUriString.toUri(), + bookId = newId, + jsonPayload = oldSidecar.second, + timestamp = oldSidecar.first + ) + preloadedSidecars[newId] = oldSidecar + } + + LocalSyncUtils.deleteBookSidecars(appContext, folderUriString.toUri(), oldId) + + existingItemsMap.remove(oldId) + recentFilesRepository.getFileByBookId(newId)?.let { + existingItemsMap[newId] = it + } + } +} diff --git a/app/src/main/java/com/aryan/reader/FontsScreen.kt b/app/src/main/java/com/aryan/reader/FontsScreen.kt index fb81a60..91f1ded 100644 --- a/app/src/main/java/com/aryan/reader/FontsScreen.kt +++ b/app/src/main/java/com/aryan/reader/FontsScreen.kt @@ -17,9 +17,13 @@ * * mail: epistemereader@gmail.com */ +// FontsScreen.kt +@file:Suppress("KotlinConstantConditions") + package com.aryan.reader import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,24 +38,32 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +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.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,6 +71,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -69,6 +83,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aryan.reader.data.CustomFontEntity import java.io.File +@OptIn(ExperimentalMaterial3Api::class) @Composable fun FontsScreen( viewModel: MainViewModel, @@ -76,24 +91,21 @@ fun FontsScreen( ) { val fonts: List by viewModel.customFonts.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current // Dialog state var showDeleteDialog by remember { mutableStateOf(false) } var fontToDelete by remember { mutableStateOf(null) } + var showGoogleFontsSheet by remember { mutableStateOf(false) } val pickFontLauncher = rememberFilePickerLauncher { uris -> uris.firstOrNull()?.let { viewModel.importFont(it) } } - // Font mime types filter val fontMimeTypes = arrayOf( - "font/ttf", - "font/otf", - "font/woff2", - "application/x-font-ttf", - "application/x-font-otf", - "application/font-woff2", - "application/vnd.ms-opentype", + "font/ttf", "font/otf", "font/woff2", + "application/x-font-ttf", "application/x-font-otf", + "application/font-woff2", "application/vnd.ms-opentype", "application/x-font-opentype" ) @@ -111,11 +123,24 @@ fun FontsScreen( }, floatingActionButton = { if (fonts.isNotEmpty()) { - ExtendedFloatingActionButton( - onClick = { pickFontLauncher.launch(fontMimeTypes) }, - icon = { Icon(Icons.Default.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.import_font)) } - ) + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + ExtendedFloatingActionButton( + onClick = { showGoogleFontsSheet = true }, + icon = { Icon(Icons.Default.CloudDownload, contentDescription = null) }, + text = { Text("Google Fonts") }, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + + ExtendedFloatingActionButton( + onClick = { pickFontLauncher.launch(fontMimeTypes) }, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + text = { Text(stringResource(R.string.import_font)) } + ) + } } } ) { padding -> @@ -125,7 +150,9 @@ fun FontsScreen( title = stringResource(R.string.no_custom_fonts), message = stringResource(R.string.import_fonts_desc), onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) }, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + secondaryButtonText = "Browse Google Fonts", + onSecondaryClick = { showGoogleFontsSheet = true } ) } else { LazyColumn( @@ -155,7 +182,6 @@ fun FontsScreen( } } } - // Banner messages removed as requested } } @@ -173,8 +199,172 @@ fun FontsScreen( } ) } + + if (showGoogleFontsSheet) { + GoogleFontsBottomSheet( + onDismiss = { showGoogleFontsSheet = false }, + existingFonts = fonts, + getFullFontList = { viewModel.loadGoogleFontsList(context) }, + onDownloadFont = { fontName, onComplete -> + viewModel.downloadGoogleFont(fontName, onComplete) + } + ) + } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GoogleFontsBottomSheet( + onDismiss: () -> Unit, + existingFonts: List, + getFullFontList: () -> List, + onDownloadFont: (String, () -> Unit) -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var searchQuery by remember { mutableStateOf("") } + var downloadingFontName by remember { mutableStateOf(null) } + + // Curated presets to show when search is empty + val popularPresets = remember { + listOf( + "Merriweather", "Open Sans", "Playfair Display", "Montserrat", "Oswald", "Raleway", "Nunito", + "Poppins", "Ubuntu", "Fira Sans", "Quicksand", "Crimson Text", + "Literata", "EB Garamond", "Libre Baskerville", "Inter", "Work Sans" + ) + } + + // Lazy evaluation of the full list only when typing + val displayList = remember(searchQuery) { + if (searchQuery.isBlank()) { + popularPresets + } else { + val allFonts = getFullFontList() + allFonts.filter { it.contains(searchQuery, ignoreCase = true) }.take(50) // Limit to 50 for performance + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = "Browse Google Fonts", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp) + ) + + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search 1900+ fonts...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + shape = RoundedCornerShape(12.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (searchQuery.isBlank()) { + item { + Text( + text = "Popular Choices", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } else if (displayList.isEmpty()) { + item { + Text( + text = "No fonts found matching '$searchQuery'", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp) + ) + } + } + + items(displayList) { fontName -> + val isDownloaded = remember(existingFonts, fontName) { + existingFonts.any { it.displayName.equals(fontName, ignoreCase = true) } + } + val isDownloading = downloadingFontName == fontName + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(enabled = !isDownloaded && !isDownloading) { + downloadingFontName = fontName + onDownloadFont(fontName) { + if (downloadingFontName == fontName) { + downloadingFontName = null + } + } + } + .background( + if (isDownloaded) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = fontName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (isDownloaded) FontWeight.Bold else FontWeight.Medium, + color = if (isDownloaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + + Box(modifier = Modifier.padding(start = 12.dp)) { + when { + isDownloaded -> { + Icon( + Icons.Default.Check, + contentDescription = "Already Downloaded", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + isDownloading -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + else -> { + Icon( + Icons.Default.CloudDownload, + contentDescription = "Download", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + } + } + } + } + } + } +} + +// Existing unchanged components @Composable fun FontListItem( font: CustomFontEntity, diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 4c60f0f..b638da2 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -339,12 +339,17 @@ fun HomeScreen( }, onAppThemeClick = { showAppThemePanel = true }, onTestPanelDetectionClick = { viewModel.testPanelDetection(context) }, - onLanguageClick = { showLanguageDialog = true } + onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) }, + onLanguageClick = { showLanguageDialog = true }, + onExportLogsClick = { viewModel.exportLogsToFile(context) } ) } else { ContextualTopAppBar( selectedItemCount = selectedContextItems.size, onNavIconClick = { viewModel.clearContextualAction() }, + onTagClick = { + viewModel.openTagSelection(selectedContextItems.map { it.bookId }.toSet()) + }, onInfoClick = { if (selectedContextItems.size == 1) { itemForInfoDialog = selectedContextItems.first() @@ -472,7 +477,8 @@ fun HomeScreen( }, onUpdateName = { newName -> viewModel.updateCustomName(item.bookId, newName) - } + }, + onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) } ) } } @@ -988,7 +994,9 @@ fun DefaultTopAppBar( onStrictFilterToggleClick: () -> Unit, onAppThemeClick: () -> Unit, onTestPanelDetectionClick: () -> Unit, - onLanguageClick: () -> Unit + onTestSpeechBubbleDetectionClick: () -> Unit, + onLanguageClick: () -> Unit, + onExportLogsClick: () -> Unit ) { var showOptionsMenu by remember { mutableStateOf(false) } var showLimitMenu by remember { mutableStateOf(false) } @@ -1094,6 +1102,16 @@ fun DefaultTopAppBar( onTestPanelDetectionClick() showOptionsMenu = false }) + + DropdownMenuItem(text = { Text("Test Speech Bubble ML Detection") }, onClick = { + onTestSpeechBubbleDetectionClick() + showOptionsMenu = false + }) + + DropdownMenuItem(text = { Text("Export Logs (Last 5000 lines)") }, onClick = { + onExportLogsClick() + showOptionsMenu = false + }) } if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") { diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 1d15092..d15ea9b 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -64,6 +64,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.LibraryBooks +import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Check @@ -76,6 +78,7 @@ import androidx.compose.material.icons.filled.FolderSpecial import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Star import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.CircularProgressIndicator @@ -131,6 +134,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import coil.request.ImageRequest import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.data.TagEntity import com.aryan.reader.opds.OpdsAcquisition import com.aryan.reader.opds.OpdsCatalog import com.aryan.reader.opds.OpdsEntry @@ -159,7 +163,7 @@ fun LibraryScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val selectedItems = uiState.contextualActionItems val isContextualModeActive = selectedItems.isNotEmpty() - val selectedShelves = uiState.contextualActionShelfNames + val selectedShelves = uiState.contextualActionShelfIds val isShelfContextualModeActive = selectedShelves.isNotEmpty() val sortOrder = uiState.sortOrder val shelves = uiState.shelves @@ -278,6 +282,7 @@ fun LibraryScreen( selectedShelves = selectedShelves, sortOrder = sortOrder, libraryFilters = uiState.libraryFilters, + allTags = uiState.allTags, pinnedLibraryBookIds = uiState.pinnedLibraryBookIds, pagerState = pagerState, scope = scope, @@ -289,6 +294,7 @@ fun LibraryScreen( onFilterClick = { showFilterSheet = true }, onClearFilters = { viewModel.updateLibraryFilters(LibraryFilters()) }, onRemoveFilter = { viewModel.updateLibraryFilters(it) }, + onTagClick = { viewModel.openTagSelection(selectedItems.map { it.bookId }.toSet()) }, onPinClick = { viewModel.togglePinForContextualItems(isHome = false) }, onClearSelection = { viewModel.clearContextualAction() }, onItemClick = viewModel::onRecentFileClicked, @@ -358,6 +364,7 @@ fun LibraryScreen( if (showFilterSheet) { LibraryFilterSheet( filters = uiState.libraryFilters, + allTags = uiState.allTags, syncedFolders = uiState.syncedFolders, onApply = { viewModel.updateLibraryFilters(it) }, onDismiss = { showFilterSheet = false } @@ -385,7 +392,8 @@ fun LibraryScreen( }, onUpdateName = { newName -> viewModel.updateCustomName(item.bookId, newName) - } + }, + onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) } ) } } @@ -399,7 +407,7 @@ fun ShelfScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val selectedItems = uiState.contextualActionItems - val viewingShelfName = uiState.viewingShelfName + val viewingShelfId = uiState.viewingShelfId val isAddingBooks = uiState.isAddingBooksToShelf val shelves = uiState.shelves val sortOrder = uiState.sortOrder @@ -414,17 +422,20 @@ fun ShelfScreen( when { selectedItems.isNotEmpty() -> viewModel.clearContextualAction() isAddingBooks -> viewModel.dismissAddBooksToShelf() - else -> viewModel.unselectShelf() + else -> viewModel.navigateBackFromShelf() } } - val currentShelf = shelves.find { it.name == viewingShelfName } + val currentShelf = shelves.find { it.id == viewingShelfId } + val childShelves = remember(shelves, currentShelf) { + currentShelf?.childShelfIds?.mapNotNull { childId -> shelves.find { it.id == childId } } ?: emptyList() + } Box(modifier = Modifier.fillMaxSize()) { - if (viewingShelfName != null && currentShelf != null) { + if (viewingShelfId != null && currentShelf != null) { if (isAddingBooks) { AddBooksModeScreen( - shelfName = viewingShelfName, + shelfName = currentShelf.name, availableBooks = uiState.booksAvailableForAdding, selectedBookUris = uiState.booksSelectedForAdding, currentSource = uiState.addBooksSource, @@ -433,20 +444,23 @@ fun ShelfScreen( onSourceChange = viewModel::setAddBooksSource, onBookClick = { item -> viewModel.toggleBookSelectionForAdding(item.bookId) }, onBack = viewModel::dismissAddBooksToShelf, - onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfName) }, + onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfId) }, downloadingBookIds = uiState.downloadingBookIds ) } else { ShelfDetailScreen( shelf = currentShelf, + childShelves = childShelves, selectedItems = selectedItems, sortOrder = sortOrder, onSortOrderChange = viewModel::setSortOrder, - onBack = viewModel::unselectShelf, + onBack = viewModel::navigateBackFromShelf, onAddBooksClick = viewModel::showAddBooksToShelf, + onChildShelfClick = viewModel::onShelfClick, onBookClick = viewModel::onRecentFileClicked, onBookLongClick = viewModel::onRecentItemLongPress, onClearSelection = viewModel::clearContextualAction, + onTagClick = { viewModel.openTagSelection(selectedItems.map { it.bookId }.toSet()) }, onInfoClick = { if (selectedItems.size == 1) { itemForInfoDialog = selectedItems.first() @@ -454,24 +468,27 @@ fun ShelfScreen( } }, onDeleteClick = { showRemoveFromShelfDialog = true }, - onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.name) }, - onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.name) }, + onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) }, + onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) }, downloadingBookIds = uiState.downloadingBookIds ) } } if (showRenameDialogFor != null) { - RenameShelfDialog( - initialName = showRenameDialogFor, - onConfirm = { newName -> viewModel.renameShelf(showRenameDialogFor, newName) }, - onDismiss = viewModel::dismissRenameShelfDialog - ) + val shelfToRename = shelves.find { it.id == showRenameDialogFor } + if (shelfToRename != null) { + RenameShelfDialog( + initialName = shelfToRename.name, + onConfirm = { newName -> viewModel.renameShelf(showRenameDialogFor, newName) }, + onDismiss = viewModel::dismissRenameShelfDialog + ) + } } if (showDeleteDialogFor != null) { DeleteShelfConfirmationDialog( - shelfName = showDeleteDialogFor, + shelfName = shelves.find { it.id == showDeleteDialogFor }?.name ?: "", onConfirm = { viewModel.deleteShelf(showDeleteDialogFor) }, onDismiss = viewModel::dismissDeleteShelfDialog ) @@ -493,13 +510,9 @@ fun ShelfScreen( if (showInfoDialog) { FileInfoDialog( item = item, - onDismiss = { - showInfoDialog = false - itemForInfoDialog = null - }, - onUpdateName = { newName -> - viewModel.updateCustomName(item.bookId, newName) - } + onDismiss = { showInfoDialog = false; itemForInfoDialog = null }, + onUpdateName = { newName -> viewModel.updateCustomName(item.bookId, newName) }, + onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) } ) } } @@ -519,6 +532,7 @@ fun LibraryScreenContent( selectedShelves: Set, sortOrder: SortOrder, libraryFilters: LibraryFilters, + allTags: List, pinnedLibraryBookIds: Set, pagerState: PagerState, scope: CoroutineScope, @@ -530,6 +544,7 @@ fun LibraryScreenContent( onFilterClick: () -> Unit, onClearFilters: () -> Unit, onRemoveFilter: (LibraryFilters) -> Unit, + onTagClick: () -> Unit, onPinClick: () -> Unit, onClearSelection: () -> Unit, onItemClick: (RecentFileItem) -> Unit, @@ -590,6 +605,7 @@ fun LibraryScreenContent( ContextualTopAppBar( selectedItemCount = selectedItems.size, onNavIconClick = onClearSelection, + onTagClick = onTagClick, onPinClick = onPinClick, onInfoClick = onInfoClick, onDeleteClick = onDeleteClick, @@ -734,6 +750,19 @@ fun LibraryScreenContent( trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) } ) } + if (libraryFilters.tagIds.isNotEmpty()) { + val selectedTags = allTags.filter { it.id in libraryFilters.tagIds } + val tagLabel = when { + selectedTags.isEmpty() -> "${libraryFilters.tagIds.size} tags" + selectedTags.size <= 2 -> selectedTags.joinToString { it.name } + else -> "${selectedTags.size} tags" + } + AssistChip( + onClick = { onRemoveFilter(libraryFilters.copy(tagIds = emptySet())) }, + label = { Text("Tags: $tagLabel") }, + trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) } + ) + } } } } @@ -846,16 +875,60 @@ private fun ShelvesScreen( onShelfLongClick: (Shelf) -> Unit, selectedShelves: Set, ) { + val tagShelves = remember(shelves) { shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 } } + val visibleShelves = remember(shelves) { + shelves.filter { shelf -> + when { + shelf.type == ShelfType.TAG -> false + shelf.type == ShelfType.FOLDER -> shelf.parentShelfId == null + else -> true + } + } + } + LazyColumn( modifier = Modifier .fillMaxSize(), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items(shelves, key = { it.name }) { shelf -> + if (tagShelves.isNotEmpty() && selectedShelves.isEmpty()) { + item { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = "Browse by tag", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + tagShelves.forEach { shelf -> + FilterChip( + selected = false, + onClick = { onShelfClick(shelf) }, + label = { Text(shelf.name) }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.tag), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + ) + } + } + } + } + } + + items(visibleShelves, key = { it.id }) { shelf -> ShelfListItem( shelf = shelf, - isSelected = shelf.name in selectedShelves, + isSelected = shelf.id in selectedShelves, onItemClick = { onShelfClick(shelf) }, onItemLongClick = { onShelfLongClick(shelf) } ) @@ -904,14 +977,17 @@ private fun CreateShelfDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit @Composable private fun ShelfDetailScreen( shelf: Shelf, + childShelves: List, selectedItems: Set, sortOrder: SortOrder, onSortOrderChange: (SortOrder) -> Unit, onBack: () -> Unit, onAddBooksClick: () -> Unit, + onChildShelfClick: (Shelf) -> Unit, onBookClick: (RecentFileItem) -> Unit, onBookLongClick: (RecentFileItem) -> Unit, onClearSelection: () -> Unit, + onTagClick: () -> Unit, onInfoClick: () -> Unit, onDeleteClick: () -> Unit, onRenameShelf: () -> Unit, @@ -919,8 +995,71 @@ private fun ShelfDetailScreen( downloadingBookIds: Set, ) { val isContextualModeActive = selectedItems.isNotEmpty() + val isFolderShelf = shelf.type == ShelfType.FOLDER var showSortMenu by remember { mutableStateOf(false) } var showMoreMenu by remember { mutableStateOf(false) } + var isSearchActive by remember(shelf.id) { mutableStateOf(false) } + var searchQuery by remember(shelf.id) { mutableStateOf("") } + val searchFocusRequester = remember { FocusRequester() } + var searchFieldValue by remember(isSearchActive, shelf.id) { + mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length))) + } + val normalizedQuery = searchQuery.trim() + val filteredChildShelves = remember(childShelves, normalizedQuery) { + if (normalizedQuery.isBlank()) { + childShelves + } else { + childShelves.filter { childShelf -> + childShelf.name.contains(normalizedQuery, ignoreCase = true) || + childShelf.books.any { item -> + item.displayName.contains(normalizedQuery, ignoreCase = true) || + item.title?.contains(normalizedQuery, ignoreCase = true) == true || + item.author?.contains(normalizedQuery, ignoreCase = true) == true + } + } + } + } + val filteredDirectBooks = remember(shelf.directBooks, normalizedQuery) { + if (normalizedQuery.isBlank()) { + shelf.directBooks + } else { + shelf.directBooks.filter { item -> + item.displayName.contains(normalizedQuery, ignoreCase = true) || + item.title?.contains(normalizedQuery, ignoreCase = true) == true || + item.author?.contains(normalizedQuery, ignoreCase = true) == true || + item.tags.any { tag -> tag.name.contains(normalizedQuery, ignoreCase = true) } + } + } + } + + LaunchedEffect(searchQuery) { + if (searchFieldValue.text != searchQuery) { + searchFieldValue = searchFieldValue.copy( + text = searchQuery, + selection = TextRange(searchQuery.length) + ) + } + } + + LaunchedEffect(isSearchActive) { + if (isSearchActive) { + searchFocusRequester.requestFocus() + } + } + + fun clearShelfSearchQuery() { + searchQuery = "" + searchFieldValue = TextFieldValue("", TextRange.Zero) + } + + fun closeShelfSearch() { + isSearchActive = false + clearShelfSearchQuery() + } + + BackHandler(enabled = isSearchActive) { + closeShelfSearch() + } Scaffold( modifier = Modifier, @@ -929,9 +1068,54 @@ private fun ShelfDetailScreen( ContextualTopAppBar( selectedItemCount = selectedItems.size, onNavIconClick = onClearSelection, + onTagClick = onTagClick, onInfoClick = onInfoClick, onDeleteClick = onDeleteClick ) + } else if (isSearchActive) { + Surface( + shadowElevation = 4.dp, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .height(64.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { closeShelfSearch() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search") + } + OutlinedTextField( + value = searchFieldValue, + onValueChange = { + searchFieldValue = it + searchQuery = it.text + }, + placeholder = { Text(stringResource(R.string.search_placeholder)) }, + modifier = Modifier + .weight(1f) + .padding(vertical = 4.dp) + .focusRequester(searchFocusRequester), + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { clearShelfSearchQuery() }) { + Icon(Icons.Default.Close, contentDescription = "Clear query") + } + } + } + ) + } + } } else { CustomTopAppBar( title = { @@ -942,7 +1126,14 @@ private fun ShelfDetailScreen( overflow = TextOverflow.Ellipsis ) Text( - text = getBookCountString(shelf.bookCount), + text = when { + isFolderShelf && shelf.childShelfCount > 0 && shelf.directBookCount > 0 -> + "${shelf.childShelfCount} folders • ${getBookCountString(shelf.directBookCount)}" + isFolderShelf && shelf.childShelfCount > 0 -> + "${shelf.childShelfCount} folders" + isFolderShelf -> getBookCountString(shelf.directBookCount) + else -> getBookCountString(shelf.bookCount) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -988,7 +1179,14 @@ private fun ShelfDetailScreen( } } - if (shelf.name != "Unshelved") { + IconButton(onClick = { isSearchActive = true }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search shelf" + ) + } + + if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved") { Box { IconButton(onClick = { showMoreMenu = true }) { Icon( @@ -1022,40 +1220,81 @@ private fun ShelfDetailScreen( } }, floatingActionButton = { - if (shelf.name != "Unshelved" && !isContextualModeActive) { + if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved" && !isContextualModeActive) { ExtendedFloatingActionButton( onClick = onAddBooksClick, icon = { Icon(Icons.Default.Add, contentDescription = null) }, text = { Text(stringResource(R.string.fab_add_books)) } ) } - } - ) { paddingValues -> - if (shelf.books.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(paddingValues), - contentAlignment = Alignment.Center - ) { - Text(stringResource(R.string.shelf_empty), style = MaterialTheme.typography.bodyLarge) - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize().padding(paddingValues), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(shelf.books, key = { it.bookId }) { item -> - LibraryListItem( - item = item, - isSelected = selectedItems.any { it.bookId == item.bookId }, - onItemClick = { onBookClick(item) }, - onItemLongClick = { onBookLongClick(item) }, - isDownloading = item.bookId in downloadingBookIds + }, + content = { paddingValues -> + if (filteredChildShelves.isEmpty() && filteredDirectBooks.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize().padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text( + text = if (normalizedQuery.isBlank()) stringResource(R.string.shelf_empty) else stringResource( + R.string.no_results_found, + normalizedQuery + ), + style = MaterialTheme.typography.bodyLarge ) } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (filteredChildShelves.isNotEmpty()) { + if (isFolderShelf) { + item { + Text( + text = "Folders", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + items(filteredChildShelves, key = { it.id }) { childShelf -> + ShelfListItem( + shelf = childShelf, + isSelected = false, + onItemClick = { onChildShelfClick(childShelf) }, + onItemLongClick = {}, + showHierarchyIndent = false + ) + } + } + if (filteredDirectBooks.isNotEmpty() && isFolderShelf && filteredChildShelves.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(4.dp)) + } + item { + Text( + text = "Files", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + items(filteredDirectBooks, key = { it.bookId }) { item -> + LibraryListItem( + item = item, + isSelected = selectedItems.any { it.bookId == item.bookId }, + onItemClick = { onBookClick(item) }, + onItemLongClick = { onBookLongClick(item) }, + isDownloading = item.bookId in downloadingBookIds + ) + } + } } } - } + ) } @Composable @@ -1073,76 +1312,67 @@ private fun AddBooksModeScreen( downloadingBookIds: Set, ) { var showSortMenu by remember { mutableStateOf(false) } - var showSourceMenu by remember { mutableStateOf(false) } Scaffold( modifier = Modifier, topBar = { - CustomTopAppBar( - title = { Text(stringResource(R.string.add_to_shelf, shelfName)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - actions = { - Box { - TextButton(onClick = { showSortMenu = true }) { - Icon( - painter = painterResource(id = R.drawable.sort), - contentDescription = "Sort", - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(sortOrder.displayName) + Column { + CustomTopAppBar( + title = { Text(stringResource(R.string.add_to_shelf, shelfName)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } - DropdownMenu( - expanded = showSortMenu, - onDismissRequest = { showSortMenu = false } - ) { - SortOrder.entries.forEach { order -> - DropdownMenuItem( - text = { Text(order.displayName) }, - onClick = { - onSortOrderChange(order) - showSortMenu = false - }, - trailingIcon = { - if (order == sortOrder) { - Icon(Icons.Default.Check, contentDescription = "Selected") - } - } + }, + actions = { + Box { + TextButton(onClick = { showSortMenu = true }) { + Icon( + painter = painterResource(id = R.drawable.sort), + contentDescription = "Sort", + modifier = Modifier.size(20.dp) ) + Spacer(modifier = Modifier.width(8.dp)) + Text(sortOrder.displayName) + } + DropdownMenu( + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false } + ) { + SortOrder.entries.forEach { order -> + DropdownMenuItem( + text = { Text(order.displayName) }, + onClick = { + onSortOrderChange(order) + showSortMenu = false + }, + trailingIcon = { + if (order == sortOrder) { + Icon(Icons.Default.Check, contentDescription = "Selected") + } + } + ) + } } } } - - Box { - IconButton(onClick = { showSourceMenu = true }) { - Icon(Icons.Default.MoreVert, contentDescription = "More options") - } - DropdownMenu( - expanded = showSourceMenu, - onDismissRequest = { showSourceMenu = false } - ) { - AddBooksSource.entries.forEach { source -> - DropdownMenuItem( - text = { Text(source.displayName) }, - onClick = { - onSourceChange(source) - showSourceMenu = false - }, - trailingIcon = { - if (source == currentSource) { - Icon(Icons.Default.Check, contentDescription = "Selected") - } - } - ) - } - } + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AddBooksSource.entries.forEach { source -> + FilterChip( + selected = source == currentSource, + onClick = { onSourceChange(source) }, + label = { Text(source.displayName) } + ) } } - ) + } }, floatingActionButton = { if (selectedBookUris.isNotEmpty()) { @@ -1152,37 +1382,42 @@ private fun AddBooksModeScreen( onClick = onAddSelectedBooks ) } - } - ) { paddingValues -> - if (availableBooks.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(paddingValues), - contentAlignment = Alignment.Center - ) { - Text( - text = if (currentSource == AddBooksSource.UNSHELVED) stringResource(R.string.no_unshelved_books) else stringResource(R.string.all_books_in_shelf), - style = MaterialTheme.typography.bodyLarge - ) - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize().padding(paddingValues), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(availableBooks, key = { it.bookId }) { item -> - val isSelected = item.bookId in selectedBookUris - LibraryListItem( - item = item, - isSelected = isSelected, - onItemClick = { onBookClick(item) }, - onItemLongClick = { onBookClick(item) }, - isDownloading = item.bookId in downloadingBookIds + }, + content = { paddingValues -> + if (availableBooks.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize().padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text( + text = if (currentSource == AddBooksSource.UNSHELVED) { + stringResource(R.string.no_unshelved_books) + } else { + stringResource(R.string.all_books_in_shelf) + }, + style = MaterialTheme.typography.bodyLarge ) } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(paddingValues), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(availableBooks, key = { it.bookId }) { item -> + val isSelected = item.bookId in selectedBookUris + LibraryListItem( + item = item, + isSelected = isSelected, + onItemClick = { onBookClick(item) }, + onItemLongClick = { onBookClick(item) }, + isDownloading = item.bookId in downloadingBookIds + ) + } + } } } - } + ) } @Composable @@ -1260,7 +1495,10 @@ private fun ShelfListItem( isSelected: Boolean, onItemClick: () -> Unit, onItemLongClick: () -> Unit, + showHierarchyIndent: Boolean = true, ) { + val folderIndent = if (showHierarchyIndent && shelf.type == ShelfType.FOLDER) (shelf.depth * 14).dp else 0.dp + androidx.compose.material3.ElevatedCard( shape = MaterialTheme.shapes.large, colors = androidx.compose.material3.CardDefaults.elevatedCardColors( @@ -1286,7 +1524,7 @@ private fun ShelfListItem( ) ) { Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + modifier = Modifier.padding(start = 12.dp + folderIndent, end = 12.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically ) { ShelfCover(shelf = shelf) @@ -1294,13 +1532,29 @@ private fun ShelfListItem( Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { - Text( - text = shelf.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + Row(verticalAlignment = Alignment.CenterVertically) { + val icon = when (shelf.type) { + ShelfType.SMART -> Icons.Default.Star + ShelfType.TAG -> Icons.AutoMirrored.Filled.LibraryBooks + ShelfType.FOLDER -> Icons.Default.Folder + ShelfType.SERIES -> Icons.AutoMirrored.Filled.LibraryBooks + ShelfType.MANUAL -> Icons.AutoMirrored.Filled.List + } + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = shelf.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } Spacer(modifier = Modifier.height(4.dp)) Text( text = getBookCountString(shelf.bookCount), @@ -1378,7 +1632,6 @@ private fun LibraryListItem( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) - if (isSelected) { Box( modifier = Modifier.matchParentSize().background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), @@ -1432,57 +1685,60 @@ private fun LibraryListItem( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { FileTypeBadge(type = item.type, overlay = false) - Box( - modifier = Modifier - .weight(1f) - .height(28.dp), - contentAlignment = Alignment.CenterStart - ) { - if (!item.isAvailable) { - Surface( - shape = RoundedCornerShape(50), - color = if (isDownloading) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.errorContainer - }, - contentColor = if (isDownloading) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onErrorContainer - } + if (item.tags.isNotEmpty()) { + BookTagChipsRow( + tags = item.tags, + compact = true, + modifier = Modifier.weight(1f, fill = false) + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + + if (!item.isAvailable) { + Surface( + shape = RoundedCornerShape(50), + color = if (isDownloading) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + }, + contentColor = if (isDownloading) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onErrorContainer + } + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - if (isDownloading) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp - ) - } else { - Icon( - Icons.Filled.Info, - contentDescription = stringResource(R.string.not_available_locally), - modifier = Modifier.size(14.dp) - ) - } - Text( - text = if (isDownloading) { - stringResource(R.string.status_downloading) - } else { - stringResource(R.string.not_available_locally) - }, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium + if (isDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + Icons.Filled.Info, + contentDescription = stringResource(R.string.not_available_locally), + modifier = Modifier.size(14.dp) ) } + Text( + text = if (isDownloading) { + stringResource(R.string.status_downloading) + } else { + stringResource(R.string.not_available_locally) + }, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium + ) } } } @@ -1558,7 +1814,7 @@ private fun DeleteShelfConfirmationDialog( AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.dialog_delete_shelf)) }, - text = { Text(stringResource(R.string.dialog_delete_shelf_desc)) }, + text = { Text(stringResource(R.string.dialog_delete_shelf_desc, shelfName)) }, confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) } }, @@ -1912,6 +2168,7 @@ private fun EditFolderFiltersDialog( @Composable fun LibraryFilterSheet( filters: LibraryFilters, + allTags: List, syncedFolders: List, onApply: (LibraryFilters) -> Unit, onDismiss: () -> Unit @@ -1987,6 +2244,41 @@ fun LibraryFilterSheet( } } + if (allTags.isNotEmpty()) { + Text("Tags", style = MaterialTheme.typography.titleMedium) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + allTags.forEach { tag -> + val selected = tag.id in currentFilters.tagIds + FilterChip( + selected = selected, + onClick = { + val newSet = if (selected) { + currentFilters.tagIds - tag.id + } else { + currentFilters.tagIds + tag.id + } + currentFilters = currentFilters.copy(tagIds = newSet) + }, + label = { Text(tag.name) }, + leadingIcon = { + Box( + modifier = Modifier + .size(10.dp) + .background( + Color(tag.color ?: 0xFF64B5F6.toInt()), + CircleShape + ) + ) + } + ) + } + } + } + Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) { TextButton(onClick = { currentFilters = LibraryFilters() }) { Text(stringResource(R.string.clear_all)) diff --git a/app/src/main/java/com/aryan/reader/MainScreen.kt b/app/src/main/java/com/aryan/reader/MainScreen.kt index c1195e6..07884d1 100644 --- a/app/src/main/java/com/aryan/reader/MainScreen.kt +++ b/app/src/main/java/com/aryan/reader/MainScreen.kt @@ -69,60 +69,77 @@ fun MainScreen( } val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val viewingShelfName = uiState.viewingShelfName + val viewingShelfName = uiState.viewingShelfId - if (viewingShelfName != null) { - ShelfScreen(viewModel = viewModel) - } else { - val pagerState = rememberPagerState( - initialPage = uiState.mainScreenStartPage, - pageCount = { bottomBarItems.size } - ) - val scope = rememberCoroutineScope() + androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) { + if (viewingShelfName != null) { + ShelfScreen(viewModel = viewModel) + } else { + val pagerState = rememberPagerState( + initialPage = uiState.mainScreenStartPage, + pageCount = { bottomBarItems.size } + ) + val scope = rememberCoroutineScope() - LaunchedEffect(uiState.mainScreenStartPage) { - if (pagerState.currentPage != uiState.mainScreenStartPage) { - pagerState.animateScrollToPage(uiState.mainScreenStartPage) + LaunchedEffect(uiState.mainScreenStartPage) { + if (pagerState.currentPage != uiState.mainScreenStartPage) { + pagerState.animateScrollToPage(uiState.mainScreenStartPage) + } } - } - LaunchedEffect(pagerState.currentPage) { - viewModel.setMainScreenPage(pagerState.currentPage) - } + LaunchedEffect(pagerState.currentPage) { + viewModel.setMainScreenPage(pagerState.currentPage) + } - Scaffold( - contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0), - bottomBar = { - NavigationBar { - bottomBarItems.forEachIndexed { index, screen -> - NavigationBarItem( - icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) }, - label = { Text(stringResource(screen.stringResId)) }, - selected = pagerState.currentPage == index, - onClick = { scope.launch { pagerState.animateScrollToPage(index) } } + Scaffold( + contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0), + bottomBar = { + NavigationBar { + bottomBarItems.forEachIndexed { index, screen -> + NavigationBarItem( + icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) }, + label = { Text(stringResource(screen.stringResId)) }, + selected = pagerState.currentPage == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } } + ) + } + } + } + ) { innerPadding -> + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + key = { bottomBarItems[it].route }, + beyondViewportPageCount = 1, + userScrollEnabled = false + ) { page -> + when (page) { + 0 -> HomeScreen( + viewModel = viewModel, + windowSizeClass = windowSizeClass, + navController = navController ) + 1 -> LibraryScreen(viewModel = viewModel) } } } - ) { innerPadding -> - HorizontalPager( - state = pagerState, - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - key = { bottomBarItems[it].route }, - beyondViewportPageCount = 1, - userScrollEnabled = false - ) { page -> - when (page) { - 0 -> HomeScreen( - viewModel = viewModel, - windowSizeClass = windowSizeClass, - navController = navController - ) - 1 -> LibraryScreen(viewModel = viewModel) - } - } + } + + if (uiState.showTagSelectionDialogFor.isNotEmpty()) { + TagSelectionBottomSheet( + allTags = uiState.allTags, + selectedBookIds = uiState.showTagSelectionDialogFor, + booksWithTags = uiState.rawLibraryFiles, + onCreateAndAssign = { name -> + viewModel.createAndAssignTag(name, uiState.showTagSelectionDialogFor) + }, + onToggleTag = { tagId, assign -> + viewModel.toggleTagForBooks(tagId, uiState.showTagSelectionDialogFor, assign) + }, + onDismiss = viewModel::closeTagSelection + ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 1fa5ec2..a60f26a 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -35,18 +35,15 @@ import android.provider.DocumentsContract import android.provider.OpenableColumns import androidx.compose.ui.graphics.toArgb import androidx.core.content.edit +import androidx.core.graphics.createBitmap import androidx.core.net.toUri import androidx.credentials.exceptions.GetCredentialCancellationException -import kotlinx.coroutines.withTimeoutOrNull import androidx.credentials.exceptions.NoCredentialException import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import androidx.work.Constraints -import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager import com.aryan.reader.data.CloudflareRepository @@ -61,12 +58,19 @@ import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFilesRepository import com.aryan.reader.data.RemoteConfigRepository import com.aryan.reader.data.ShelfMetadata +import com.aryan.reader.data.SmartCollectionEngine +import com.aryan.reader.data.TagEntity import com.aryan.reader.data.toBookMetadata import com.aryan.reader.data.toRecentFileItem +import com.aryan.reader.epub.CalibreBundleExtractor +import com.aryan.reader.epub.CalibreBundleResult import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubParser +import com.aryan.reader.epub.ImportedFileCache import com.aryan.reader.epub.MobiParser import com.aryan.reader.epub.SingleFileImporter +import com.aryan.reader.ml.ISpeechBubbleDetector +import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookProcessingWorker @@ -83,9 +87,12 @@ import com.aryan.reader.pdf.data.PdfTextBoxRepository import com.aryan.reader.pdf.data.PdfTextRepository import com.aryan.reader.pdf.data.VirtualPage import com.tom_roush.pdfbox.android.PDFBoxResourceLoader +import io.legere.pdfiumandroid.PdfiumCore import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay @@ -94,6 +101,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -105,6 +113,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONArray import org.json.JSONObject import timber.log.Timber @@ -112,23 +121,45 @@ import java.io.File import java.io.FileOutputStream import java.util.Date import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CancellationException -import java.util.concurrent.TimeUnit -import androidx.core.graphics.createBitmap -import io.legere.pdfiumandroid.PdfiumCore -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.flow.distinctUntilChanged import java.util.concurrent.Executors.newSingleThreadExecutor +import java.util.concurrent.TimeUnit private const val KEY_RENDER_MODE = "render_mode" private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled" +private const val KEY_MAIN_SCREEN_START_PAGE = "main_screen_start_page" +private const val KEY_LIBRARY_SCREEN_START_PAGE = "library_screen_start_page" +private const val KEY_LAST_VIEWING_SHELF_ID = "last_viewing_shelf_id" +private const val KEY_LAST_ADDING_BOOKS_TO_SHELF = "last_adding_books_to_shelf" private const val KEY_FILTER_FILE_TYPES = "filter_file_types" private const val KEY_FILTER_FOLDERS = "filter_folders" private const val KEY_FILTER_READ_STATUS = "filter_read_status" +private const val KEY_FILTER_TAG_IDS = "filter_tag_ids" +private const val KEY_DEFAULT_TAGS_SEEDED = "default_tags_seeded" +private val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7) +private val EPUB_READER_FILE_TYPES = setOf( + FileType.EPUB, + FileType.MOBI, + FileType.MD, + FileType.TXT, + FileType.HTML, + FileType.FB2, + FileType.DOCX, + FileType.ODT, + FileType.FODT +) data class BannerMessage(val message: String, val isError: Boolean = false, val isPersistent: Boolean = false) +data class ImportResult( + val internalUri: Uri, + val bookId: String, + val type: FileType, + val bundleResult: CalibreBundleResult? = null +) + data class UserData( val uid: String, val displayName: String?, val photoUrl: String?, val email: String? ) @@ -137,6 +168,19 @@ data class NavigationEvent( val route: String, val bookId: String? = null, val uri: Uri? = null ) +private data class SpeechBubbleCacheKey( + val documentId: String, + val pageIndex: Int +) + +private data class CachedSpeechBubble( + val leftFraction: Float, + val topFraction: Float, + val rightFraction: Float, + val bottomFraction: Float, + val maskBitmap: Bitmap? +) + enum class AddBooksSource(val displayName: String) { UNSHELVED("Unshelved"), ALL_BOOKS("All Books") } @@ -177,11 +221,23 @@ data class SyncedFolder( val uriString: String, val name: String, val lastScanTime: Long, val allowedFileTypes: Set = FileType.entries.toSet() ) -data class Shelf(val name: String, val books: List) { - val bookCount: Int - get() = books.size - val topBook: RecentFileItem? - get() = books.maxByOrNull { it.timestamp } +enum class ShelfType { MANUAL, SMART, TAG, SERIES, FOLDER } + +data class Shelf( + val id: String, + val name: String, + val type: ShelfType, + val books: List, + val directBooks: List = books, + val parentShelfId: String? = null, + val childShelfIds: List = 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(val displayName: String) { @@ -201,10 +257,14 @@ enum class ReadStatusFilter(val displayName: String) { data class LibraryFilters( val fileTypes: Set = emptySet(), val sourceFolders: Set = emptySet(), - val readStatus: ReadStatusFilter = ReadStatusFilter.ALL + val readStatus: ReadStatusFilter = ReadStatusFilter.ALL, + val tagIds: Set = emptySet() ) { val isActive: Boolean - get() = fileTypes.isNotEmpty() || sourceFolders.isNotEmpty() || readStatus != ReadStatusFilter.ALL + get() = fileTypes.isNotEmpty() || + sourceFolders.isNotEmpty() || + readStatus != ReadStatusFilter.ALL || + tagIds.isNotEmpty() } data class ReaderScreenState( @@ -224,7 +284,7 @@ data class ReaderScreenState( val initialHighlightsJson: String? = null, val initialPageInBook: Int? = null, val shelves: List = emptyList(), - val viewingShelfName: String? = null, + val viewingShelfId: String? = null, val isAddingBooksToShelf: Boolean = false, val showCreateShelfDialog: Boolean = false, val mainScreenStartPage: Int = 0, @@ -234,7 +294,7 @@ data class ReaderScreenState( val addBooksSource: AddBooksSource = AddBooksSource.UNSHELVED, val booksSelectedForAdding: Set = emptySet(), val booksAvailableForAdding: List = emptyList(), - val contextualActionShelfNames: Set = emptySet(), + val contextualActionShelfIds: Set = emptySet(), val currentUser: UserData? = null, val isAuthMenuExpanded: Boolean = false, val isProUser: Boolean = false, @@ -272,7 +332,9 @@ data class ReaderScreenState( val appContrastOption: AppContrastOption = AppContrastOption.STANDARD, val appTextDimFactor: Float = 1.0f, val appSeedColor: androidx.compose.ui.graphics.Color? = null, - val customAppThemes: List = emptyList() + val customAppThemes: List = emptyList(), + val allTags: List = emptyList(), + val showTagSelectionDialogFor: Set = emptySet(), ) open class MainViewModel(application: Application) : AndroidViewModel(application) { @@ -310,6 +372,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private val feedbackRepository = FeedbackRepository(appContext) private var feedbackListener: Any? = null private val importMutex = Mutex() + private val epubRecoveryMutex = Mutex() private val _navigationEvent = Channel(Channel.BUFFERED) @Suppress("unused") val navigationEvent = _navigationEvent.receiveAsFlow() @@ -317,8 +380,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private var externalOpenedBookId: String? = null private var panelDetector: com.aryan.reader.ml.IPanelDetector? = null + private var speechBubbleDetector: ISpeechBubbleDetector? = null private val mlDispatcher = newSingleThreadExecutor().asCoroutineDispatcher() + private val speechBubbleCacheMutex = Mutex() + private val speechBubbleCache = ConcurrentHashMap>() + private val speechBubbleDetectionJobs = ConcurrentHashMap>>() private fun getOrInitDetector(context: Context): com.aryan.reader.ml.IPanelDetector? { if (panelDetector == null && BuildConfig.DEBUG) { @@ -337,6 +404,246 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio return panelDetector } + private fun getOrInitSpeechBubbleDetector(context: Context): ISpeechBubbleDetector? { + if (speechBubbleDetector == null && BuildConfig.FLAVOR != "oss") { + val modelFile = File(context.getExternalFilesDir(null), "manga_speech_bubble_v3.ort") + if (modelFile.exists()) { + try { + val clazz = Class.forName("com.aryan.reader.ml.SpeechBubbleDetector") + speechBubbleDetector = clazz.getConstructor(File::class.java).newInstance(modelFile) as ISpeechBubbleDetector + } catch (t: Throwable) { + Timber.e(t, "Failed to instantiate SpeechBubbleDetector via reflection. Deleting corrupted model.") + modelFile.delete() + } + } else { + Timber.e("Model file manga_speech_bubble_v3.ort not found in external files dir") + } + } + return speechBubbleDetector + } + + private fun normalizeSpeechBubbles( + bubbles: List, + width: Int, + height: Int + ): List { + if (width <= 0 || height <= 0) return emptyList() + val widthF = width.toFloat() + val heightF = height.toFloat() + return bubbles.mapNotNull { bubble -> + val bounds = bubble.bounds + if (bounds.width() <= 0f || bounds.height() <= 0f) { + null + } else { + CachedSpeechBubble( + leftFraction = (bounds.left / widthF).coerceIn(0f, 1f), + topFraction = (bounds.top / heightF).coerceIn(0f, 1f), + rightFraction = (bounds.right / widthF).coerceIn(0f, 1f), + bottomFraction = (bounds.bottom / heightF).coerceIn(0f, 1f), + maskBitmap = bubble.maskBitmap + ) + } + } + } + + private fun scaleCachedSpeechBubbles( + bubbles: List, + width: Int, + height: Int + ): List { + if (width <= 0 || height <= 0) return emptyList() + val widthF = width.toFloat() + val heightF = height.toFloat() + return bubbles.mapNotNull { bubble -> + val bounds = android.graphics.RectF( + bubble.leftFraction * widthF, + bubble.topFraction * heightF, + bubble.rightFraction * widthF, + bubble.bottomFraction * heightF + ) + if (bounds.width() <= 0f || bounds.height() <= 0f) { + null + } else { + SpeechBubble(bounds = bounds, maskBitmap = bubble.maskBitmap) + } + } + } + + fun hasCachedSpeechBubbles(documentId: String, pageIndex: Int): Boolean { + return speechBubbleCache.containsKey(SpeechBubbleCacheKey(documentId, pageIndex)) + } + + fun testSpeechBubbleDetection(context: Context) { + viewModelScope.launch(mlDispatcher) { + try { // <--- We now wrap the WHOLE thing in a Throwable catch + val modelFile = File(context.getExternalFilesDir(null), "manga_speech_bubble_v3.ort") + if (!modelFile.exists()) { + withContext(Dispatchers.Main) { showBanner("ONNX Model not found", isError = true) } + return@launch + } + + val cbzItem = uiState.value.contextualActionItems.firstOrNull { it.type == FileType.CBZ } + ?: uiState.value.allRecentFiles.firstOrNull { it.type == FileType.CBZ } + + if (cbzItem == null) { + withContext(Dispatchers.Main) { showBanner("No CBZ found in Library.", isError = true) } + return@launch + } + + val uri = cbzItem.getUri() ?: return@launch + Timber.d("BUBBLE TEST START: ${cbzItem.displayName}") + + var cacheFile: File? = null + try { + Timber.d("Initializing Speech Bubble Detector...") + val detector = getOrInitSpeechBubbleDetector(context) ?: run { + withContext(Dispatchers.Main) { showBanner("ONNX Model could not be loaded", isError = true) } + return@launch + } + Timber.d("Detector successfully initialized. Copying CBZ to cache...") + + cacheFile = File(context.cacheDir, "temp_test_bubble.cbz") + context.contentResolver.openInputStream(uri)?.use { input -> + cacheFile.outputStream().use { output -> input.copyTo(output) } + } + Timber.d("CBZ copied to cache successfully. Opening archive...") + + val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile) + val totalPages = archiveDoc.getPageCount() + Timber.d("Archive opened. Total pages: $totalPages") + + val targetIndex = 2 + if (targetIndex < totalPages) { + Timber.d("Reading page $targetIndex...") + val page = archiveDoc.openPage(targetIndex) + if (page != null) { + val w = page.getPageWidthPoint() + val h = page.getPageHeightPoint() + if (w > 0 && h > 0) { + Timber.d("Rendering bitmap: $w x $h...") + val bitmap = androidx.core.graphics.createBitmap(w, h) + page.renderPageBitmap(bitmap, 0, 0, w, h, false) + + Timber.d("Running ONNX Inference...") + val pageStartTime = System.currentTimeMillis() + val bubbles = detector.detectBubbles(bitmap, confidenceThreshold = 0.4f) + val pageDuration = System.currentTimeMillis() - pageStartTime + + val logLine = "Page $targetIndex: ${pageDuration}ms (Found ${bubbles.size} bubbles)" + Timber.d(">>> [BUBBLE] $logLine") + + withContext(Dispatchers.Main) { + showBanner("Bubble Test Complete! $logLine") + } + bitmap.recycle() + } + page.close() + } + } else { + Timber.e("Page $targetIndex out of bounds") + withContext(Dispatchers.Main) { + showBanner("CBZ does not have a 3rd page.", isError = true) + } + } + archiveDoc.close() + Timber.d("Archive closed cleanly.") + } finally { + cacheFile?.delete() + } + } catch (t: Throwable) { + Timber.e(t, "Fatal error during bubble test") + } + } + } + + val speechBubbleModelDownloadProgress = MutableStateFlow(null) + + fun isSpeechBubbleModelAvailable(context: Context): Boolean { + return File(context.getExternalFilesDir(null), "manga_speech_bubble_v3.ort").exists() + } + + fun downloadSpeechBubbleModel(context: Context) { + if (speechBubbleModelDownloadProgress.value != null) return + viewModelScope.launch(Dispatchers.IO) { + speechBubbleModelDownloadProgress.value = 0f + var success = false + val modelFile = File(context.getExternalFilesDir(null), "manga_speech_bubble_v3.ort") + val tempFile = File(context.getExternalFilesDir(null), "manga_speech_bubble_v3.ort.tmp") + val urlString = "https://huggingface.co/1m4ryan/speech-bubble-detector/resolve/main/manga_speech_bubble_v3.ort" + + var downloadedBytes = if (tempFile.exists()) tempFile.length() else 0L + val maxRetries = 3 + var retryCount = 0 + + while (retryCount < maxRetries && !success) { + try { + val url = java.net.URL(urlString) + val connection = url.openConnection() as java.net.HttpURLConnection + + if (downloadedBytes > 0) { + connection.setRequestProperty("Range", "bytes=$downloadedBytes-") + } + + connection.connectTimeout = 15000 + connection.readTimeout = 15000 + connection.connect() + + val responseCode = connection.responseCode + val isPartial = responseCode == java.net.HttpURLConnection.HTTP_PARTIAL + + if (responseCode == java.net.HttpURLConnection.HTTP_OK && downloadedBytes > 0) { + downloadedBytes = 0L + } else if (responseCode != java.net.HttpURLConnection.HTTP_OK && !isPartial) { + throw Exception("HTTP error code: $responseCode") + } + + val contentLength = connection.getHeaderField("Content-Length")?.toLongOrNull() ?: -1L + val totalFileLength = if (contentLength != -1L) downloadedBytes + contentLength else -1L + + val input = connection.inputStream + val output = java.io.FileOutputStream(tempFile, isPartial) + val data = ByteArray(16 * 1024) + var count: Int + + while (input.read(data).also { count = it } != -1) { + output.write(data, 0, count) + downloadedBytes += count + if (totalFileLength > 0) { + speechBubbleModelDownloadProgress.value = (downloadedBytes.toFloat() / totalFileLength).coerceIn(0f, 1f) + } + } + output.flush() + output.close() + input.close() + + if (tempFile.exists() && tempFile.length() > 0) { + if (modelFile.exists()) modelFile.delete() + if (tempFile.renameTo(modelFile)) { + success = true + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to download Bubble Zoom model, attempt ${retryCount + 1}") + retryCount++ + if (retryCount >= maxRetries) break + delay(2000) + downloadedBytes = if (tempFile.exists()) tempFile.length() else 0L + } + } + + speechBubbleModelDownloadProgress.value = null + withContext(Dispatchers.Main) { + if (success) { + showBanner("Bubble Zoom model downloaded successfully!") + } else { + showBanner("Download failed. Please keep the app open during download.", isError = true) + } + } + } + } + data class PageModificationResult( val layout: List, val annotations: Map>, @@ -377,6 +684,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } catch (_: IllegalArgumentException) { AddBooksSource.UNSHELVED }, + mainScreenStartPage = prefs.getInt(KEY_MAIN_SCREEN_START_PAGE, 0).coerceIn(0, 1), + libraryScreenStartPage = prefs.getInt( + KEY_LIBRARY_SCREEN_START_PAGE, + 0 + ).coerceIn(0, if (BuildConfig.IS_OFFLINE) 2 else 3), + viewingShelfId = prefs.getString(KEY_LAST_VIEWING_SHELF_ID, null), + isAddingBooksToShelf = prefs.getBoolean(KEY_LAST_ADDING_BOOKS_TO_SHELF, false), currentUser = authRepository.getSignedInUser(), isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false), isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false), @@ -387,7 +701,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio sourceFolders = prefs.getStringSet(KEY_FILTER_FOLDERS, emptySet()) ?: emptySet(), readStatus = runCatching { ReadStatusFilter.valueOf(prefs.getString(KEY_FILTER_READ_STATUS, ReadStatusFilter.ALL.name) ?: ReadStatusFilter.ALL.name) - }.getOrDefault(ReadStatusFilter.ALL) + }.getOrDefault(ReadStatusFilter.ALL), + tagIds = prefs.getStringSet(KEY_FILTER_TAG_IDS, emptySet()) ?: emptySet() ), syncedFolders = loadSyncedFoldersFromPrefs(), lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong( @@ -419,24 +734,89 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) ) + private suspend fun prepareBookForImport(externalUri: Uri): ImportResult? { + val displayName = getFileNameFromUri(externalUri, appContext) + var type = getFileTypeFromUri(externalUri, appContext) + + val hash = FileHasher.calculateSha256 { + appContext.contentResolver.openInputStream(externalUri) + } + if (hash == null) { + Timber.e("Failed to process file hash for $externalUri") + return null + } + + val existingItem = recentFilesRepository.getFileByBookId(hash) + if (existingItem != null) { + Timber.i("Book with ID: $hash already exists. Skipping import.") + return null + } + + val fileName = displayName ?: "" + if (fileName.endsWith(".zip", ignoreCase = true) || type == FileType.CBZ) { + val bundleResult = CalibreBundleExtractor.processZip(appContext, externalUri, hash, bookImporter, recentFilesRepository) + + Timber.d("MainViewModel: Calibre processZip returned: $bundleResult") + + if (bundleResult != null) { + return ImportResult( + internalUri = bundleResult.internalBookUri, + bookId = hash, + type = bundleResult.type, + bundleResult = bundleResult + ) + } + if (type == null) type = FileType.CBZ + } + + if (type == null) return null + + Timber.i("Importing new book with ID: $hash") + val internalFile = bookImporter.importBook(externalUri) ?: return null + return ImportResult(internalFile.toUri(), hash, type, null) + } + + val libraryFlow = combine( + recentFilesRepository.getRecentFilesFlow(), + recentFilesRepository.activeShelvesFlow, + recentFilesRepository.shelfCrossRefsFlow, + ::Triple + ) + + val tagFlow = combine( + recentFilesRepository.tagsFlow, + recentFilesRepository.tagCrossRefsFlow, + ::Pair + ) + open val uiState: StateFlow = combine( - _internalState, recentFilesRepository.getRecentFilesFlow(), _prefsUpdateFlow - ) { internalState, recentFilesFromDb, _ -> + _internalState, libraryFlow, tagFlow + ) { internalState, (recentFilesFromDb, dbShelves, shelfRefs), (dbTags, tagRefs) -> + val tagsById = dbTags.associateBy { it.id } + val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry -> + entry.value.mapNotNull { tagsById[it.tagId] } + } + + val allLibraryFiles = recentFilesFromDb + .filterNot { it.bookId.endsWith("_reflow") } + .map { item -> + item.copy(tags = bookTagsMap[item.bookId] ?: emptyList()) + } + val query = internalState.searchQuery.trim() val rawFilteredByQuery = if (query.isBlank()) { - recentFilesFromDb + allLibraryFiles } else { - recentFilesFromDb.filter { item -> + allLibraryFiles.filter { item -> item.displayName.contains(query, ignoreCase = true) || - item.title?.contains(query, ignoreCase = true) == true || - item.author?.contains(query, ignoreCase = true) == 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 baseVisibleFiles = rawFilteredByQuery.filterNot { it.bookId.endsWith("_reflow") } - val filters = internalState.libraryFilters - val libraryFiltered = baseVisibleFiles.filter { item -> + 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 @@ -450,7 +830,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ReadStatusFilter.IN_PROGRESS -> progress > 0f && progress < 100f ReadStatusFilter.COMPLETED -> progress >= 100f } - matchType && matchFolder && matchStatus + val matchTags = if (filters.tagIds.isNotEmpty()) { + item.tags.any { it.id in filters.tagIds } + } else true + matchType && matchFolder && matchStatus && matchTags } fun sortFiles(files: List): List { @@ -465,65 +848,97 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - val sortedLibraryFiles = sortFiles(libraryFiltered).let { list -> - val pinned = list.filter { it.bookId in internalState.pinnedLibraryBookIds } - val unpinned = list.filter { it.bookId !in internalState.pinnedLibraryBookIds } - pinned + unpinned - } - - val visibleRecentFiles = sortFiles(baseVisibleFiles.filter { it.isRecent }).let { list -> - val pinned = list.filter { it.bookId in internalState.pinnedHomeBookIds } - val unpinned = list.filter { it.bookId !in internalState.pinnedHomeBookIds } - val combined = pinned + unpinned - if (internalState.recentFilesLimit > 0) combined.take(internalState.recentFilesLimit) else combined - } - - val allBaseFiles = recentFilesFromDb.filterNot { it.bookId.endsWith("_reflow") } - val openTabsList = internalState.openTabIds.mapNotNull { tabId -> - allBaseFiles.find { it.bookId == tabId } - } - - val validContextualItems = internalState.contextualActionItems.filter { contextItem -> - baseVisibleFiles.any { dbItem -> dbItem.uriString == contextItem.uriString } - }.toSet() - - val shelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()) ?: emptySet() + 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() val shelvedBookIds = mutableSetOf() + val baseFilesMap = allLibraryFiles.associateBy { it.bookId } - val shelvesFromPrefs = shelfNames.map { shelfName -> - val bookIds = prefs.getStringSet("$KEY_SHELF_CONTENT_PREFIX$shelfName", emptySet()) ?: emptySet() - val booksForShelf = baseVisibleFiles.filter { it.bookId in bookIds } - shelvedBookIds.addAll(booksForShelf.map { it.bookId }) - Shelf(shelfName, booksForShelf) - }.sortedBy { it.name } - - val unshelvedBooks = baseVisibleFiles.filter { it.bookId !in shelvedBookIds } - val allShelves = shelvesFromPrefs + Shelf("Unshelved", unshelvedBooks) - - val booksAvailableForAdding = - if (internalState.isAddingBooksToShelf && internalState.viewingShelfName != null) { - val currentShelfBooksUris = allShelves.find { - it.name == internalState.viewingShelfName - }?.books?.map { it.uriString }?.toSet() ?: emptySet() - - when (internalState.addBooksSource) { - AddBooksSource.UNSHELVED -> unshelvedBooks - AddBooksSource.ALL_BOOKS -> baseVisibleFiles.filter { - it.uriString !in currentShelfBooksUris - } + 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 { - emptyList() + 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 = baseVisibleFiles, - contextualActionItems = validContextualItems, + 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 + booksAvailableForAdding = booksAvailableForAdding, + allTags = dbTags ) }.stateIn( scope = viewModelScope, @@ -531,6 +946,145 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio 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 = mutableListOf(), + val directBooks: MutableList = mutableListOf(), + val childShelfIds: MutableList = mutableListOf() + ) + + private fun buildFolderShelves( + allLibraryFiles: List, + syncedFolders: List, + sortFiles: (List) -> List + ): List { + 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().also { direct -> + direct.addAll(books.filter { getRelativeFolderSegments(it).isEmpty() }) + }, + childShelfIds = emptyList(), + depth = 0, + sortKey = "folder:${rootName.lowercase()}:" + ) + + val nestedShelves = linkedMapOf() + 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 { + val documentUriString = item.uriString ?: return emptyList() + val rootFolderUriString = item.sourceFolderUri ?: return emptyList() + + return try { + val documentUri = documentUriString.toUri() + val rootFolderUri = rootFolderUriString.toUri() + val rootDocId = DocumentsContract.getTreeDocumentId(rootFolderUri) + val documentId = when { + DocumentsContract.isDocumentUri(appContext, documentUri) -> DocumentsContract.getDocumentId(documentUri) + DocumentsContract.isTreeUri(documentUri) -> DocumentsContract.getTreeDocumentId(documentUri) + else -> return emptyList() + } + + val rootPath = rootDocId.substringAfter(':', "") + val documentPath = documentId.substringAfter(':', "") + val relativeDocumentPath = when { + rootPath.isBlank() -> documentPath + documentPath == rootPath -> "" + documentPath.startsWith("$rootPath/") -> documentPath.removePrefix("$rootPath/") + else -> documentPath + } + + relativeDocumentPath + .substringBeforeLast('/', "") + .split('/') + .map { Uri.decode(it).trim() } + .filter { it.isNotEmpty() } + } catch (e: Exception) { + Timber.tag("FolderShelves").w(e, "Failed to derive relative folder path for ${item.displayName}") + emptyList() + } + } + fun setTabsEnabled(enabled: Boolean) { prefs.edit { putBoolean(KEY_TABS_ENABLED, enabled) } _internalState.update { it.copy(isTabsEnabled = enabled) } @@ -569,6 +1123,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.tag("PdfTabSync").d("ViewModel: ActiveTab updated to $bookId. URI found: ${uri != null}") uri?.let { + persistReaderSession(bookId, item.type) Timber.tag("PdfTabSync").d("ViewModel: Setting new URI directly: $it") _internalState.update { state -> state.copy( @@ -599,6 +1154,140 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + fun openTagSelection(bookIds: Set) { + if (bookIds.isEmpty()) return + _internalState.update { it.copy(showTagSelectionDialogFor = bookIds) } + } + + fun closeTagSelection() { + _internalState.update { it.copy(showTagSelectionDialogFor = emptySet()) } + } + + fun createAndAssignTag(name: String, bookIds: Set) { + val trimmedName = name.trim() + if (trimmedName.isBlank() || bookIds.isEmpty()) return + + viewModelScope.launch { + val tagId = UUID.randomUUID().toString() + val colors = listOf(0xFFE57373, 0xFFF06292, 0xFFBA68C8, 0xFF9575CD, 0xFF7986CB, 0xFF64B5F6, 0xFF4FC3F7, 0xFF4DD0E1, 0xFF4DB6AC, 0xFF81C784, 0xFFAED581, 0xFFFF8A65, 0xFFA1887F, 0xFF90A4AE) + val color = colors.random().toInt() + + val tag = TagEntity(tagId, trimmedName, color, System.currentTimeMillis()) + recentFilesRepository.createTag(tag) + + bookIds.forEach { bookId -> + recentFilesRepository.assignTagToBook(bookId, tagId) + } + } + } + + fun toggleTagForBooks(tagId: String, bookIds: Set, assign: Boolean) { + if (tagId.isBlank() || bookIds.isEmpty()) return + viewModelScope.launch { + bookIds.forEach { bookId -> + if (assign) { + recentFilesRepository.assignTagToBook(bookId, tagId) + } else { + recentFilesRepository.removeTagFromBook(bookId, tagId) + } + } + } + } + + private fun buildDefaultTags(): List { + val now = System.currentTimeMillis() + return listOf( + TagEntity(id = "default_to_read", name = "To Read", color = 0xFF64B5F6.toInt(), createdAt = now), + TagEntity(id = "default_reading", name = "Reading", color = 0xFF81C784.toInt(), createdAt = now + 1), + TagEntity(id = "default_finished", name = "Finished", color = 0xFFFFB74D.toInt(), createdAt = now + 2), + TagEntity(id = "default_favorites", name = "Favorites", color = 0xFFF06292.toInt(), createdAt = now + 3), + TagEntity(id = "default_reference", name = "Reference", color = 0xFF9575CD.toInt(), createdAt = now + 4) + ) + } + + private var googleFontsCache: List = emptyList() + + fun loadGoogleFontsList(context: Context): List { + if (googleFontsCache.isEmpty()) { + try { + val jsonString = context.assets.open("google_fonts.json").bufferedReader().use { it.readText() } + val jsonArray = org.json.JSONArray(jsonString) + val list = mutableListOf() + for (i in 0 until jsonArray.length()) { + list.add(jsonArray.getString(i)) + } + googleFontsCache = list + Timber.d("Loaded ${list.size} Google Fonts from assets.") + } catch (e: Exception) { + Timber.e(e, "Failed to load google_fonts.json from assets") + } + } + return googleFontsCache + } + + fun downloadGoogleFont(fontName: String, onComplete: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + try { + val encodedName = java.net.URLEncoder.encode(fontName, "UTF-8") + val url = java.net.URL("https://fonts.googleapis.com/css?family=$encodedName") + val connection = url.openConnection() as java.net.HttpURLConnection + + // CRITICAL: We spoof an old Safari User-Agent. This forces Google to return the raw .ttf file instead of .woff2 + connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1") + + if (connection.responseCode != 200) { + withContext(Dispatchers.Main) { showBanner("Font '$fontName' not found on server.", isError = true) } + return@launch + } + + val css = connection.inputStream.bufferedReader().readText() + + val regex = """url\((https://[^)]+)\)""".toRegex() + val match = regex.find(css) + + if (match != null) { + val fontUrl = match.groupValues[1] + val ext = fontUrl.substringAfterLast(".", "ttf").lowercase() + + // Strict format validation + if (ext != "ttf" && ext != "otf") { + withContext(Dispatchers.Main) { showBanner("Unsupported format ($ext) returned for $fontName", isError = true) } + return@launch + } + + val fontConnection = java.net.URL(fontUrl).openConnection() as java.net.HttpURLConnection + val tempFile = File(appContext.cacheDir, "$fontName.$ext") + + fontConnection.inputStream.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + val result = fontsRepository.importFont(android.net.Uri.fromFile(tempFile)) + result.onSuccess { font -> + if (uiState.value.isSyncEnabled) { + uploadNewFont(font) + } + withContext(Dispatchers.Main) { showBanner("$fontName downloaded successfully!") } + }.onFailure { + withContext(Dispatchers.Main) { + showBanner(appContext.getString(R.string.error_import_font, it.message), isError = true) + } + } + tempFile.delete() + } else { + withContext(Dispatchers.Main) { showBanner("Could not parse download link for $fontName", isError = true) } + } + } catch (e: Exception) { + Timber.e(e, "Failed to download Google Font: $fontName") + withContext(Dispatchers.Main) { showBanner("Failed to download $fontName: ${e.localizedMessage}", isError = true) } + } finally { + withContext(Dispatchers.Main) { onComplete() } + } + } + } + fun closeTab(bookId: String) { Timber.tag("PdfTabSync").i("ViewModel: closeTab called for $bookId") val currentTabs = _internalState.value.openTabIds.toMutableList() @@ -822,6 +1511,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio init { Timber.d("ViewModel instance created.") + WorkManager.getInstance(application).cancelUniqueWork(FolderSyncWorker.WORK_NAME) + viewModelScope.launch { + recentFilesRepository.migrateLegacyShelvesToRoom() + if (!prefs.getBoolean(KEY_DEFAULT_TAGS_SEEDED, false)) { + recentFilesRepository.seedTagsIfEmpty(buildDefaultTags()) + prefs.edit { putBoolean(KEY_DEFAULT_TAGS_SEEDED, true) } + } + } viewModelScope.launch(Dispatchers.IO) { PDFBoxResourceLoader.init(getApplication()) } @@ -843,6 +1540,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } sweepOrphanedCache() + restoreReaderSessionIfNeeded() viewModelScope.launch { billingClientWrapper.initializeConnection() } @@ -908,6 +1606,258 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + private fun persistReaderSession(bookId: String, type: FileType) { + prefs.edit { + putString(KEY_LAST_OPEN_BOOK_ID, bookId) + putString(KEY_LAST_OPEN_FILE_TYPE, type.name) + } + } + + private fun clearPersistedReaderSession() { + prefs.edit { + remove(KEY_LAST_OPEN_BOOK_ID) + remove(KEY_LAST_OPEN_FILE_TYPE) + } + } + + private fun restoreReaderSessionIfNeeded() { + val currentState = _internalState.value + if (currentState.selectedBookId != null || currentState.selectedPdfUri != null || currentState.selectedEpubUri != null) { + return + } + + val persistedType = prefs.getString(KEY_LAST_OPEN_FILE_TYPE, null)?.let { typeName -> + runCatching { FileType.valueOf(typeName) }.getOrNull() + } + val restoreBookId = prefs.getString(KEY_LAST_OPEN_BOOK_ID, null) ?: return + if (persistedType == null) { + clearPersistedReaderSession() + return + } + + viewModelScope.launch { + val item = recentFilesRepository.getFileByBookId(restoreBookId) + val restoreUri = item?.getUri() + if (item == null || restoreUri == null || item.type != persistedType) { + Timber.tag("ReaderRestore") + .w("Skipping restore for bookId=$restoreBookId. Item missing, URI missing, or type mismatch.") + clearPersistedReaderSession() + return@launch + } + + when { + item.type in PDF_VIEWER_FILE_TYPES -> { + _internalState.update { state -> + if (state.selectedBookId != null || state.selectedPdfUri != null || state.selectedEpubUri != null) { + state + } else { + state.copy( + selectedPdfUri = restoreUri, + selectedBookId = item.bookId, + selectedEpubBook = null, + selectedEpubUri = null, + selectedFileType = item.type, + isLoading = false, + errorMessage = null, + initialLocator = null, + initialCfi = null, + initialBookmarksJson = item.bookmarksJson, + initialHighlightsJson = null, + initialPageInBook = item.lastPage + ) + } + } + persistReaderSession(item.bookId, item.type) + Timber.tag("ReaderRestore").i("Restored reader session for ${item.bookId} (${item.type}).") + } + item.type in EPUB_READER_FILE_TYPES -> { + val locator = + if (item.lastChapterIndex != null && item.locatorBlockIndex != null && item.locatorCharOffset != null) { + Locator( + chapterIndex = item.lastChapterIndex, + blockIndex = item.locatorBlockIndex, + charOffset = item.locatorCharOffset + ) + } else { + null + } + + _internalState.update { state -> + if (state.selectedBookId != null || state.selectedPdfUri != null || state.selectedEpubUri != null) { + state + } else { + state.copy( + selectedPdfUri = null, + selectedBookId = item.bookId, + selectedEpubBook = null, + selectedEpubUri = restoreUri, + selectedFileType = item.type, + isLoading = true, + errorMessage = null, + initialLocator = locator, + initialCfi = item.lastPositionCfi, + initialBookmarksJson = item.bookmarksJson, + initialHighlightsJson = item.highlightsJson, + initialPageInBook = null + ) + } + } + + runCatching { + restoreEpubReaderBook(item, restoreUri) + }.onSuccess { restoredBook -> + _internalState.update { state -> + if (state.selectedBookId != item.bookId) { + state + } else { + state.copy(selectedEpubBook = restoredBook, isLoading = false, errorMessage = null) + } + } + persistReaderSession(item.bookId, item.type) + Timber.tag("ReaderRestore").i("Restored reader session for ${item.bookId} (${item.type}).") + }.onFailure { error -> + Timber.tag("ReaderRestore").e(error, "Failed to restore EPUB-like session for ${item.bookId}") + clearPersistedReaderSession() + _internalState.update { state -> + if (state.selectedBookId != item.bookId) { + state + } else { + state.copy( + selectedBookId = null, + selectedEpubUri = null, + selectedEpubBook = null, + selectedFileType = null, + isLoading = false, + errorMessage = appContext.getString(R.string.error_load_file, error.message) + ) + } + } + } + } + else -> { + clearPersistedReaderSession() + } + } + } + } + + private suspend fun restoreEpubReaderBook(item: RecentFileItem, uri: Uri): EpubBook { + return restoreEpubReaderBook(item.type, item.bookId, item.displayName, uri) + } + + private suspend fun restoreEpubReaderBook( + type: FileType, + bookId: String, + displayName: String, + uri: Uri + ): EpubBook = withContext(Dispatchers.IO) { + appContext.contentResolver.openInputStream(uri).use { inputStream -> + if (inputStream == null) { + throw Exception("Could not open input stream for restore") + } + + when (type) { + FileType.EPUB -> epubParser.createEpubBook( + inputStream = inputStream, + bookId = bookId, + originalBookNameHint = displayName + ) + + FileType.MOBI -> mobiParser.createMobiBook( + inputStream = inputStream, + bookId = bookId, + originalBookNameHint = displayName + ) ?: throw Exception("MobiParser returned null. The file might be DRM-protected or invalid.") + + FileType.FB2 -> fb2Parser.createFb2Book( + inputStream = inputStream, + bookId = bookId, + originalBookNameHint = displayName + ) + + FileType.ODT, FileType.FODT -> odtParser.createOdtBook( + inputStream = inputStream, + bookId = bookId, + originalBookNameHint = displayName, + isFlat = type == FileType.FODT + ) + + FileType.MD, FileType.TXT, FileType.HTML, FileType.DOCX -> singleFileImporter.importSingleFile( + inputStream = inputStream, + type = type, + originalBookNameHint = displayName, + bookId = bookId + ) + + else -> throw IllegalArgumentException("Unsupported reader restore type: $type") + } + } + } + + fun recoverSelectedEpubContent() { + val state = _internalState.value + val bookId = state.selectedBookId ?: return + val uri = state.selectedEpubUri ?: return + val type = state.selectedFileType ?: return + + if (type !in EPUB_READER_FILE_TYPES) return + + val displayName = state.selectedEpubBook?.fileName + ?: state.selectedEpubBook?.title + ?: getFileNameFromUri(uri, appContext) + ?: "unknown_book" + + viewModelScope.launch { + epubRecoveryMutex.withLock { + val latestState = _internalState.value + if (latestState.selectedBookId != bookId || latestState.selectedEpubUri != uri) { + return@withLock + } + if (latestState.selectedEpubBook?.extractionBasePath?.let { path -> + path.isNotBlank() && File(path).exists() + } == true + ) { + return@withLock + } + + _internalState.update { + if (it.selectedBookId == bookId) it.copy(isLoading = true, errorMessage = null) else it + } + + runCatching { + val item = recentFilesRepository.getFileByBookId(bookId) + restoreEpubReaderBook( + type = item?.type ?: type, + bookId = bookId, + displayName = item?.displayName ?: displayName, + uri = uri + ) + }.onSuccess { restoredBook -> + _internalState.update { + if (it.selectedBookId == bookId && it.selectedEpubUri == uri) { + it.copy(selectedEpubBook = restoredBook, isLoading = false, errorMessage = null) + } else { + it + } + } + Timber.tag("EpubRecovery").i("Recovered missing extracted content for $bookId") + }.onFailure { error -> + Timber.tag("EpubRecovery").e(error, "Failed to recover missing extracted content for $bookId") + _internalState.update { + if (it.selectedBookId == bookId && it.selectedEpubUri == uri) { + it.copy( + isLoading = false, + errorMessage = appContext.getString(R.string.error_load_file, error.message) + ) + } else { + it + } + } + } + } + } + } + private fun getDisplayPathFromUri(context: Context, uriString: String): String { val uri = uriString.toUri() val fallbackName = DocumentFile.fromTreeUri(context, uri)?.name ?: "Unknown Folder" @@ -1323,6 +2273,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio putStringSet(KEY_FILTER_FILE_TYPES, filters.fileTypes.map { it.name }.toSet()) putStringSet(KEY_FILTER_FOLDERS, filters.sourceFolders) putString(KEY_FILTER_READ_STATUS, filters.readStatus.name) + putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) } Timber.d("Library filters updated and persisted: $filters") @@ -1593,6 +2544,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio initialPageInBook = null ) } + clearPersistedReaderSession() if (closingBookId != null && closingBookId == externalOpenedBookId) { val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK" @@ -1749,16 +2701,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio scanSyncedFolder() - val workManager = WorkManager.getInstance(appContext) - val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build() - val syncRequest = - PeriodicWorkRequestBuilder(4, TimeUnit.HOURS).setConstraints( - constraints - ).build() - workManager.enqueueUniquePeriodicWork( - FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, syncRequest - ) - showBanner(appContext.getString(R.string.banner_folder_added, name)) } catch (e: SecurityException) { @@ -1925,38 +2867,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - private suspend fun prepareBookForImport(externalUri: Uri): Triple? { - val type = getFileTypeFromUri(externalUri, appContext) - if (type == null) { - Timber.e("Could not determine file type for external URI: $externalUri") - return null - } - - val hash = FileHasher.calculateSha256 { - appContext.contentResolver.openInputStream(externalUri) - } - - if (hash == null) { - Timber.e("Failed to process file hash for $externalUri") - return null - } - - val existingItem = recentFilesRepository.getFileByBookId(hash) - if (existingItem != null) { - Timber.i("Book with ID: $hash already exists. Skipping import.") - return null - } - - Timber.i("Importing new book with ID: $hash") - val internalFile = bookImporter.importBook(externalUri) - if (internalFile == null) { - Timber.e("Failed to copy book to internal storage for $externalUri") - return null - } - - return Triple(internalFile.toUri(), hash, type) - } - private fun downloadBook(item: RecentFileItem, openWhenComplete: Boolean = false): Job { if (!uiState.value.isSyncEnabled) { _internalState.update { it.copy(errorMessage = appContext.getString(R.string.error_enable_sync_download)) } @@ -2568,7 +3478,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio epubBook: EpubBook? = null, customDisplayName: String? = null, isRecent: Boolean, - sourceFolderUri: String? = null + sourceFolderUri: String? = null, + bundleResult: CalibreBundleResult? = null ) = withContext(Dispatchers.IO) { val addStart = System.currentTimeMillis() Timber.tag("FileOpenPerf") @@ -2600,12 +3511,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio uri, appContext ) ?: "Unknown File" - var coverPath: String? = null - var title: String? = null - var author: String? = null + var coverPath: String? = bundleResult?.coverCachePath + var title: String? = bundleResult?.title + var author: String? = bundleResult?.author + var seriesName: String? = bundleResult?.seriesName + var seriesIndex: Double? = bundleResult?.seriesIndex + var description: String? = bundleResult?.description var bookForMetadata = epubBook - if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT)) { + if (bookForMetadata == null && bundleResult == null && (type == 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)) { Timber.d("Parsing downloaded book for cover/metadata: $displayName") Timber.tag("FileOpenPerf") .d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)") @@ -2677,18 +3591,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val finalBookMetadata = bookForMetadata if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) && finalBookMetadata != null) { - title = - finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName + title = title ?: finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName - author = finalBookMetadata.author.takeIf { + author = author ?: finalBookMetadata.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } - finalBookMetadata.coverImage?.let { cover -> - coverPath = recentFilesRepository.saveCoverToCache(cover, uri) + if (coverPath == null) { + finalBookMetadata.coverImage?.let { cover -> + coverPath = recentFilesRepository.saveCoverToCache(cover, uri) + } } + + seriesName = seriesName ?: finalBookMetadata.seriesName + seriesIndex = seriesIndex ?: finalBookMetadata.seriesIndex + description = description ?: finalBookMetadata.description } else if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { - title = displayName + title = title ?: displayName if (type == FileType.PDF) { try { @@ -2698,12 +3617,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val meta = pdfiumCore.getDocumentMeta(pdfDocument) val extractedTitle = meta.title - if (!extractedTitle.isNullOrBlank()) { + if (!extractedTitle.isNullOrBlank() && title == displayName) { title = extractedTitle } val extractedAuthor = meta.author - if (!extractedAuthor.isNullOrBlank()) { + if (!extractedAuthor.isNullOrBlank() && author == null) { author = extractedAuthor } @@ -2713,49 +3632,53 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.e(e, "Failed to extract PDF title using PdfiumCore") } - val pdfCoverGenerator = PdfCoverGenerator(appContext) - val coverBitmap = pdfCoverGenerator.generateCover(uri) - if (coverBitmap != null) { - coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri) + if (coverPath == null) { + val pdfCoverGenerator = PdfCoverGenerator(appContext) + val coverBitmap = pdfCoverGenerator.generateCover(uri) + if (coverBitmap != null) { + coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri) + } } } else if (uri.scheme != "opds-pse" && (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7)) { - var cacheFile: File? = null - try { - cacheFile = File(appContext.cacheDir, "temp_archive_cover_${System.currentTimeMillis()}.${type.name.lowercase()}") - withContext(Dispatchers.IO) { - appContext.contentResolver.openInputStream(uri)?.use { input -> - cacheFile.outputStream().use { output -> input.copyTo(output) } - } - } - val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile) - if (archiveDoc.getPageCount() > 0) { - val page = archiveDoc.openPage(0) - if (page != null) { - val w = page.getPageWidthPoint() - val h = page.getPageHeightPoint() - if (w > 0 && h > 0) { - val targetHeight = 800 - val targetWidth = (targetHeight * (w.toFloat() / h.toFloat())).toInt() - if (targetWidth > 0) { - val bitmap = createBitmap(targetWidth, targetHeight) - page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false) - coverPath = recentFilesRepository.saveCoverToCache(bitmap, uri) - } - } - page.close() - } - } - archiveDoc.close() - } catch (e: Exception) { - Timber.e(e, "Error generating CBZ cover") - } finally { + if (coverPath == null) { + var cacheFile: File? = null try { - if (cacheFile?.exists() == true) { - val deleted = cacheFile.delete() - if (deleted) Timber.d("Successfully deleted temp archive file: ${cacheFile.name}") + cacheFile = File(appContext.cacheDir, "temp_archive_cover_${System.currentTimeMillis()}.${type.name.lowercase()}") + withContext(Dispatchers.IO) { + appContext.contentResolver.openInputStream(uri)?.use { input -> + cacheFile.outputStream().use { output -> input.copyTo(output) } + } } + val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile) + if (archiveDoc.getPageCount() > 0) { + val page = archiveDoc.openPage(0) + if (page != null) { + val w = page.getPageWidthPoint() + val h = page.getPageHeightPoint() + if (w > 0 && h > 0) { + val targetHeight = 800 + val targetWidth = (targetHeight * (w.toFloat() / h.toFloat())).toInt() + if (targetWidth > 0) { + val bitmap = createBitmap(targetWidth, targetHeight) + page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false) + coverPath = recentFilesRepository.saveCoverToCache(bitmap, uri) + } + } + page.close() + } + } + archiveDoc.close() } catch (e: Exception) { - Timber.e(e, "Failed to delete temp archive file") + Timber.e(e, "Error generating CBZ cover") + } finally { + try { + if (cacheFile?.exists() == true) { + val deleted = cacheFile.delete() + if (deleted) Timber.d("Successfully deleted temp archive file: ${cacheFile.name}") + } + } catch (e: Exception) { + Timber.e(e, "Failed to delete temp archive file") + } } } } @@ -2778,7 +3701,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio isDeleted = false, isRecent = isRecent, sourceFolderUri = sourceFolderUri, - fileSize = fileSize + fileSize = fileSize, + seriesName = seriesName, + seriesIndex = seriesIndex, + description = description ) recentFilesRepository.addRecentFile(newItem) Timber.i("Added/Updated $displayName ($type) to recent files via repository.") @@ -2820,6 +3746,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1) val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet() val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet() + ImportedFileCache.deleteStaleTemporaryBookDirs(appContext, TimeUnit.HOURS.toMillis(1)) cacheDir.listFiles()?.forEach { file -> val name = file.name @@ -2828,7 +3755,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val deleted = if (file.isDirectory) file.deleteRecursively() else file.delete() if (deleted) Timber.d("Sweeper cleaned old temp file: $name") } - } else if (name.startsWith("imported_file_")) { + } else if (ImportedFileCache.isActiveBookDir(name)) { val bookId = name.removePrefix("imported_file_") if (bookId !in allDbIds) { val deleted = file.deleteRecursively() @@ -2856,14 +3783,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private fun getFileNameFromUri(uri: Uri, context: Context): String? { var fileName: String? = null if (uri.scheme == "content") { - val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null) - cursor?.use { - if (it.moveToFirst()) { - val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex != -1) { - fileName = it.getString(nameIndex) + try { + val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1) { + fileName = it.getString(nameIndex) + } } } + } catch (e: SecurityException) { + Timber.w(e, "Permission denied while resolving display name for URI: $uri") + } catch (e: IllegalArgumentException) { + Timber.w(e, "Provider rejected display-name query for URI: $uri") + } catch (e: RuntimeException) { + Timber.w(e, "Unexpected failure while resolving display name for URI: $uri") } } if (fileName == null) { @@ -2910,7 +3845,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio bookId = bookId, customDisplayName = displayName, isRecent = false, - sourceFolderUri = null + sourceFolderUri = null, + bundleResult = importResult.bundleResult ) importedCount++ } else { @@ -2960,30 +3896,48 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } viewModelScope.launch { - val importResult = prepareBookForImport(externalUri) + try { + val importResult = prepareBookForImport(externalUri) - if (importResult != null) { - val (internalUri, bookId, type) = importResult - if (isExternalIntent) { - externalOpenedBookId = bookId - } - val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File" - openBook( - internalUri, bookId = bookId, type = type, originalDisplayName = displayName - ) - } else { - val hash = FileHasher.calculateSha256 { - appContext.contentResolver.openInputStream(externalUri) - } - if (hash != null) { - val existingItem = recentFilesRepository.getFileByBookId(hash) - if (existingItem != null) { - Timber.i("Re-selected an existing book. Opening it.") - onRecentFileClicked(existingItem) - _internalState.update { it.copy(isLoading = false) } - return@launch + if (importResult != null) { + val (internalUri, bookId, type) = importResult + if (isExternalIntent) { + externalOpenedBookId = bookId + } + val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File" + openBook( + internalUri, bookId = bookId, type = type, + originalDisplayName = displayName, bundleResult = importResult.bundleResult + ) + } else { + val hash = FileHasher.calculateSha256 { + appContext.contentResolver.openInputStream(externalUri) + } + if (hash != null) { + val existingItem = recentFilesRepository.getFileByBookId(hash) + if (existingItem != null) { + Timber.i("Re-selected an existing book. Opening it.") + onRecentFileClicked(existingItem) + _internalState.update { it.copy(isLoading = false) } + return@launch + } + } + _internalState.update { + it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_import_file_failed)) } } + } catch (e: SecurityException) { + Timber.e(e, "Permission denied while importing URI: $externalUri") + _internalState.update { + it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_import_file_failed)) + } + } catch (e: IllegalArgumentException) { + Timber.e(e, "Provider rejected URI import for: $externalUri") + _internalState.update { + it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_import_file_failed)) + } + } catch (e: RuntimeException) { + Timber.e(e, "Unexpected import failure for URI: $externalUri") _internalState.update { it.copy(isLoading = false, errorMessage = appContext.getString(R.string.error_import_file_failed)) } @@ -3035,6 +3989,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val bookId = item.bookId if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { + persistReaderSession(bookId, type) _internalState.update { it.copy( selectedEpubUri = null, @@ -3064,15 +4019,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio stateUpdateDeferred.complete(true) } else { - val epubBook = withContext(Dispatchers.IO) { - appContext.contentResolver.openInputStream(uri)?.use { inputStream -> - singleFileImporter.importSingleFile( - inputStream, type, item.displayName, bookId - ) - } - } - - if (epubBook != null) { + persistReaderSession(bookId, type) + try { + val epubBook = restoreEpubReaderBook(type, bookId, item.displayName, uri) _internalState.update { it.copy( selectedPdfUri = null, @@ -3105,11 +4054,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event") _navigationEvent.send(NavigationEvent("epub_reader", bookId, uri)) stateUpdateDeferred.complete(true) - } else { + } catch (e: Exception) { + Timber.e(e, "Failed to switch seamlessly to $type book: $bookId") _internalState.update { it.copy( isLoading = false, - errorMessage = appContext.getString(R.string.error_load_generated_text_view), + errorMessage = appContext.getString(R.string.error_load_file, e.message), selectedFileType = null ) } @@ -3190,18 +4140,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private fun clearImportedFileCache(bookId: String) { try { - val cacheDir = File(appContext.cacheDir, "imported_file_$bookId") - if (cacheDir.exists()) { - val deleted = cacheDir.deleteRecursively() - Timber.tag("FileCleanup").d("Deleted imported cache for $bookId: $deleted") - } + ImportedFileCache.clearBookCache(appContext, bookId) + Timber.tag("FileCleanup").d("Deleted imported cache for $bookId") } catch (e: Exception) { Timber.e(e, "Failed to clear imported file cache for $bookId") } } private fun openBook( - uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false + uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null, suppressNavigation: Boolean = false, bundleResult: CalibreBundleResult? = null ) { val openBookStartTime = System.currentTimeMillis() Timber.tag("FileOpenPerf") @@ -3287,13 +4234,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio isLoading = false ) } + persistReaderSession(bookId, type) addFileToRecent( uri, type, bookId, customDisplayName = originalDisplayName, isRecent = true, - sourceFolderUri = null + sourceFolderUri = null, + bundleResult = bundleResult ) if (!suppressNavigation) { @@ -3333,6 +4282,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio initialHighlightsJson = recentItem?.highlightsJson, ) } + persistReaderSession(bookId, type) if (!suppressNavigation) { Timber.tag("FileSwitch").d("EPUB state updated, emitting navigation event") @@ -3341,22 +4291,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio when (type) { FileType.EPUB -> { - loadEpub(uri, bookId, customDisplayName = originalDisplayName) + loadEpub(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult) } FileType.MOBI -> { - loadMobi(uri, bookId, customDisplayName = originalDisplayName) + loadMobi(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult) } FileType.FB2 -> { - loadFb2(uri, bookId, customDisplayName = originalDisplayName) + loadFb2(uri, bookId, customDisplayName = originalDisplayName, bundleResult = bundleResult) } FileType.ODT, FileType.FODT -> { - loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName) + loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName, bundleResult = bundleResult) } else -> { loadSingleFile( - uri, bookId, type, customDisplayName = originalDisplayName + uri, bookId, type, customDisplayName = originalDisplayName, bundleResult = bundleResult ) } } @@ -3365,7 +4315,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - private fun loadFb2(uri: Uri, bookId: String, customDisplayName: String? = null) { + private fun loadFb2(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { val loadStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START") viewModelScope.launch { @@ -3388,7 +4338,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") addFileToRecent( - uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null + uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult ) _internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) } @@ -3401,7 +4351,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - private fun loadOdt(uri: Uri, bookId: String, isFlat: Boolean, customDisplayName: String? = null) { + private fun loadOdt(uri: Uri, bookId: String, isFlat: Boolean, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { val loadStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat") viewModelScope.launch { @@ -3425,7 +4375,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") addFileToRecent( - uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null + uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null, bundleResult = bundleResult ) _internalState.update { it.copy(selectedEpubBook = odtBook, isLoading = false) } @@ -3442,7 +4392,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio uri: Uri, bookId: String, type: FileType, - customDisplayName: String? = null + customDisplayName: String? = null, + bundleResult: CalibreBundleResult? = null ) { val loadStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[$bookId] loadSingleFile START | type=$type") @@ -3478,7 +4429,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio epubBook, customDisplayName, isRecent = true, - sourceFolderUri = null + sourceFolderUri = null, + bundleResult = bundleResult ) _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } @@ -3499,7 +4451,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } private fun getFileTypeFromUri(uri: Uri, context: Context): FileType? { - val mimeType = context.contentResolver.getType(uri) + val mimeType = try { + context.contentResolver.getType(uri) + } catch (e: SecurityException) { + Timber.w(e, "Permission denied while resolving MIME type for URI: $uri") + null + } catch (e: IllegalArgumentException) { + Timber.w(e, "Provider rejected MIME type lookup for URI: $uri") + null + } catch (e: RuntimeException) { + Timber.w(e, "Unexpected failure while resolving MIME type for URI: $uri") + null + } val fileName = getFileNameFromUri(uri, context) Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName") @@ -3616,7 +4579,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - private fun loadMobi(uri: Uri, bookId: String, customDisplayName: String? = null) { + private fun loadMobi(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { viewModelScope.launch { if (!_internalState.value.isLoading) { _internalState.update { it.copy(isLoading = true, errorMessage = null) } @@ -3645,7 +4608,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio mobiAsEpubBook, customDisplayName, isRecent = true, - sourceFolderUri = null + sourceFolderUri = null, + bundleResult = bundleResult ) _internalState.update { it.copy(selectedEpubBook = mobiAsEpubBook, isLoading = false) @@ -3664,7 +4628,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null) { + private fun loadEpub(uri: Uri, bookId: String, customDisplayName: String? = null, bundleResult: CalibreBundleResult? = null) { val loadStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[$bookId] loadEpub START") viewModelScope.launch { @@ -3696,7 +4660,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio epubBook, customDisplayName, isRecent = true, - sourceFolderUri = null + sourceFolderUri = null, + bundleResult = bundleResult ) _internalState.update { it.copy(selectedEpubBook = epubBook, isLoading = false) } @@ -3762,12 +4727,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } else { 0f } - Timber.tag("PdfPositionDebug").d("ViewModel: Saving to DB | Page: $page | Total: $totalPages | URI: ${currentPdfUri.lastPathSegment}") + Timber.tag("PdfPositionDebug").i("ViewModel: Save request triggered | Page: $page | Total: $totalPages | Progress: $progress | URI: ${currentPdfUri.lastPathSegment}") viewModelScope.launch { recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ -> recentFilesRepository.updatePdfReadingPosition( uriString = currentPdfUri.toString(), page = page, progress = progress ) + } ?: run { + Timber.tag("PdfPositionDebug").e("ViewModel: Save aborted. Could not resolve file item from URI in DB.") } } } else { @@ -3775,6 +4742,44 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + fun exportLogsToFile(activityContext: Context) { + viewModelScope.launch(Dispatchers.IO) { + try { + Timber.d("Generating logcat dump for debugging...") + val logFile = File(appContext.cacheDir, "debug_logs_${System.currentTimeMillis()}.txt") + + val process = Runtime.getRuntime().exec("logcat -d -v threadtime -t 5000") + process.inputStream.bufferedReader().use { reader -> + logFile.writeText(reader.readText()) + } + + val authority = "${appContext.packageName}.provider" + val uri = androidx.core.content.FileProvider.getUriForFile(appContext, authority, logFile) + + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_TITLE, "App Debug Logs") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + val chooser = Intent.createChooser(intent, "Export Debug Logs") + if (activityContext !is android.app.Activity) { + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + withContext(Dispatchers.Main) { + activityContext.startActivity(chooser) + } + } catch (e: Exception) { + Timber.e(e, "Failed to export logs") + withContext(Dispatchers.Main) { + showBanner("Failed to export logs", isError = true) + } + } + } + } + fun refreshLibrary() { val syncEnabled = _internalState.value.isSyncEnabled val hasFolder = _internalState.value.syncedFolders.isNotEmpty() @@ -3978,215 +4983,178 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun createShelf(name: String) { if (name.isNotBlank()) { - val currentShelves = prefs.getStringSet(KEY_SHELVES, emptySet()) ?: emptySet() - val newTimestamp = System.currentTimeMillis() - prefs.edit { - putStringSet(KEY_SHELVES, currentShelves + name) - putLong("$KEY_SHELF_TIMESTAMP_PREFIX$name", newTimestamp) - putStringSet("$KEY_SHELF_CONTENT_PREFIX$name", emptySet()) - putBoolean("$KEY_SHELF_DELETED_PREFIX$name", false) + viewModelScope.launch { + val shelfId = UUID.randomUUID().toString() + val shelf = com.aryan.reader.data.ShelfEntity( + id = shelfId, + name = name, + isSmart = false, + smartRulesJson = null, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + recentFilesRepository.addShelf(shelf) + dismissCreateShelfDialog() + syncShelfChangeToFirestore(shelfId) } - dismissCreateShelfDialog() - syncShelfChangeToFirestore(name) } } fun setMainScreenPage(page: Int) { - _internalState.update { it.copy(mainScreenStartPage = page) } + val sanitizedPage = page.coerceIn(0, 1) + _internalState.update { it.copy(mainScreenStartPage = sanitizedPage) } + persistLibraryLandingState() } fun setLibraryScreenPage(page: Int) { - _internalState.update { it.copy(libraryScreenStartPage = page) } - } - - fun navigateToShelf(name: String) { + val maxLibraryPage = if (BuildConfig.IS_OFFLINE) 2 else 3 + val sanitizedPage = page.coerceIn(0, maxLibraryPage) _internalState.update { - it.copy(viewingShelfName = name, mainScreenStartPage = 1, libraryScreenStartPage = 1) + it.copy(libraryScreenStartPage = sanitizedPage) } + persistLibraryLandingState() } - fun showRenameShelfDialog(shelfName: String) { - _internalState.update { it.copy(showRenameShelfDialogFor = shelfName) } + fun navigateToShelf(id: String) { + _internalState.update { + it.copy(viewingShelfId = id, mainScreenStartPage = 1, libraryScreenStartPage = 1) + } + persistLibraryLandingState() + } + + fun showRenameShelfDialog(shelfId: String) { + _internalState.update { it.copy(showRenameShelfDialogFor = shelfId) } } fun dismissRenameShelfDialog() { _internalState.update { it.copy(showRenameShelfDialogFor = null) } } - fun showDeleteShelfDialog(shelfName: String) { - _internalState.update { it.copy(showDeleteShelfDialogFor = shelfName) } + fun showDeleteShelfDialog(shelfId: String) { + _internalState.update { it.copy(showDeleteShelfDialogFor = shelfId) } } fun dismissDeleteShelfDialog() { _internalState.update { it.copy(showDeleteShelfDialogFor = null) } } - fun renameShelf(oldName: String, newName: String) { - if (oldName.isBlank() || newName.isBlank() || oldName == newName) { + fun renameShelf(shelfId: String, newName: String) { + if (shelfId.isBlank() || newName.isBlank()) { dismissRenameShelfDialog() return } - - val currentShelves = - prefs.getStringSet(KEY_SHELVES, emptySet())?.toMutableSet() ?: mutableSetOf() - - if (newName in currentShelves) { - Timber.w("Cannot rename shelf. A shelf with the name '$newName' already exists.") - _internalState.update { - it.copy(errorMessage = appContext.getString(R.string.error_shelf_exists)) - } + viewModelScope.launch { + recentFilesRepository.renameShelf(shelfId, newName) + syncShelfChangeToFirestore(shelfId) + _internalState.update { it.copy(viewingShelfId = shelfId) } + persistLibraryLandingState() dismissRenameShelfDialog() - return } - - val oldContentKey = "$KEY_SHELF_CONTENT_PREFIX$oldName" - val shelfContent = prefs.getStringSet(oldContentKey, emptySet()) ?: emptySet() - val newTimestamp = System.currentTimeMillis() - - prefs.edit { - currentShelves.remove(oldName) - putBoolean("$KEY_SHELF_DELETED_PREFIX$oldName", true) - putLong("$KEY_SHELF_TIMESTAMP_PREFIX$oldName", newTimestamp) - - currentShelves.add(newName) - putStringSet(KEY_SHELVES, currentShelves) - putStringSet("$KEY_SHELF_CONTENT_PREFIX$newName", shelfContent) - putLong("$KEY_SHELF_TIMESTAMP_PREFIX$newName", newTimestamp) - } - - syncShelfChangeToFirestore(oldName) - syncShelfChangeToFirestore(newName) - - _internalState.update { it.copy(viewingShelfName = newName) } - dismissRenameShelfDialog() } - fun deleteShelf(shelfName: String) { - if (shelfName.isBlank() || shelfName == "Unshelved") { + fun deleteShelf(shelfId: String) { + if (shelfId.isBlank() || shelfId == "unshelved") { dismissDeleteShelfDialog() return } - - _internalState.update { - it.copy( - viewingShelfName = null, - isAddingBooksToShelf = false, - showDeleteShelfDialogFor = null - ) + viewModelScope.launch { + _internalState.update { + it.copy(viewingShelfId = null, isAddingBooksToShelf = false, showDeleteShelfDialogFor = null) + } + persistLibraryLandingState() + recentFilesRepository.deleteShelf(shelfId) + syncShelfChangeToFirestore(shelfId) } - - prefs.edit { - val currentShelves = - prefs.getStringSet(KEY_SHELVES, emptySet())?.toMutableSet() ?: mutableSetOf() - currentShelves.remove(shelfName) - putStringSet(KEY_SHELVES, currentShelves) - putBoolean("$KEY_SHELF_DELETED_PREFIX$shelfName", true) - putLong("$KEY_SHELF_TIMESTAMP_PREFIX$shelfName", System.currentTimeMillis()) - } - syncShelfChangeToFirestore(shelfName) } fun unselectShelf() { - _internalState.update { it.copy(viewingShelfName = null, isAddingBooksToShelf = false) } + _internalState.update { it.copy(viewingShelfId = null, isAddingBooksToShelf = false) } + persistLibraryLandingState() + } + + fun navigateBackFromShelf() { + val currentShelf = uiState.value.shelves.find { it.id == _internalState.value.viewingShelfId } + val parentShelfId = currentShelf?.takeIf { it.type == ShelfType.FOLDER }?.parentShelfId + if (parentShelfId != null) { + _internalState.update { it.copy(viewingShelfId = parentShelfId, isAddingBooksToShelf = false) } + persistLibraryLandingState() + } else { + unselectShelf() + } } fun removeContextualItemsFromShelf() { - val shelfName = _internalState.value.viewingShelfName - if (shelfName.isNullOrBlank() || shelfName == "Unshelved") { - Timber.w("Attempted to remove items from an invalid or unshelved shelf: $shelfName") + val shelfId = _internalState.value.viewingShelfId + if (shelfId.isNullOrBlank() || shelfId == "unshelved") { clearContextualAction() return } - val bookIdsToRemove = _internalState.value.contextualActionItems.map { it.bookId }.toSet() + val bookIdsToRemove = _internalState.value.contextualActionItems.map { it.bookId } if (bookIdsToRemove.isEmpty()) { - Timber.w("removeContextualItemsFromShelf called but no items were selected.") clearContextualAction() return } - Timber.d("Removing ${bookIdsToRemove.size} book(s) from shelf '$shelfName'.") - val key = "$KEY_SHELF_CONTENT_PREFIX$shelfName" - val currentBookIds = prefs.getStringSet(key, emptySet())?.toMutableSet() ?: mutableSetOf() - - currentBookIds.removeAll(bookIdsToRemove) - - prefs.edit { - putStringSet(key, currentBookIds) - putLong("$KEY_SHELF_TIMESTAMP_PREFIX$shelfName", System.currentTimeMillis()) + viewModelScope.launch { + recentFilesRepository.removeBooksFromShelf(shelfId, bookIdsToRemove) + clearContextualAction() + syncShelfChangeToFirestore(shelfId) } - Timber.d( - "Successfully removed books. Shelf '$shelfName' now has ${currentBookIds.size} books." - ) - - clearContextualAction() - syncShelfChangeToFirestore(shelfName) } fun onShelfClick(shelf: Shelf) { - if (_internalState.value.contextualActionShelfNames.isNotEmpty()) { - toggleShelfSelection(shelf.name) + if (_internalState.value.contextualActionShelfIds.isNotEmpty()) { + toggleShelfSelection(shelf) } else { - navigateToShelf(shelf.name) + navigateToShelf(shelf.id) } } - private fun toggleShelfSelection(shelfName: String) { - if (shelfName == "Unshelved") return + private fun toggleShelfSelection(shelf: Shelf) { + if (shelf.type != ShelfType.MANUAL) return _internalState.update { state -> - val currentSelection = state.contextualActionShelfNames - val newSelection = if (shelfName in currentSelection) { - currentSelection - shelfName + val currentSelection = state.contextualActionShelfIds + val newSelection = if (shelf.id in currentSelection) { + currentSelection - shelf.id } else { - currentSelection + shelfName + currentSelection + shelf.id } - state.copy(contextualActionShelfNames = newSelection) + state.copy(contextualActionShelfIds = newSelection) } } fun onShelfLongPress(shelf: Shelf) { - if (shelf.name == "Unshelved") return // Cannot select "Unshelved" - val currentSelection = _internalState.value.contextualActionShelfNames - if (shelf.name !in currentSelection) { + if (shelf.type != ShelfType.MANUAL || shelf.id == "unshelved") return + val currentSelection = _internalState.value.contextualActionShelfIds + if (shelf.id !in currentSelection) { _internalState.update { - it.copy(contextualActionShelfNames = currentSelection + shelf.name) + it.copy(contextualActionShelfIds = currentSelection + shelf.id) } } } fun clearShelfContextualAction() { - if (_internalState.value.contextualActionShelfNames.isNotEmpty()) { - _internalState.update { it.copy(contextualActionShelfNames = emptySet()) } + if (_internalState.value.contextualActionShelfIds.isNotEmpty()) { + _internalState.update { it.copy(contextualActionShelfIds = emptySet()) } } } fun deleteSelectedShelves() { - val shelvesToDelete = - _internalState.value.contextualActionShelfNames.filter { it != "Unshelved" } + val shelvesToDelete = _internalState.value.contextualActionShelfIds if (shelvesToDelete.isEmpty()) { clearShelfContextualAction() return } - Timber.d("Deleting ${shelvesToDelete.size} shelves: ${shelvesToDelete.joinToString()}") - val currentShelves = - prefs.getStringSet(KEY_SHELVES, emptySet())?.toMutableSet() ?: mutableSetOf() - val newTimestamp = System.currentTimeMillis() - - prefs.edit { - shelvesToDelete.forEach { shelfName -> - currentShelves.remove(shelfName) - putBoolean("$KEY_SHELF_DELETED_PREFIX$shelfName", true) - putLong("$KEY_SHELF_TIMESTAMP_PREFIX$shelfName", newTimestamp) + viewModelScope.launch { + shelvesToDelete.forEach { shelfId -> + recentFilesRepository.deleteShelf(shelfId) + syncShelfChangeToFirestore(shelfId) } - putStringSet(KEY_SHELVES, currentShelves) + clearShelfContextualAction() } - - shelvesToDelete.forEach { syncShelfChangeToFirestore(it) } - - Timber.d("Shelves deleted successfully.") - clearShelfContextualAction() } fun showAddBooksToShelf() { @@ -4197,29 +5165,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio booksSelectedForAdding = emptySet() ) } + persistLibraryLandingState() } - private fun syncShelfChangeToFirestore(shelfName: String) { + private fun syncShelfChangeToFirestore(shelfId: String) { if (!uiState.value.isSyncEnabled) return val currentUser = uiState.value.currentUser ?: return - viewModelScope.launch { - val shelfContent = - prefs.getStringSet("$KEY_SHELF_CONTENT_PREFIX$shelfName", null)?.toList() - ?: emptyList() - val isDeleted = prefs.getBoolean("$KEY_SHELF_DELETED_PREFIX$shelfName", false) - val timestamp = prefs.getLong("$KEY_SHELF_TIMESTAMP_PREFIX$shelfName", 0L) + viewModelScope.launch(Dispatchers.IO) { + val db = com.aryan.reader.data.AppDatabase.getDatabase(appContext) + val shelf = db.shelfDao().getShelfById(shelfId) ?: return@launch + val crossRefs = db.shelfDao().getCrossRefsForShelf(shelfId) + val bookIds = crossRefs.map { it.bookId } val shelfMetadata = ShelfMetadata( - name = shelfName, - bookIds = shelfContent, - isDeleted = isDeleted, - lastModifiedTimestamp = timestamp + name = shelf.name, + bookIds = bookIds, + isDeleted = shelf.isDeleted, + lastModifiedTimestamp = shelf.updatedAt ) val deviceId = getInstallationId() firestoreRepository.syncShelf(currentUser.uid, shelfMetadata, deviceId) - Timber.d("Pushed shelf update to Firestore for: $shelfName") } } @@ -4231,36 +5198,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio addBooksSource = AddBooksSource.UNSHELVED ) } + persistLibraryLandingState() } - fun addBooksToShelf(shelfName: String) { + fun addBooksToShelf(shelfId: String) { val bookIdsToAdd = _internalState.value.booksSelectedForAdding if (bookIdsToAdd.isEmpty()) { - Timber.w("addBooksToShelf called for '$shelfName' but no books were selected.") dismissAddBooksToShelf() return } - - Timber.i( - "Attempting to add ${bookIdsToAdd.size} books to shelf '$shelfName'. Book IDs: ${bookIdsToAdd.joinToString()}" - ) - val key = "$KEY_SHELF_CONTENT_PREFIX$shelfName" - val currentBookIds = prefs.getStringSet(key, emptySet()) ?: emptySet() - Timber.d("Existing book IDs in shelf '$shelfName': ${currentBookIds.joinToString()}") - - val newBookIds = currentBookIds + bookIdsToAdd - prefs.edit { - putStringSet(key, newBookIds) - putLong("$KEY_SHELF_TIMESTAMP_PREFIX$shelfName", System.currentTimeMillis()) - } - Timber.i( - "Successfully updated shelf '$shelfName'. It now contains ${newBookIds.size} book(s)." - ) - - syncShelfChangeToFirestore(shelfName) - - _internalState.update { - it.copy(isAddingBooksToShelf = false, booksSelectedForAdding = emptySet()) + viewModelScope.launch { + recentFilesRepository.addBooksToShelf(shelfId, bookIdsToAdd.toList()) + syncShelfChangeToFirestore(shelfId) + _internalState.update { + it.copy(isAddingBooksToShelf = false, booksSelectedForAdding = emptySet()) + } + persistLibraryLandingState() } } @@ -4269,6 +5222,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio prefs.edit { putString(KEY_ADD_BOOKS_SOURCE, source.name) } } + private fun persistLibraryLandingState() { + val state = _internalState.value + val resolvedUiState = uiState.value + prefs.edit { + putInt(KEY_MAIN_SCREEN_START_PAGE, state.mainScreenStartPage) + putInt(KEY_LIBRARY_SCREEN_START_PAGE, state.libraryScreenStartPage) + putString(KEY_LAST_VIEWING_SHELF_ID, resolvedUiState.viewingShelfId) + putBoolean(KEY_LAST_ADDING_BOOKS_TO_SHELF, resolvedUiState.isAddingBooksToShelf) + } + } + fun toggleBookSelectionForAdding(bookId: String) { _internalState.update { state -> val currentSelection = state.booksSelectedForAdding @@ -4438,6 +5402,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio firestoreRepository.removeListener(feedbackListener) panelDetector?.close() panelDetector = null + + speechBubbleDetector?.close() + speechBubbleDetector = null + speechBubbleCache.clear() + speechBubbleDetectionJobs.clear() + Timber.d("ViewModel instance cleared (onCleared).") } @@ -4753,6 +5723,77 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + private suspend fun runSpeechBubbleDetection( + bitmap: Bitmap, + context: Context, + confidenceThreshold: Float = 0.1f + ): List { + return withContext(mlDispatcher) { + try { + val detector = getOrInitSpeechBubbleDetector(context) + if (detector == null) { + Timber.tag("BubbleZoom").w("ViewModel: Detector is null!") + return@withContext emptyList() + } + detector.detectBubbles(bitmap, confidenceThreshold) + } catch (e: Exception) { + Timber.tag("BubbleZoom").e(e, "ViewModel: Error during speech bubble detection") + emptyList() + } + } + } + + suspend fun detectSpeechBubbles(bitmap: Bitmap, context: Context): List { + Timber.tag("BubbleZoom").d("ViewModel: detectSpeechBubbles called") + val bubbles = runSpeechBubbleDetection(bitmap, context) + Timber.tag("BubbleZoom").d("ViewModel: detectSpeechBubbles returning ${bubbles.size} bubbles") + return bubbles + } + + suspend fun detectSpeechBubblesCached( + documentId: String, + pageIndex: Int, + bitmap: Bitmap, + context: Context + ): List { + val key = SpeechBubbleCacheKey(documentId = documentId, pageIndex = pageIndex) + + val cachedBeforeLock = speechBubbleCache[key] + if (cachedBeforeLock != null) { + return scaleCachedSpeechBubbles(cachedBeforeLock, bitmap.width, bitmap.height) + } + + val detectionJob: Deferred> + speechBubbleCacheMutex.withLock { + val cachedInsideLock = speechBubbleCache[key] + if (cachedInsideLock != null) { + detectionJob = CompletableDeferred(cachedInsideLock) + } else { + detectionJob = speechBubbleDetectionJobs[key] ?: viewModelScope.async { + val detected = runSpeechBubbleDetection(bitmap, context) + val normalized = normalizeSpeechBubbles(detected, bitmap.width, bitmap.height) + speechBubbleCache[key] = normalized + normalized + }.also { job -> + speechBubbleDetectionJobs[key] = job + } + } + } + + val cached = try { + detectionJob.await() + } finally { + speechBubbleCacheMutex.withLock { + val activeJob = speechBubbleDetectionJobs[key] + if (activeJob === detectionJob && detectionJob.isCompleted) { + speechBubbleDetectionJobs.remove(key) + } + } + } + + return scaleCachedSpeechBubbles(cached, bitmap.width, bitmap.height) + } + companion object { private const val KEY_SORT_ORDER = "sort_order" internal const val KEY_SHELVES = "shelf_names" @@ -4774,6 +5815,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private const val KEY_TABS_ENABLED = "tabs_enabled" private const val KEY_OPEN_TAB_IDS = "open_tab_ids" private const val KEY_ACTIVE_TAB = "active_tab_book_id" + private const val KEY_LAST_OPEN_BOOK_ID = "last_open_book_id" + private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type" private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior" private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter" private const val KEY_APP_THEME_MODE = "app_theme_mode" diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt index b70baab..7f3be16 100644 --- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt +++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt @@ -7,6 +7,7 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters 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 @@ -55,6 +56,13 @@ class MetadataExtractionWorker( if (item.sourceFolderUri == null) return@forEach + val tempExtractionDir = + if (item.type == FileType.EPUB || item.type == FileType.MOBI || item.type == FileType.ODT || item.type == FileType.FODT) { + ImportedFileCache.createTemporaryBookDir(appContext, item.bookId, "metadata") + } else { + null + } + try { val uri = item.uriString?.toUri() ?: return@forEach val type = item.type @@ -86,7 +94,8 @@ class MetadataExtractionWorker( inputStream = inputStream, bookId = item.bookId, originalBookNameHint = item.displayName, - parseContent = false + parseContent = false, + extractionDirOverride = tempExtractionDir ) title = book.title.takeIf { it.isNotBlank() && it != "content" } author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } @@ -97,7 +106,8 @@ class MetadataExtractionWorker( inputStream = inputStream, bookId = item.bookId, originalBookNameHint = item.displayName, - parseContent = false + parseContent = false, + extractionDirOverride = tempExtractionDir ) book?.let { title = it.title.takeIf { t -> t.isNotBlank() && t != "content" } @@ -139,7 +149,8 @@ class MetadataExtractionWorker( bookId = item.bookId, originalBookNameHint = item.displayName, isFlat = type == FileType.FODT, - parseContent = false + parseContent = false, + extractionDirOverride = tempExtractionDir ) title = book.title.takeIf { it.isNotBlank() && it != "content" } author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } @@ -166,13 +177,16 @@ class MetadataExtractionWorker( Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}") } finally { try { - val cacheDir = File(appContext.cacheDir, "imported_file_${item.bookId}") - if (cacheDir.exists()) { - val deleted = cacheDir.deleteRecursively() - if (deleted) Timber.tag("MetadataWorker").d("Cleaned up extraction cache for ${item.bookId}") + 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 extraction cache for ${item.bookId}") + Timber.tag("MetadataWorker") + .e(e, "Failed to clean up temporary extraction cache for ${item.bookId}") } } } @@ -183,4 +197,4 @@ class MetadataExtractionWorker( return@withContext Result.failure() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt index 402146e..8d1ec22 100644 --- a/app/src/main/java/com/aryan/reader/SharedComposables.kt +++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt @@ -33,7 +33,19 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.ui.state.ToggleableState +import androidx.compose.material3.TriStateCheckbox +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import com.aryan.reader.data.TagEntity +import androidx.compose.material.icons.filled.Search import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -90,7 +102,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler @@ -220,6 +231,7 @@ fun ContextualTopAppBar( selectedItemCount: Int, onNavIconClick: () -> Unit, onInfoClick: (() -> Unit)? = null, + onTagClick: (() -> Unit)? = null, onSelectAllClick: (() -> Unit)? = null, onPinClick: (() -> Unit)? = null, onDeleteClick: () -> Unit @@ -232,6 +244,11 @@ fun ContextualTopAppBar( } }, actions = { + if (onTagClick != null) { + IconButton(onClick = onTagClick) { + Icon(painterResource(id = R.drawable.tag), contentDescription = "Tag") + } + } if (onPinClick != null) { IconButton(onClick = onPinClick) { Icon(Icons.Filled.PushPin, contentDescription = stringResource(R.string.pin_unpin)) @@ -342,7 +359,7 @@ fun DeleteConfirmationDialog( } @Composable -fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit) { +fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit, onOpenTags: () -> Unit) { LocalContext.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current @@ -465,6 +482,14 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let { InfoRowDetailed(stringResource(R.string.author), it) } + item.seriesName?.takeIf { it.isNotBlank() }?.let { series -> + val seriesText = if (item.seriesIndex != null && item.seriesIndex > 0) { + "$series #${item.seriesIndex.toInt()}" + } else { + series + } + InfoRowDetailed("Series", seriesText) + } InfoRowDetailed(stringResource(R.string.format), item.type.name) InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize)) InfoRowDetailed(stringResource(R.string.added), formattedDate) @@ -488,6 +513,19 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S ) } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Tags", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + TextButton(onClick = onOpenTags) { Text("+ Add / Edit") } + } + + if (item.tags.isNotEmpty()) { + BookTagChipsRow(tags = item.tags, compact = false) + } else { + Text("No tags assigned.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Row( modifier = Modifier .fillMaxWidth() @@ -901,6 +939,53 @@ fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolea } } +private fun TagEntity.displayColor(): Color = Color(color ?: 0xFF64B5F6.toInt()) + +@Composable +fun BookTagChipsRow( + tags: List, + modifier: Modifier = Modifier, + compact: Boolean = true, +) { + if (tags.isEmpty()) return + + Row( + modifier = modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + tags.forEach { tag -> + val tagColor = tag.displayColor() + Surface( + shape = RoundedCornerShape(50), + color = tagColor.copy(alpha = 0.14f), + contentColor = tagColor + ) { + Row( + modifier = Modifier.padding( + horizontal = if (compact) 8.dp else 10.dp, + vertical = if (compact) 4.dp else 6.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + modifier = Modifier + .size(if (compact) 6.dp else 8.dp) + .background(tagColor, androidx.compose.foundation.shape.CircleShape) + ) + Text( + text = tag.name, + style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } + } + } + } +} + private const val UNKNOWN_AUTHOR_LABEL = "No author listed" fun RecentFileItem.cardTitle(): String { @@ -1046,4 +1131,92 @@ fun ReadingProgressSection( trackColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) ) } -} \ No newline at end of file +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TagSelectionBottomSheet( + allTags: List, + selectedBookIds: Set, + booksWithTags: List, + onCreateAndAssign: (String) -> Unit, + onToggleTag: (String, Boolean) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var searchQuery by remember { mutableStateOf("") } + + val filteredTags = remember(allTags, searchQuery) { + if (searchQuery.isBlank()) allTags else allTags.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + + val exactMatch = allTags.any { it.name.equals(searchQuery.trim(), ignoreCase = true) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).heightIn(max = 500.dp)) { + Text("Apply Tags", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 16.dp)) + + androidx.compose.material3.OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search or create tag...") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + leadingIcon = { Icon(Icons.Default.Search, null) } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f, fill = false)) { + if (searchQuery.isNotBlank() && !exactMatch) { + item { + Row( + modifier = Modifier.fillMaxWidth().clickable { + onCreateAndAssign(searchQuery) + searchQuery = "" + }.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.width(16.dp)) + Text("Create \"${searchQuery.trim()}\"", color = MaterialTheme.colorScheme.primary) + } + } + } + + items(filteredTags, key = { it.id }) { tag -> + var checkedCount = 0 + selectedBookIds.forEach { bookId -> + val book = booksWithTags.find { it.bookId == bookId } + if (book?.tags?.any { it.id == tag.id } == true) checkedCount++ + } + + val state = when (checkedCount) { + 0 -> ToggleableState.Off + selectedBookIds.size -> ToggleableState.On + else -> ToggleableState.Indeterminate + } + + Row( + modifier = Modifier.fillMaxWidth().clickable { + val assign = state != ToggleableState.On + onToggleTag(tag.id, assign) + }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TriStateCheckbox(state = state, onClick = null) + Spacer(modifier = Modifier.width(16.dp)) + Surface(shape = androidx.compose.foundation.shape.CircleShape, color = Color(tag.color ?: 0xFF64B5F6.toInt()).copy(alpha = 0.2f), modifier = Modifier.size(24.dp)) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon(painterResource(id = R.drawable.tag), contentDescription = null, modifier = Modifier.size(12.dp), tint = Color(tag.color ?: 0xFF64B5F6.toInt())) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Text(tag.name, style = MaterialTheme.typography.bodyLarge) + } + } + } + } + } +} diff --git a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt index da594b6..50794f2 100644 --- a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt +++ b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt @@ -27,11 +27,24 @@ import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -@Database(entities =[RecentFileEntity::class, CustomFontEntity::class], version = 16, exportSchema = false) +@Database( + entities =[ + RecentFileEntity::class, + CustomFontEntity::class, + ShelfEntity::class, + BookShelfCrossRef::class, + TagEntity::class, + BookTagCrossRef::class + ], + version = 18, + exportSchema = false +) @TypeConverters(FileTypeConverter::class) abstract class AppDatabase : RoomDatabase() { abstract fun recentFileDao(): RecentFileDao abstract fun customFontDao(): CustomFontDao + abstract fun shelfDao(): ShelfDao + abstract fun tagDao(): TagDao companion object { @Volatile @@ -191,6 +204,53 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_16_17 = object : Migration(16, 17) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE recent_files ADD COLUMN seriesName TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE recent_files ADD COLUMN seriesIndex REAL DEFAULT NULL") + db.execSQL("ALTER TABLE recent_files ADD COLUMN description TEXT DEFAULT NULL") + } + } + + val MIGRATION_17_18 = object : Migration(17, 18) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL(""" + CREATE TABLE IF NOT EXISTS `shelves` ( + `id` TEXT NOT NULL, `name` TEXT NOT NULL, `isSmart` INTEGER NOT NULL, + `smartRulesJson` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, + `isDeleted` INTEGER NOT NULL, PRIMARY KEY(`id`) + ) + """) + db.execSQL(""" + CREATE TABLE IF NOT EXISTS `tags` ( + `id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER, + `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`) + ) + """) + db.execSQL(""" + CREATE TABLE IF NOT EXISTS `book_shelf_cross_ref` ( + `bookId` TEXT NOT NULL, `shelfId` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, + PRIMARY KEY(`bookId`, `shelfId`), + FOREIGN KEY(`bookId`) REFERENCES `recent_files`(`bookId`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`shelfId`) REFERENCES `shelves`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_book_shelf_cross_ref_shelfId` ON `book_shelf_cross_ref` (`shelfId`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_book_shelf_cross_ref_bookId` ON `book_shelf_cross_ref` (`bookId`)") + + db.execSQL(""" + CREATE TABLE IF NOT EXISTS `book_tag_cross_ref` ( + `bookId` TEXT NOT NULL, `tagId` TEXT NOT NULL, + PRIMARY KEY(`bookId`, `tagId`), + FOREIGN KEY(`bookId`) REFERENCES `recent_files`(`bookId`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`tagId`) REFERENCES `tags`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_book_tag_cross_ref_tagId` ON `book_tag_cross_ref` (`tagId`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_book_tag_cross_ref_bookId` ON `book_tag_cross_ref` (`bookId`)") + } + } + fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( @@ -202,7 +262,8 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, - MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16 + MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, + MIGRATION_16_17, MIGRATION_17_18 ) .fallbackToDestructiveMigration(false) .build() diff --git a/app/src/main/java/com/aryan/reader/data/LibraryDaos.kt b/app/src/main/java/com/aryan/reader/data/LibraryDaos.kt new file mode 100644 index 0000000..8420722 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/data/LibraryDaos.kt @@ -0,0 +1,61 @@ +package com.aryan.reader.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface ShelfDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertShelf(shelf: ShelfEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertBookShelfCrossRefs(crossRefs: List) + + @Query("SELECT * FROM shelves WHERE isDeleted = 0 ORDER BY name ASC") + fun getAllActiveShelves(): Flow> + + @Query("SELECT * FROM book_shelf_cross_ref") + fun getAllBookShelfCrossRefs(): Flow> + + @Query("DELETE FROM book_shelf_cross_ref WHERE shelfId = :shelfId AND bookId IN (:bookIds)") + suspend fun removeBooksFromShelf(shelfId: String, bookIds: List) + + @Query("UPDATE shelves SET isDeleted = 1, updatedAt = :timestamp WHERE id = :shelfId") + suspend fun markShelfAsDeleted(shelfId: String, timestamp: Long) + + @Query("UPDATE shelves SET name = :newName, updatedAt = :timestamp WHERE id = :shelfId") + suspend fun updateShelfName(shelfId: String, newName: String, timestamp: Long) + + @Query("SELECT * FROM shelves WHERE id = :shelfId") + suspend fun getShelfById(shelfId: String): ShelfEntity? + + @Query("SELECT * FROM book_shelf_cross_ref WHERE shelfId = :shelfId") + suspend fun getCrossRefsForShelf(shelfId: String): List +} + +@Dao +interface TagDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTag(tag: TagEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTags(tags: List) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertBookTagCrossRef(crossRef: BookTagCrossRef) + + @Query("SELECT * FROM tags ORDER BY name ASC") + fun getAllTags(): Flow> + + @Query("SELECT * FROM book_tag_cross_ref") + fun getAllBookTagCrossRefs(): Flow> + + @Query("SELECT COUNT(*) FROM tags") + suspend fun getTagCount(): Int + + @Query("DELETE FROM book_tag_cross_ref WHERE tagId = :tagId AND bookId = :bookId") + suspend fun removeTagFromBook(tagId: String, bookId: String) +} diff --git a/app/src/main/java/com/aryan/reader/data/LibraryEntities.kt b/app/src/main/java/com/aryan/reader/data/LibraryEntities.kt new file mode 100644 index 0000000..322def1 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/data/LibraryEntities.kt @@ -0,0 +1,74 @@ +package com.aryan.reader.data + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity(tableName = "shelves") +data class ShelfEntity( + @PrimaryKey val id: String, + val name: String, + val isSmart: Boolean = false, + val smartRulesJson: String? = null, + val createdAt: Long, + val updatedAt: Long, + val isDeleted: Boolean = false +) + +@Entity( + tableName = "book_shelf_cross_ref", + primaryKeys =["bookId", "shelfId"], + foreignKeys =[ + ForeignKey( + entity = RecentFileEntity::class, + parentColumns = ["bookId"], + childColumns = ["bookId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = ShelfEntity::class, + parentColumns = ["id"], + childColumns = ["shelfId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index(value = ["shelfId"]), Index(value = ["bookId"])] +) +data class BookShelfCrossRef( + val bookId: String, + val shelfId: String, + val addedAt: Long +) + +@Entity(tableName = "tags") +data class TagEntity( + @PrimaryKey val id: String, + val name: String, + val color: Int? = null, + val createdAt: Long +) + +@Entity( + tableName = "book_tag_cross_ref", + primaryKeys = ["bookId", "tagId"], + foreignKeys =[ + ForeignKey( + entity = RecentFileEntity::class, + parentColumns = ["bookId"], + childColumns = ["bookId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = TagEntity::class, + parentColumns = ["id"], + childColumns =["tagId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices =[Index(value = ["tagId"]), Index(value = ["bookId"])] +) +data class BookTagCrossRef( + val bookId: String, + val tagId: String +) \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt index f7c3912..dc809cf 100644 --- a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt +++ b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt @@ -72,34 +72,14 @@ object LocalSyncUtils { val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext val syncFileName = ".${metadata.bookId}.json" - val legacyVisibleName = "${metadata.bookId}.json" - - val existingHidden = syncDir.findFile(syncFileName) - val existingVisible = syncDir.findFile(legacyVisibleName) - val fileToCheck = existingHidden ?: existingVisible - - if (fileToCheck != null && fileToCheck.exists()) { - try { - val existingContent = context.contentResolver.openInputStream(fileToCheck.uri)?.use { input -> - input.bufferedReader().use { it.readText() } - } - if (existingContent != null) { - val existingMeta = FolderBookMetadata.fromJsonString(existingContent) - if (existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) { - Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.") - return@withContext - } - } - } catch (_: Exception) {} - } - - if (existingVisible != null && existingVisible.exists()) { - try { existingVisible.delete() } catch (_: Exception) {} + val existingMeta = resolveAndCleanMetadataConflicts(context, syncDir, metadata.bookId) + if (existingMeta != null && existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) { + Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.") + return@withContext } val tempFileName = ".${metadata.bookId}.tmp" syncDir.findFile(tempFileName)?.delete() - val tempFile = syncDir.createFile("application/json", tempFileName) if (tempFile == null) { Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}") @@ -128,7 +108,7 @@ object LocalSyncUtils { } @Suppress("KotlinConstantConditions") if (writeSuccess) { - val targetFile = rootTree.findFile(syncFileName) + val targetFile = syncDir.findFile(syncFileName) if (targetFile != null && targetFile.exists()) { targetFile.delete() } @@ -169,8 +149,8 @@ object LocalSyncUtils { val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId) val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json" val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp" + syncDir.findFile(tempName)?.delete() val tempFile = syncDir.createFile("application/json", tempName) - val existingMain = syncDir.findFile(targetName) if (currentBest != null) { val (remoteTs, _) = currentBest @@ -186,8 +166,6 @@ object LocalSyncUtils { wrapper.put("data", JSONObject(jsonPayload)) val contentBytes = wrapper.toString().toByteArray() - syncDir.findFile(tempName)?.delete() - if (tempFile == null) { Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.") return@withContext @@ -210,7 +188,7 @@ object LocalSyncUtils { } @Suppress("KotlinConstantConditions") if (writeSuccess) { - val existingMain = rootTree.findFile(targetName) + val existingMain = syncDir.findFile(targetName) if (existingMain != null) { if (!existingMain.delete()) { Timber.tag("FolderAnnotationSync").w("Failed to delete existing sidecar before rename. Attempting rename anyway (might fail on some SAF providers).") @@ -238,55 +216,14 @@ object LocalSyncUtils { try { val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) if (syncDir == null || !syncDir.isDirectory) return@withContext results - val allFiles = syncDir.listFiles() + val bookIds = syncDir.listFiles() + .mapNotNull { extractAnnotationBookId(it.name) } + .toSet() - val annotationFiles = allFiles.filter { file -> - val name = file.name ?: "" - name.contains(ANNOTATION_SUFFIX) && name.endsWith(".json") && !name.endsWith(".tmp") - } - - val filesByBookId = annotationFiles.groupBy { file -> - val name = file.name ?: "" - var temp = name.substringBeforeLast(".json") - if (temp.contains(".sync-conflict")) { - temp = temp.substringBefore(".sync-conflict") - } - if (temp.endsWith(ANNOTATION_SUFFIX)) { - temp = temp.substring(0, temp.length - ANNOTATION_SUFFIX.length) - } - if (temp.startsWith(".")) { - temp = temp.substring(1) - } - temp - } - - filesByBookId.forEach { (bookId, files) -> - if (bookId.isNotBlank()) { - var bestTs = -1L - var bestData: String? = null - - for (file in files) { - 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 preloaded file: ${file.name}") - } - } - - if (bestData != null) { - results[bookId] = Pair(bestTs, bestData) - } + for (bookId in bookIds) { + val best = resolveAndCleanAnnotationConflicts(context, syncDir, bookId) + if (best != null) { + results[bookId] = best } } } catch (e: Exception) { @@ -319,15 +256,16 @@ object LocalSyncUtils { bookId: String ): Pair? { val basePattern = ".${bookId}${ANNOTATION_SUFFIX}" + val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}" val allFiles = syncDir.listFiles() val candidates = allFiles.filter { file -> val name = file.name ?: "" - name.startsWith(basePattern) && - name.endsWith(".json") && - !name.endsWith(".tmp") && - !name.contains(".syncthing.") + (name.startsWith(basePattern) || name.startsWith(legacyPattern)) && + name.endsWith(".json") && + !name.endsWith(".tmp") && + !name.contains(".syncthing.") } if (candidates.isEmpty()) return null @@ -460,16 +398,76 @@ object LocalSyncUtils { } } - // 3. Migrate Legacy to Hidden if needed - val winnerName = bestFile.name ?: "" - if (!winnerName.startsWith(".")) { - Timber.tag(TAG).i("Migrating legacy file to hidden: $winnerName") + val correctName = ".${bookId}.json" + if (bestFile.name != correctName) { + Timber.tag(TAG).i("Renaming metadata winner ${bestFile.name} to $correctName") + bestFile.renameTo(correctName) } } return bestMeta } + private fun resolveAndCleanMetadataConflicts( + context: Context, + syncDir: DocumentFile, + bookId: String + ): FolderBookMetadata? { + val candidates = syncDir.listFiles().filter { file -> + val name = file.name ?: "" + val normalizedName = if (name.startsWith(".")) name.substring(1) else name + normalizedName == "$bookId.json" || + normalizedName.startsWith("$bookId.sync-conflict") || + normalizedName.startsWith("$bookId.json.sync-conflict") + } + if (candidates.isEmpty()) return null + return resolveAndCleanConflicts(context, candidates, bookId) + } + + private fun extractAnnotationBookId(name: String?): String? { + if (name.isNullOrBlank()) return null + var temp = name + if (!temp.contains(ANNOTATION_SUFFIX) || !temp.endsWith(".json") || temp.endsWith(".tmp")) return null + if (temp.contains(".sync-conflict")) { + temp = temp.substringBefore(".sync-conflict") + } + temp = temp.substringBeforeLast(".json") + if (temp.endsWith(ANNOTATION_SUFFIX)) { + temp = temp.substring(0, temp.length - ANNOTATION_SUFFIX.length) + } + if (temp.startsWith(".")) { + temp = temp.substring(1) + } + return temp.ifBlank { null } + } + + suspend fun deleteBookSidecars( + context: Context, + sourceFolderUri: Uri, + bookId: String + ) = withContext(Dispatchers.IO) { + try { + val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext + val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext + val targets = syncDir.listFiles().filter { file -> + val name = file.name ?: return@filter false + val normalized = if (name.startsWith(".")) name.substring(1) else name + normalized == "$bookId.json" || + normalized.startsWith("$bookId.sync-conflict") || + normalized.startsWith("$bookId.json.sync-conflict") || + normalized.startsWith("$bookId${ANNOTATION_SUFFIX}") + } + targets.forEach { + try { + it.delete() + } catch (_: Exception) { + } + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to delete folder sidecars for $bookId") + } + } + suspend fun getAllFolderMetadata( context: Context, sourceFolderUri: Uri @@ -486,6 +484,7 @@ object LocalSyncUtils { .filter { val name = it.name ?: "" (name.endsWith(".json") || name.contains(".sync-conflict")) && + !name.contains(ANNOTATION_SUFFIX) && !name.endsWith(".tmp") && !name.contains(".syncthing.") } @@ -513,4 +512,4 @@ object LocalSyncUtils { } return@withContext finalResults } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt index a6f9687..4af1a91 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt @@ -21,20 +21,19 @@ package com.aryan.reader.data import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Upsert import kotlinx.coroutines.flow.Flow @Dao interface RecentFileDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Upsert suspend fun insertOrUpdateFile(file: RecentFileEntity) - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Upsert suspend fun insertOrUpdateFiles(files: List) - @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") + @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") fun getRecentFiles(): Flow> @Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0") @@ -46,7 +45,7 @@ interface RecentFileDao { @Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId") suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean) - @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit") + @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit") fun getRecentFilesList(limit: Int): List @Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)") @@ -99,4 +98,4 @@ interface RecentFileDao { @Query("UPDATE recent_files SET highlights = :highlightsJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId") suspend fun updateHighlights(bookId: String, highlightsJson: String, timestamp: Long) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt index 96dd34d..4269ebe 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt @@ -51,7 +51,10 @@ data class RecentFileEntity( @ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean, @ColumnInfo(defaultValue = "NULL") val customName: String?, @ColumnInfo(defaultValue = "NULL") val highlights: String?, - @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 seriesIndex: Double?, + @ColumnInfo(defaultValue = "NULL") val description: String? ) data class RecentFileSummary( @@ -76,5 +79,8 @@ data class RecentFileSummary( @ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?, @ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean, @ColumnInfo(defaultValue = "NULL") val customName: String?, - @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 seriesIndex: Double?, + @ColumnInfo(defaultValue = "NULL") val description: String? ) \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt index c4ce3aa..bba7345 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt @@ -47,7 +47,11 @@ data class RecentFileItem( val isReflowPreferred: Boolean = false, val customName: String? = null, val highlightsJson: String? = null, - val fileSize: Long = 0L + val fileSize: Long = 0L, + val seriesName: String? = null, + val seriesIndex: Double? = null, + val description: String? = null, + val tags: List = emptyList() ) { fun getUri(): Uri? = uriString?.toUri() } @@ -77,7 +81,10 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem { isReflowPreferred = this.isReflowPreferred, customName = this.customName, highlightsJson = this.highlights, - fileSize = this.fileSize + fileSize = this.fileSize, + seriesName = this.seriesName, + seriesIndex = this.seriesIndex, + description = this.description ) } @@ -106,7 +113,10 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity { isReflowPreferred = this.isReflowPreferred, customName = this.customName, highlights = this.highlightsJson, - fileSize = this.fileSize + fileSize = this.fileSize, + seriesName = this.seriesName, + seriesIndex = this.seriesIndex, + description = this.description ) } @@ -184,6 +194,9 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem { isReflowPreferred = this.isReflowPreferred, customName = this.customName, highlightsJson = null, - fileSize = this.fileSize + fileSize = this.fileSize, + seriesName = this.seriesName, + seriesIndex = this.seriesIndex, + description = this.description ) } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index 7cf9be0..7bfd37f 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -28,6 +28,7 @@ import timber.log.Timber import com.aryan.reader.BookImporter import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.pdf.PdfRichTextRepository +import com.aryan.reader.epub.ImportedFileCache import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -39,6 +40,8 @@ import com.aryan.reader.pdf.data.PageLayoutRepository import com.aryan.reader.pdf.data.PdfTextBoxRepository import org.json.JSONObject import org.json.JSONArray +import java.util.UUID +import androidx.core.content.edit private const val COVER_CACHE_DIR = "cover_cache" @@ -54,6 +57,11 @@ class RecentFilesRepository(private val context: Context) { private val pdfTextBoxRepository = PdfTextBoxRepository(context) private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context) + val activeShelvesFlow = AppDatabase.getDatabase(context).shelfDao().getAllActiveShelves() + val shelfCrossRefsFlow = AppDatabase.getDatabase(context).shelfDao().getAllBookShelfCrossRefs() + val tagsFlow = AppDatabase.getDatabase(context).tagDao().getAllTags() + val tagCrossRefsFlow = AppDatabase.getDatabase(context).tagDao().getAllBookTagCrossRefs() + init { if (!coverCacheDir.exists()) { coverCacheDir.mkdirs() @@ -74,6 +82,28 @@ class RecentFilesRepository(private val context: Context) { return@withContext recentFileDao.getFileByUri(uriString)?.toRecentFileItem() } + suspend fun addShelf(shelf: ShelfEntity) = withContext(Dispatchers.IO) { + AppDatabase.getDatabase(context).shelfDao().insertShelf(shelf) + } + + suspend fun addBooksToShelf(shelfId: String, bookIds: List) = withContext(Dispatchers.IO) { + val timestamp = System.currentTimeMillis() + val crossRefs = bookIds.map { BookShelfCrossRef(it, shelfId, timestamp) } + AppDatabase.getDatabase(context).shelfDao().insertBookShelfCrossRefs(crossRefs) + } + + suspend fun renameShelf(shelfId: String, newName: String) = withContext(Dispatchers.IO) { + AppDatabase.getDatabase(context).shelfDao().updateShelfName(shelfId, newName, System.currentTimeMillis()) + } + + suspend fun deleteShelf(shelfId: String) = withContext(Dispatchers.IO) { + AppDatabase.getDatabase(context).shelfDao().markShelfAsDeleted(shelfId, System.currentTimeMillis()) + } + + suspend fun removeBooksFromShelf(shelfId: String, bookIds: List) = withContext(Dispatchers.IO) { + AppDatabase.getDatabase(context).shelfDao().removeBooksFromShelf(shelfId, bookIds) + } + suspend fun getFilesBySourceFolder(sourceFolderUri: String): List = withContext(Dispatchers.IO) { return@withContext recentFileDao.getFilesBySourceFolder(sourceFolderUri).map { it.toRecentFileItem() } } @@ -82,6 +112,26 @@ class RecentFilesRepository(private val context: Context) { return@withContext recentFileDao.getAllFiles().map { it.toRecentFileItem() } } + suspend fun createTag(tag: TagEntity) = withContext(Dispatchers.IO) { + AppDatabase.getDatabase(context).tagDao().insertTag(tag) + } + + suspend fun seedTagsIfEmpty(tags: List) = withContext(Dispatchers.IO) { + if (tags.isEmpty()) return@withContext + val tagDao = AppDatabase.getDatabase(context).tagDao() + if (tagDao.getTagCount() == 0) { + tagDao.insertTags(tags) + } + } + + suspend fun assignTagToBook(bookId: String, tagId: String) = withContext(Dispatchers.IO) { + AppDatabase.getDatabase(context).tagDao().insertBookTagCrossRef(BookTagCrossRef(bookId, tagId)) + } + + suspend fun removeTagFromBook(bookId: String, tagId: String) = withContext(Dispatchers.IO) { + AppDatabase.getDatabase(context).tagDao().removeTagFromBook(tagId, bookId) + } + suspend fun clearAllLocalData() = withContext(Dispatchers.IO) { recentFileDao.clearAll() if (coverCacheDir.exists()) { @@ -131,7 +181,10 @@ class RecentFilesRepository(private val context: Context) { isDeleted = item.isDeleted, sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri, highlights = item.highlightsJson ?: existingItem.highlights, - 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, + seriesIndex = item.seriesIndex ?: existingItem.seriesIndex, + description = item.description ?: existingItem.description ) } else { item.toRecentFileEntity() @@ -348,7 +401,9 @@ class RecentFilesRepository(private val context: Context) { if (item != null) { val currentTime = System.currentTimeMillis() recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime) - Timber.d("Updated PDF reading position for ${item.bookId} to page $page, progress $progress%") + Timber.tag("PdfPositionDebug").i("Repository: Executed DB update for ${item.bookId} to Page $page, Progress $progress% at TS: $currentTime") + } else { + Timber.tag("PdfPositionDebug").e("Repository: DB Update Failed! No recent file found matching URI: $uriString") } } @@ -400,8 +455,7 @@ class RecentFilesRepository(private val context: Context) { pdfTextBoxRepository.getFileForSync(item.bookId).delete() pdfHighlightRepository.getFileForSync(item.bookId).delete() - val cacheDir = File(context.cacheDir, "imported_file_${item.bookId}") - if (cacheDir.exists()) cacheDir.deleteRecursively() + ImportedFileCache.clearBookCache(context, item.bookId) } catch (e: Exception) { Timber.e(e, "Error during deep cleanup of sidecars for ${item.bookId}: ${e.message}") } @@ -473,8 +527,10 @@ class RecentFilesRepository(private val context: Context) { renameSafely(pdfTextBoxRepository.getFileForSync(oldId), pdfTextBoxRepository.getFileForSync(newId)) renameSafely(pdfHighlightRepository.getFileForSync(oldId), pdfHighlightRepository.getFileForSync(newId)) - val oldCache = File(context.cacheDir, "imported_file_$oldId") - val newCache = File(context.cacheDir, "imported_file_$newId") + ImportedFileCache.clearTemporaryBookDirs(context, oldId) + ImportedFileCache.clearTemporaryBookDirs(context, newId) + val oldCache = ImportedFileCache.activeBookDir(context, oldId) + val newCache = ImportedFileCache.activeBookDir(context, newId) if (oldCache.exists()) { if (newCache.exists()) newCache.deleteRecursively() oldCache.renameTo(newCache) @@ -486,8 +542,7 @@ class RecentFilesRepository(private val context: Context) { try { pdfRichTextRepository.getFileForSync(bookId).delete() pageLayoutRepository.getLayoutFile(bookId).delete() - val cacheDir = File(context.cacheDir, "imported_file_$bookId") - if (cacheDir.exists()) cacheDir.deleteRecursively() + ImportedFileCache.clearBookCache(context, bookId) Timber.d("Cleared layout and text caches for modified book: $bookId") } catch (e: Exception) { Timber.e(e, "Error clearing caches for $bookId") @@ -502,4 +557,46 @@ class RecentFilesRepository(private val context: Context) { } Timber.d("Batch inserted/updated ${items.size} recent files in DB.") } -} \ No newline at end of file + + suspend fun migrateLegacyShelvesToRoom() = withContext(Dispatchers.IO) { + val prefs = context.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) + if (prefs.getBoolean("is_shelves_migrated_to_room", false)) return@withContext + + Timber.i("Starting migration of legacy SharedPreferences shelves to Room DB...") + + val shelfNames = prefs.getStringSet("shelf_names", emptySet()) ?: emptySet() + if (shelfNames.isEmpty()) { + prefs.edit { putBoolean("is_shelves_migrated_to_room", true) } + return@withContext + } + + val db = AppDatabase.getDatabase(context) + val shelfDao = db.shelfDao() + + val validBookIds = recentFileDao.getAllFiles().map { it.bookId }.toSet() + + shelfNames.forEach { name -> + val shelfId = UUID.nameUUIDFromBytes(name.toByteArray()).toString() + val timestamp = prefs.getLong("shelf_timestamp_$name", System.currentTimeMillis()) + val isDeleted = prefs.getBoolean("shelf_deleted_$name", false) + val bookIds = prefs.getStringSet("shelf_content_$name", emptySet()) ?: emptySet() + + val shelf = ShelfEntity( + id = shelfId, name = name, isSmart = false, smartRulesJson = null, + createdAt = timestamp, updatedAt = timestamp, isDeleted = isDeleted + ) + shelfDao.insertShelf(shelf) + + val crossRefs = bookIds.filter { it in validBookIds }.map { bookId -> + BookShelfCrossRef(bookId = bookId, shelfId = shelfId, addedAt = timestamp) + } + + if (crossRefs.isNotEmpty()) { + shelfDao.insertBookShelfCrossRefs(crossRefs) + } + } + + prefs.edit { putBoolean("is_shelves_migrated_to_room", true) } + Timber.i("Successfully migrated legacy shelves to Room.") + } +} diff --git a/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt b/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt new file mode 100644 index 0000000..07a8d49 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/data/SmartCollectionEngine.kt @@ -0,0 +1,81 @@ +package com.aryan.reader.data + +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +enum class SmartField { TITLE, AUTHOR, PROGRESS, FILE_TYPE, FOLDER, TAG } +@Serializable +enum class SmartOperator { EQUALS, CONTAINS, GREATER_THAN, LESS_THAN } + +@Serializable +data class SmartRule( + val field: SmartField, + val operator: SmartOperator, + val value: String +) + +@Serializable +data class SmartCollectionDefinition( + val matchAll: Boolean = true, + val rules: List = emptyList() +) + +object SmartCollectionEngine { + private val json = Json { + encodeDefaults = true + ignoreUnknownKeys = true + } + + fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition) + + fun fromJson(json: String?): SmartCollectionDefinition? { + if (json.isNullOrBlank()) return null + return try { + this.json.decodeFromString(json) + } catch (_: Exception) { null } + } + + fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean { + if (definition.rules.isEmpty()) return false + + val results = definition.rules.map { rule -> + when (rule.field) { + SmartField.TITLE -> evaluateString(book.title ?: book.displayName, rule) + SmartField.AUTHOR -> evaluateString(book.author ?: "", rule) + SmartField.FILE_TYPE -> evaluateString(book.type.name, rule) + SmartField.FOLDER -> evaluateString(book.sourceFolderUri ?: "", rule) + SmartField.TAG -> evaluateTags(book.tags.map { it.name }, rule) + SmartField.PROGRESS -> evaluateNumber(book.progressPercentage ?: 0f, rule) + } + } + return if (definition.matchAll) results.all { it } else results.any { it } + } + + private fun evaluateString(target: String, rule: SmartRule): Boolean { + return when (rule.operator) { + SmartOperator.EQUALS -> target.equals(rule.value, ignoreCase = true) + SmartOperator.CONTAINS -> target.contains(rule.value, ignoreCase = true) + else -> false + } + } + + private fun evaluateNumber(target: Float, rule: SmartRule): Boolean { + val ruleValue = rule.value.toFloatOrNull() ?: return false + return when (rule.operator) { + SmartOperator.EQUALS -> target == ruleValue + SmartOperator.GREATER_THAN -> target > ruleValue + SmartOperator.LESS_THAN -> target < ruleValue + else -> false + } + } + + private fun evaluateTags(tags: List, rule: SmartRule): Boolean { + return when (rule.operator) { + SmartOperator.EQUALS -> tags.any { it.equals(rule.value, ignoreCase = true) } + SmartOperator.CONTAINS -> tags.any { it.contains(rule.value, ignoreCase = true) } + else -> false + } + } +} diff --git a/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt b/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt new file mode 100644 index 0000000..12a06e4 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt @@ -0,0 +1,166 @@ +// CalibreBundleExtractor.kt +package com.aryan.reader.epub + +import android.content.Context +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.core.net.toUri +import com.aryan.reader.BookImporter +import com.aryan.reader.FileType +import com.aryan.reader.data.RecentFilesRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.w3c.dom.Element +import timber.log.Timber +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipInputStream +import javax.xml.parsers.DocumentBuilderFactory + +data class CalibreBundleResult( + val internalBookUri: Uri, + val type: FileType, + val title: String?, + val author: String?, + val description: String?, + val seriesName: String?, + val seriesIndex: Double?, + val coverCachePath: String? +) + +object CalibreBundleExtractor { + suspend fun processZip( + context: Context, + zipUri: Uri, + bookId: String, + bookImporter: BookImporter, + recentFilesRepository: RecentFilesRepository + ): CalibreBundleResult? = withContext(Dispatchers.IO) { + var tempBookFile: File? = null + var extractedType: FileType? = null + var ext = "" + var opfData: String? = null + var coverBytes: ByteArray? = null + + try { + context.contentResolver.openInputStream(zipUri)?.use { inputStream -> + val zis = ZipInputStream(inputStream) + var entry = zis.nextEntry + Timber.d("CalibreExtractor: Started reading zip entries from $zipUri") + while (entry != null) { + val name = entry.name.lowercase() + Timber.d("CalibreExtractor: Found zip entry: $name") + if (!entry.isDirectory) { + if (name.endsWith(".opf")) { + opfData = String(zis.readBytes(), Charsets.UTF_8) + Timber.d("CalibreExtractor: Extracted OPF data, length=${opfData?.length}") + } else if (name == "cover.jpg" || name == "cover.jpeg" || name.endsWith(".jpg")) { + // Prefer exact 'cover.jpg' but grab the first image as fallback + if (coverBytes == null || name.startsWith("cover")) { + coverBytes = zis.readBytes() + Timber.d("CalibreExtractor: Extracted cover image from $name") + } + } else { + val type = when { + name.endsWith(".epub") -> FileType.EPUB + name.endsWith(".mobi") || name.endsWith(".azw3") -> FileType.MOBI + name.endsWith(".pdf") -> FileType.PDF + name.endsWith(".fb2") -> FileType.FB2 + else -> null + } + if (type != null && tempBookFile == null) { + extractedType = type + ext = File(name).extension + tempBookFile = File(context.cacheDir, "temp_bundle_${bookId}.$ext") + FileOutputStream(tempBookFile!!).use { fos -> + zis.copyTo(fos) + } + Timber.d("CalibreExtractor: Extracted book file $name to temp file") + } + } + } + zis.closeEntry() + entry = zis.nextEntry + } + } + + Timber.d("CalibreExtractor: Finished parsing zip. tempBookFile exists=${tempBookFile != null}, opfData exists=${opfData != null}, extractedType=$extractedType") + + if (tempBookFile != null && opfData != null && extractedType != null) { + val finalBookFile = bookImporter.createBookFile("$bookId.$ext") + tempBookFile!!.renameTo(finalBookFile) + + var coverPath: String? = null + if (coverBytes != null) { + val bitmap = BitmapFactory.decodeByteArray(coverBytes, 0, coverBytes!!.size) + if (bitmap != null) { + coverPath = recentFilesRepository.saveCoverToCache(bitmap, zipUri) + } + } + + var title: String? = null + var author: String? = null + var description: String? = null + var seriesName: String? = null + var seriesIndex: Double? = null + + try { + val factory = DocumentBuilderFactory.newInstance() + val builder = factory.newDocumentBuilder() + val document = builder.parse(ByteArrayInputStream(opfData!!.toByteArray(Charsets.UTF_8))) + val metadataNodes = document.getElementsByTagName("metadata") + Timber.d("CalibreExtractor: Parsed OPF XML. metadataNodes count: ${metadataNodes.length}") + + if (metadataNodes.length > 0) { + val metadata = metadataNodes.item(0) as Element + + val titleNodes = metadata.getElementsByTagName("dc:title") + Timber.d("CalibreExtractor: Found ${titleNodes.length} dc:title nodes") + if (titleNodes.length > 0) title = titleNodes.item(0).textContent + + val authorNodes = metadata.getElementsByTagName("dc:creator") + Timber.d("CalibreExtractor: Found ${authorNodes.length} dc:creator nodes") + if (authorNodes.length > 0) author = authorNodes.item(0).textContent + + val descNodes = metadata.getElementsByTagName("dc:description") + Timber.d("CalibreExtractor: Found ${descNodes.length} dc:description nodes") + if (descNodes.length > 0) description = descNodes.item(0).textContent + + val metaNodes = metadata.getElementsByTagName("meta") + Timber.d("CalibreExtractor: Found ${metaNodes.length} meta nodes") + + for (i in 0 until metaNodes.length) { + val meta = metaNodes.item(i) as Element + val nameAttr = meta.getAttribute("name") + val contentAttr = meta.getAttribute("content") + if (nameAttr == "calibre:series") seriesName = contentAttr + if (nameAttr == "calibre:series_index") seriesIndex = contentAttr.toDoubleOrNull() + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse metadata.opf") + } + + Timber.d("CalibreExtractor: Final extracted info - title=$title, author=$author, series=$seriesName, index=$seriesIndex") + + return@withContext CalibreBundleResult( + internalBookUri = finalBookFile.toUri(), + type = extractedType!!, + title = title, + author = author, + description = description, + seriesName = seriesName, + seriesIndex = seriesIndex, + coverCachePath = coverPath + ) + } + + } catch (e: Exception) { + Timber.e(e, "Failed to process zip bundle") + } finally { + tempBookFile?.delete() // Cleanup if parsing failed midway + } + return@withContext null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epub/EpubBook.kt b/app/src/main/java/com/aryan/reader/epub/EpubBook.kt index 59f08ee..7e32bb5 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubBook.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubBook.kt @@ -46,5 +46,8 @@ data class EpubBook( val extractionBasePath: String = "", val css: Map = emptyMap(), @Transient - val chaptersForPagination: List = chapters + val chaptersForPagination: List = chapters, + val seriesName: String? = null, + val seriesIndex: Double? = null, + val description: String? = null, ) \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt index dfe2092..b5bb7c1 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt @@ -167,17 +167,14 @@ class EpubParser(private val context: Context) { bookId: String, shouldUseToc: Boolean = true, originalBookNameHint: String = "streamed_book", - parseContent: Boolean = true + parseContent: Boolean = true, + extractionDirOverride: File? = null ): EpubBook { return withContext(Dispatchers.IO) { Timber.d("Parsing EPUB input stream for bookId: $bookId") - val extractionDir = File(context.cacheDir, "imported_file_$bookId") - - if (extractionDir.exists()) { - extractionDir.deleteRecursively() - } - extractionDir.mkdirs() + val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory) + ?: ImportedFileCache.prepareActiveBookDir(context, bookId) val tempFile = File.createTempFile("epub_stream", ".epub", context.cacheDir) val filesMap: Map @@ -244,6 +241,24 @@ class EpubParser(private val context: Context) { val metadataLanguage = document.metadata.selectFirstChildTag("dc:language")?.textContent ?: "en" val metadataCoverId = getMetadataCoverId(document.metadata) + val metadataDescription = + document.metadata.selectFirstChildTag("dc:description")?.textContent + + Timber.d("EpubParser: Extracted OPF metadata: title='$metadataTitle', author='$metadataAuthor'") + + var metadataSeriesName: String? = null + var metadataSeriesIndex: Double? = null + + document.metadata.selectChildTag("meta") + .ifEmpty { document.metadata.selectChildTag("opf:meta") } + .forEach { meta -> + val nameAttr = meta.getAttributeValue("name") + val contentAttr = meta.getAttributeValue("content") + + if (nameAttr == "calibre:series") metadataSeriesName = contentAttr + if (nameAttr == "calibre:series_index") metadataSeriesIndex = contentAttr?.toDoubleOrNull() + } + val opfRelativePath = document.opfFilePath val opfParentDir = File(opfRelativePath).parentFile ?: File("") val manifestItems = getManifestItems(document.manifest, opfParentDir) @@ -344,7 +359,10 @@ class EpubParser(private val context: Context) { pageList = pageTargets, tableOfContents = tableOfContents, extractionBasePath = extractionBasePath, - css = cssContent + css = cssContent, + seriesName = metadataSeriesName, + seriesIndex = metadataSeriesIndex, + description = metadataDescription ) } @@ -688,4 +706,4 @@ class EpubParser(private val context: Context) { lowerName.endsWith(".htm") || lowerName.endsWith(".css") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt index a465ecd..2d6d743 100644 --- a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt +++ b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt @@ -20,13 +20,11 @@ class Fb2Parser(private val context: Context) { inputStream: InputStream, bookId: String, originalBookNameHint: String, - parseContent: Boolean = true + parseContent: Boolean = true, + extractionDirOverride: File? = null ): EpubBook = withContext(Dispatchers.IO) { - File(context.cacheDir, "imported_file_$bookId").deleteRecursively() - - val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply { - if (!exists()) mkdirs() - } + val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory) + ?: ImportedFileCache.prepareActiveBookDir(context, bookId) var streamToParse = inputStream try { @@ -324,4 +322,4 @@ class Fb2Parser(private val context: Context) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt b/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt new file mode 100644 index 0000000..c3fdb28 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/epub/ImportedFileCache.kt @@ -0,0 +1,81 @@ +package com.aryan.reader.epub + +import android.content.Context +import java.io.File +import java.util.UUID + +object ImportedFileCache { + private const val ACTIVE_PREFIX = "imported_file_" + private const val TEMP_PREFIX = "imported_file_tmp_" + private val invalidSegmentChars = Regex("[^A-Za-z0-9._-]+") + + fun activeBookDir(context: Context, bookId: String): File { + return File(context.cacheDir, "$ACTIVE_PREFIX$bookId") + } + + fun prepareActiveBookDir(context: Context, bookId: String): File { + return prepareDirectory(activeBookDir(context, bookId)) + } + + fun createTemporaryBookDir(context: Context, bookId: String, purpose: String): File { + val dirName = buildString { + append(TEMP_PREFIX) + append(purpose.toCacheSegment()) + append('_') + append(bookMarker(bookId)) + append('_') + append(UUID.randomUUID()) + } + return prepareDirectory(File(context.cacheDir, dirName)) + } + + fun prepareDirectory(directory: File): File { + if (directory.exists()) { + directory.deleteRecursively() + } + directory.mkdirs() + return directory + } + + fun clearBookCache(context: Context, bookId: String) { + activeBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively() + clearTemporaryBookDirs(context, bookId) + } + + fun clearTemporaryBookDirs(context: Context, bookId: String) { + val marker = "_${bookMarker(bookId)}_" + context.cacheDir.listFiles()?.forEach { file -> + if (isTemporaryBookDir(file.name) && file.name.contains(marker)) { + file.deleteRecursively() + } + } + } + + fun deleteStaleTemporaryBookDirs( + context: Context, + olderThanMillis: Long, + nowMillis: Long = System.currentTimeMillis() + ) { + context.cacheDir.listFiles()?.forEach { file -> + if (isTemporaryBookDir(file.name) && nowMillis - file.lastModified() >= olderThanMillis) { + file.deleteRecursively() + } + } + } + + fun isTemporaryBookDir(name: String): Boolean = name.startsWith(TEMP_PREFIX) + + fun isActiveBookDir(name: String): Boolean { + return name.startsWith(ACTIVE_PREFIX) && !isTemporaryBookDir(name) + } + + private fun bookMarker(bookId: String): String { + val normalized = bookId.toCacheSegment().ifBlank { "book" }.take(40) + val hash = bookId.hashCode().toLong() and 0xffffffffL + return "${normalized}_${hash.toString(16)}" + } + + private fun String.toCacheSegment(): String { + return replace(invalidSegmentChars, "_").trim('_') + } +} diff --git a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt b/app/src/main/java/com/aryan/reader/epub/MobiParser.kt index af7f138..e52c239 100644 --- a/app/src/main/java/com/aryan/reader/epub/MobiParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/MobiParser.kt @@ -109,27 +109,18 @@ class MobiParser(private val context: Context) { private external fun parseMobiFile(filePath: String): ParsedMobiData? companion object { - const val EXTRACTED_EPUB_DIR_NAME = "extracted_epubs" - init { System.loadLibrary("mobi") System.loadLibrary("native-lib") } } - private fun getBookExtractionDir(bookIdentifier: String): File { - val parentDir = File(context.cacheDir, EXTRACTED_EPUB_DIR_NAME) - if (!parentDir.exists()) { - parentDir.mkdirs() - } - return File(parentDir, bookIdentifier) - } - suspend fun createMobiBook( inputStream: InputStream, bookId: String, originalBookNameHint: String, - parseContent: Boolean = true + parseContent: Boolean = true, + extractionDirOverride: File? = null ): EpubBook? = withContext(Dispatchers.IO) { val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir) try { @@ -162,8 +153,8 @@ class MobiParser(private val context: Context) { val bookTitle = parsedData.title ?: originalBookNameHint val bookAuthor = parsedData.author ?: "Unknown Author" - val extractionDir = File(context.cacheDir, "imported_file_$bookId") - extractionDir.mkdirs() + val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory) + ?: ImportedFileCache.prepareActiveBookDir(context, bookId) val sequentialImageMap = parsedData.resources .filter { it.mediaType.startsWith("image/") } @@ -314,4 +305,4 @@ class MobiParser(private val context: Context) { Timber.d("Final EpubBook created. CSS map size: ${finalBook.css.size}, Image count: ${finalBook.images.size}") return@withContext finalBook } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt index 55cb3e8..e027fef 100644 --- a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt @@ -29,12 +29,11 @@ class OdtParser(private val context: Context) { bookId: String, originalBookNameHint: String, isFlat: Boolean, - parseContent: Boolean = true + parseContent: Boolean = true, + extractionDirOverride: File? = null ): EpubBook = withContext(Dispatchers.IO) { - File(context.cacheDir, "imported_file_$bookId").deleteRecursively() - val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply { - if (!exists()) mkdirs() - } + val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory) + ?: ImportedFileCache.prepareActiveBookDir(context, bookId) val mathJaxFileName = "tex-mml-chtml.js" val mathJaxFile = File(extractionDir, mathJaxFileName) @@ -427,4 +426,4 @@ class OdtParser(private val context: Context) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt index f029d6a..8f8caea 100644 --- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt +++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt @@ -41,11 +41,17 @@ import java.io.File import java.io.FileOutputStream import java.io.InputStream import java.util.UUID +import java.util.zip.ZipFile class SingleFileImporter(private val context: Context) { private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true } + companion object { + private const val MAX_DOCX_ARCHIVE_BYTES = 64L * 1024L * 1024L + private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L + } + suspend fun importSingleFile( inputStream: InputStream, type: FileType, @@ -104,14 +110,19 @@ class SingleFileImporter(private val context: Context) { var inQuotes = false for (char in line) { - if (char == '\"') { - inQuotes = !inQuotes - } else if (char == delimiter && !inQuotes) { - val escaped = current.toString().replace("&", "&").replace("<", "<").replace(">", ">") - writer.write("$escaped") - current.clear() - } else { - current.append(char) + when (char) { + '\"' -> { + inQuotes = !inQuotes + } + delimiter if !inQuotes -> { + val escaped = + current.toString().replace("&", "&").replace("<", "<").replace(">", ">") + writer.write("$escaped") + current.clear() + } + else -> { + current.append(char) + } } } val escapedFinal = current.toString().replace("&", "&").replace("<", "<").replace(">", ">") @@ -663,15 +674,29 @@ class SingleFileImporter(private val context: Context) { val parseStart = System.currentTimeMillis() Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint") - val htmlContent = inputStream.use { stream -> - val converter = DocumentConverter() - converter.convertToHtml(stream).value ?: "" - } - - Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms") - + val sourceDocxFile = File(context.cacheDir, "temp_docx_source_${UUID.randomUUID()}.docx") val tempFile = File(context.cacheDir, "temp_docx_${UUID.randomUUID()}.html") try { + inputStream.use { stream -> + FileOutputStream(sourceDocxFile).use { output -> + stream.copyTo(output) + } + } + + validateDocxForImport(sourceDocxFile, originalBookNameHint) + + val htmlContent = sourceDocxFile.inputStream().use { stream -> + try { + val converter = DocumentConverter() + converter.convertToHtml(stream).value ?: "" + } catch (oom: OutOfMemoryError) { + Timber.e(oom, "DOCX conversion ran out of memory for $originalBookNameHint") + throw IllegalStateException("This DOCX file is too large to open safely on this device.") + } + } + + Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms") + FileOutputStream(tempFile).bufferedWriter().use { writer -> val title = originalBookNameHint.substringBeforeLast(".") writer.write("\n\n\n$title\n\n\n") @@ -683,12 +708,41 @@ class SingleFileImporter(private val context: Context) { return@withContext parseHtml(tempStream, originalBookNameHint, bookId, parseContent) } } finally { + if (sourceDocxFile.exists()) { + sourceDocxFile.delete() + } if (tempFile.exists()) { tempFile.delete() } } } + private fun validateDocxForImport(sourceDocxFile: File, originalBookNameHint: String) { + val archiveBytes = sourceDocxFile.length() + if (archiveBytes > MAX_DOCX_ARCHIVE_BYTES) { + throw IllegalStateException("This DOCX file is too large to open safely on this device.") + } + + ZipFile(sourceDocxFile).use { zip -> + var totalXmlBytes = 0L + val entries = zip.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (entry.isDirectory) continue + if (entry.name.endsWith(".xml", ignoreCase = true)) { + val entrySize = entry.size + if (entrySize > 0) { + totalXmlBytes += entrySize + } + if (totalXmlBytes > MAX_DOCX_XML_BYTES) { + Timber.w("DOCX XML payload too large for import: file=$originalBookNameHint xmlBytes=$totalXmlBytes") + throw IllegalStateException("This DOCX file is too large to open safely on this device.") + } + } + } + } + } + private fun writeHtmlChapter( extractionDir: File, bookId: String, @@ -718,4 +772,4 @@ class SingleFileImporter(private val context: Context) { isInToc = true ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index 9d28ffa..59a99c0 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -92,6 +92,8 @@ import java.io.BufferedReader import java.io.ByteArrayOutputStream import java.io.InputStreamReader +private const val TAG_LINK_NAV = "LINK_NAV" + private fun getFontCssInjection(): String { return """ @font-face { font-family: 'Merriweather'; src: url('file:///android_asset/fonts/merriweather.ttf'); } @@ -313,6 +315,17 @@ class FootnoteJsBridge( } } +@Suppress("unused") +class LinkNavJsBridge( + private val currentChapterTitle: String +) { + @JavascriptInterface + fun onLinkClicked(href: String, epubType: String, linkText: String) { + Timber.tag(TAG_LINK_NAV) + .d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'") + } +} + @SuppressLint("SetJavaScriptEnabled") @Composable fun ChapterWebView( @@ -336,6 +349,8 @@ fun ChapterWebView( currentFontSize: Float, currentLineHeight: Float, currentParagraphGap: Float, + currentImageSize: Float, + currentHorizontalMargin: Float, onChapterInitiallyScrolled: () -> Unit, modifier: Modifier = Modifier, onTap: () -> Unit, @@ -370,6 +385,7 @@ fun ChapterWebView( onAutoScrollChapterEnd: () -> Unit = {}, activeHighlightPalette: List, onUpdatePalette: (Int, HighlightColor) -> Unit, + onInternalLinkClick: (String) -> Unit, activeTextureId: String? = null ) { Timber.d( @@ -471,6 +487,8 @@ fun ChapterWebView( currentFontSize, currentLineHeight, currentParagraphGap, + currentImageSize, + currentHorizontalMargin, currentFontFamily, currentTextAlign ) { @@ -550,6 +568,11 @@ fun ChapterWebView( consoleMessage?.let { val message = it.message() when { + message.startsWith("LINK_NAV:") -> { + Timber.tag(TAG_LINK_NAV) + .d("JS -> ${message.substringAfter("LINK_NAV: ")}") + } + message.startsWith("FootnoteDiag:") -> { Timber.tag("FootnoteDiag") .d("JS -> ${message.substringAfter("FootnoteDiag: ")}") @@ -653,16 +676,31 @@ fun ChapterWebView( }, "FootnoteBridge" ) + addJavascriptInterface( + LinkNavJsBridge(chapterTitle), "LinkNavBridge" + ) + webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? ): Boolean { val url = request?.url?.toString() if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) { - Timber.d("Intercepted external link: $url") + Timber.tag(TAG_LINK_NAV) + .d("[EXTERNAL-INTERCEPT] url='$url' from chapter '$chapterTitle'") showExternalLinkDialog = url return true } + if (url != null && url.startsWith("file://")) { + Timber.tag(TAG_LINK_NAV) + .d("[INTERNAL-LINK-INTERCEPTED] url='$url' from chapter '$chapterTitle'") + onInternalLinkClick(url) + return true + } + if (url != null) { + Timber.tag(TAG_LINK_NAV) + .d("[INTERNAL-LINK-PASSED] url='$url' from chapter '$chapterTitle' — allowing WebView to handle") + } return false } @@ -744,7 +782,7 @@ fun ChapterWebView( } view?.evaluateJavascript( - "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap);", + "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);", null ) @@ -768,10 +806,59 @@ fun ChapterWebView( } } else if (!initialFragmentId.isNullOrBlank()) { Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId") - view?.evaluateJavascript( - "javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }", - null - ) + val js = """ + (function() { + var targetId = '$initialFragmentId'; + var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]'); + if (el) { + var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + return -2; + } + if (window.virtualization && window.virtualization.chunksData) { + for (var i = 0; i < window.virtualization.chunksData.length; i++) { + var chunkHtml = window.virtualization.chunksData[i]; + if (chunkHtml && (chunkHtml.indexOf('id="' + targetId + '"') !== -1 || chunkHtml.indexOf('name="' + targetId + '"') !== -1 || chunkHtml.indexOf("id='" + targetId + "'") !== -1 || chunkHtml.indexOf("name='" + targetId + "'") !== -1)) { + return i; + } + } + } + return -1; + })() + """.trimIndent() + + view?.evaluateJavascript(js) { result -> + val chunkIdx = result?.toIntOrNull() ?: -1 + if (chunkIdx >= 0) { + for (i in 0..chunkIdx) { + onChunkRequested(i) + } + val scrollJs = """ + (function() { + var chunkIndex = $chunkIdx; + var fragmentId = '$initialFragmentId'; + setTimeout(function() { + var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]'); + if (chunkDiv && chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { + chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex]; + chunkDiv.style.height = ""; + } + setTimeout(function() { + var el = document.getElementById(fragmentId) || document.querySelector('[name="' + fragmentId + '"]'); + if (el) { + var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + } else if (chunkDiv) { + var targetScrollY = window.scrollY + chunkDiv.getBoundingClientRect().top - window.VIEWPORT_PADDING_TOP; + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + } + }, 50); + }, 200); + })() + """.trimIndent() + view.evaluateJavascript(scrollJs, null) + } + } onChapterInitiallyScrolled() scrollActionTaken = true } else if (initialScrollTarget != null) { @@ -859,7 +946,7 @@ fun ChapterWebView( ) webView.evaluateJavascript( - "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap);", + "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);", null ) @@ -1075,4 +1162,4 @@ fun ChapterWebView( }) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index 870ce0d..20e1cec 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -1378,6 +1378,7 @@ fun TtsOverlayControls( currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode, isCollapsed: Boolean, onCollapseChange: (Boolean) -> Unit, + onLocateCurrentChunk: () -> Unit, onOpenTtsSettings: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier, @@ -1504,6 +1505,14 @@ fun TtsOverlayControls( } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton(onClick = onLocateCurrentChunk, modifier = Modifier.size(32.dp)) { + Icon( + painterResource(R.drawable.pin_drop), + "Locate current chunk", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) { Icon(Icons.Default.ChevronRight, "Collapse", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -1589,4 +1598,4 @@ fun TtsOverlayControls( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index cc804a5..b353f66 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -218,6 +218,11 @@ private const val AUTO_SCROLL_LOCAL_MAX_PREFIX = "auto_scroll_local_max_" private const val MUSICIAN_MODE_KEY = "musician_mode_enabled" private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled" private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools" +private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore" +private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume" +private const val TTS_LOCATE_REASON_OVERLAY = "overlay" + +private const val TAG_LINK_NAV = "LINK_NAV" private fun saveHiddenTools(context: Context, hiddenTools: Set) { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) @@ -411,6 +416,48 @@ fun EpubReaderScreen( } } else null + val hasValidExtractionBasePath = remember(epubBook.extractionBasePath) { + epubBook.extractionBasePath.isNotBlank() && File(epubBook.extractionBasePath).exists() + } + var requestedContentRecovery by remember(epubBook.extractionBasePath, uiState.selectedBookId) { + mutableStateOf(false) + } + + LaunchedEffect(hasValidExtractionBasePath, uiState.selectedBookId, uiState.selectedEpubUri) { + if (!hasValidExtractionBasePath && !requestedContentRecovery && uiState.selectedEpubUri != null) { + requestedContentRecovery = true + viewModel.recoverSelectedEpubContent() + } + } + + if (!hasValidExtractionBasePath) { + val isRecovering = uiState.isLoading || (requestedContentRecovery && uiState.errorMessage == null) + val message = uiState.errorMessage ?: if (isRecovering) { + "Recovering book content..." + } else { + "Book content not found. Reopen the book to recreate its cache." + } + + Box(modifier = Modifier.fillMaxSize()) { + if (isRecovering) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + Text( + text = message, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(24.dp), + color = if (uiState.errorMessage != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + textAlign = TextAlign.Center + ) + } + return + } + EpubReaderHost( epubBook = epubBook, renderMode = renderMode, @@ -519,7 +566,6 @@ fun EpubReaderHost( var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) } var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) } var showVisualOptionsSheet by remember { mutableStateOf(false) } - var removeEdgePadding by remember { mutableStateOf(loadRemoveEdgePadding(context)) } var volumeScrollEnabled by remember { mutableStateOf(loadVolumeScrollSetting(context)) @@ -819,6 +865,12 @@ fun EpubReaderHost( var ttsShouldStartOnChapterLoad by remember { mutableStateOf(false) } var userStoppedTts by remember { mutableStateOf(false) } var ttsChapterIndex by remember { mutableStateOf(null) } + var pendingTtsLocateRequest by remember { mutableStateOf(false) } + var pendingTtsLocateReason by remember { mutableStateOf(null) } + var hasQueuedInitialTtsLocate by remember(epubBook.title) { mutableStateOf(false) } + var isDetachedFromVerticalTts by remember { mutableStateOf(false) } + var detachedVerticalTtsChunkKey by remember { mutableStateOf(null) } + var suppressNextVerticalTtsDetach by remember { mutableStateOf(false) } var searchHighlightTarget by remember { mutableStateOf(null) } var lastHighlightClickTime by remember { mutableLongStateOf(0L) } @@ -851,6 +903,7 @@ fun EpubReaderHost( val paginatedPagerState = rememberPagerState(pageCount = { (paginator as? BookPaginator)?.totalPageCount ?: 0 }) + var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) } val ttsController = rememberTtsController() val ttsState by ttsController.ttsState.collectAsState() @@ -936,6 +989,8 @@ fun EpubReaderHost( var currentFontSizeEm by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.fontSize) } var currentLineHeight by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.lineHeight) } var currentParagraphGap by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.paragraphGap) } + var currentImageSize by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.imageSize) } + var currentHorizontalMargin by remember(initialFormatSettings) { mutableFloatStateOf(initialFormatSettings.horizontalMargin) } var currentTextAlign by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.textAlign) } var currentFontFamily by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.font) } var currentCustomFontPath by remember(initialFormatSettings) { mutableStateOf(initialFormatSettings.customPath) } @@ -951,14 +1006,14 @@ fun EpubReaderHost( var showFontSelectionSheet by remember { mutableStateOf(false) } val fontSheetState = rememberModalBottomSheetState() - LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) { + LaunchedEffect(currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign, isFormatLocal) { if (isFormatLocal) { saveLocalReaderSettings( - context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentFontFamily, currentCustomFontPath, currentTextAlign + context, bookId, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign ) } else { saveReaderSettings( - context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentFontFamily, currentCustomFontPath, currentTextAlign + context, currentFontSizeEm, currentLineHeight, currentParagraphGap, currentImageSize, currentHorizontalMargin, currentFontFamily, currentCustomFontPath, currentTextAlign ) } } @@ -1116,6 +1171,223 @@ fun EpubReaderHost( } } + fun isActiveReaderTtsForCurrentBook(): Boolean { + val isReaderSession = ttsState.playbackSource == "READER" + val hasReaderSessionState = + ttsState.isPlaying || + ttsState.isLoading || + ttsState.sessionFinished || + ttsState.chapterIndex != null || + !ttsState.currentWordSourceCfi.isNullOrBlank() || + !ttsState.sourceCfi.isNullOrBlank() || + !ttsState.currentText.isNullOrBlank() + val isSameBook = ttsState.bookTitle == null || ttsState.bookTitle == epubBook.title + return isReaderSession && hasReaderSessionState && isSameBook + } + + fun getActiveTtsChapterIndex(): Int? = ttsState.chapterIndex ?: ttsChapterIndex + + fun buildTtsDiagState(): String { + val sourceCfiPreview = ttsState.sourceCfi?.take(48) + val pendingCfiPreview = cfiToLoad?.take(48) + return "render=$currentRenderMode currentChapter=$currentChapterIndex activeTtsChapter=${getActiveTtsChapterIndex()} " + + "pendingLocate=$pendingTtsLocateRequest locateReason=$pendingTtsLocateReason detached=$isDetachedFromVerticalTts suppressDetach=$suppressNextVerticalTtsDetach " + + "chunkOverride=$chunkTargetOverride pendingCfi=$pendingCfiPreview ttsCfi=$sourceCfiPreview offset=${ttsState.startOffsetInSource}" + } + + fun logTtsChapterDiag(message: String) { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("$message | ${buildTtsDiagState()}") + } + + fun currentTtsChunkKey(): String? { + val cfi = ttsState.sourceCfi?.takeIf { it.isNotBlank() } ?: return null + val offset = ttsState.startOffsetInSource.takeIf { it >= 0 } ?: return null + return "$cfi@$offset" + } + + fun queuePendingTtsLocate(reason: String) { + pendingTtsLocateReason = reason + pendingTtsLocateRequest = true + } + + fun detachVerticalReaderFromTts(reason: String) { + logTtsChapterDiag("Detaching vertical reader from active TTS chapter. reason=$reason") + isDetachedFromVerticalTts = true + detachedVerticalTtsChunkKey = currentTtsChunkKey() + pendingTtsLocateRequest = false + pendingTtsLocateReason = null + isNavigatingToPosition = false + suppressNextVerticalTtsDetach = false + } + + fun clearPendingTtsRelocationState(reason: String) { + logTtsChapterDiag("Clearing pending TTS relocation state. reason=$reason") + pendingTtsLocateRequest = false + pendingTtsLocateReason = null + chunkTargetOverride = null + cfiToLoad = null + fragmentToLoad = null + isNavigatingToPosition = false + suppressNextVerticalTtsDetach = false + } + + suspend fun saveResolvedLocatorPosition(locator: Locator, cfiForWebView: String?) { + lastKnownLocator = locator + + val chapterLengthChars = chapters.getOrNull(locator.chapterIndex)?.plainTextContent?.length?.toLong() ?: 0L + val exactOffset = locatorConverter.getTextOffset(epubBook, locator)?.coerceAtLeast(0) ?: 0 + val boundedOffset = exactOffset.coerceAtMost(chapterLengthChars.toInt()).toLong() + + val progress = if (totalBookLengthChars > 0) { + val completedCharsInPreviousChapters = + chapters.take(locator.chapterIndex).sumOf { it.plainTextContent.length.toLong() } + val totalCharsScrolled = completedCharsInPreviousChapters + boundedOffset + val calculatedProgress = + ((totalCharsScrolled.toDouble() / totalBookLengthChars.toDouble()) * 100.0).toFloat() + val isAtEndOfBook = locator.chapterIndex == chapters.lastIndex && chapterLengthChars > 0 && boundedOffset >= chapterLengthChars + if (isAtEndOfBook) 100f else calculatedProgress + } else { + 0f + } + + Timber.tag("TTS_LOCATE") + .d("Saving locator from TTS. chapter=${locator.chapterIndex}, block=${locator.blockIndex}, progress=$progress") + onSavePosition(locator, cfiForWebView, progress) + } + + fun ensureVerticalChunksLoaded(targetChunk: Int) { + if (targetChunk >= loadedChunkCount) { + val chunksToInject = loadedChunkCount..targetChunk + chunksToInject.forEach { idx -> + val content = chapterChunks.getOrNull(idx) ?: return@forEach + webViewRefForTts?.evaluateJavascript( + "javascript:window.virtualization.appendChunk($idx, '${escapeJsString(content)}');", + null + ) + } + loadUpToChunkIndex = targetChunk + loadedChunkCount = max(loadedChunkCount, targetChunk + 1) + } else { + chapterChunks.getOrNull(targetChunk)?.let { content -> + webViewRefForTts?.evaluateJavascript( + "javascript:window.virtualization.appendChunk($targetChunk, '${escapeJsString(content)}');", + null + ) + } + } + } + + suspend fun saveActiveTtsPosition(reason: String): Boolean { + if (!isActiveReaderTtsForCurrentBook()) return false + + val chapterIndex = getActiveTtsChapterIndex() ?: return false + val sourceCfi = (ttsState.currentWordSourceCfi ?: ttsState.sourceCfi)?.takeIf { it.isNotBlank() } ?: return false + val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, sourceCfi) ?: return false + + logTtsChapterDiag("Persisting active TTS position. reason=$reason chapter=$chapterIndex cfi=${sourceCfi.take(48)}") + saveResolvedLocatorPosition(locator, sourceCfi) + return true + } + + suspend fun navigateToActiveTtsPosition(reason: String): Boolean { + if (!isActiveReaderTtsForCurrentBook()) { + logTtsChapterDiag("navigateToActiveTtsPosition aborted: inactive reader TTS. reason=$reason") + return false + } + + val chapterIndex = getActiveTtsChapterIndex() ?: run { + logTtsChapterDiag("navigateToActiveTtsPosition aborted: no active TTS chapter. reason=$reason") + return false + } + val sourceCfi = (ttsState.currentWordSourceCfi ?: ttsState.sourceCfi)?.takeIf { it.isNotBlank() } ?: run { + logTtsChapterDiag("navigateToActiveTtsPosition aborted: no active source CFI. reason=$reason") + return false + } + val sourceOffset = + ttsState.currentWordStartOffset.takeIf { it >= 0 } + ?: ttsState.startOffsetInSource.takeIf { it >= 0 } + val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, sourceCfi) ?: run { + logTtsChapterDiag("navigateToActiveTtsPosition aborted: locator conversion failed. reason=$reason chapter=$chapterIndex cfi=${sourceCfi.take(48)}") + return false + } + val targetChunk = max(0, locator.blockIndex / 20) + + saveResolvedLocatorPosition(locator, sourceCfi) + logTtsChapterDiag("Navigating to active TTS position. reason=$reason targetChapter=$chapterIndex targetChunk=$targetChunk sourceOffset=$sourceOffset") + + when (currentRenderMode) { + RenderMode.VERTICAL_SCROLL -> { + isNavigatingToPosition = true + initialScrollTargetForChapter = null + isDetachedFromVerticalTts = false + detachedVerticalTtsChunkKey = null + suppressNextVerticalTtsDetach = true + + if (chapterIndex != currentChapterIndex) { + logTtsChapterDiag("Vertical locate switching chapters. reason=$reason from=$currentChapterIndex to=$chapterIndex targetChunk=$targetChunk") + chunkTargetOverride = targetChunk + cfiToLoad = sourceCfi + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex = chapterIndex + } else { + if (webViewRefForTts == null) { + logTtsChapterDiag("Vertical locate queued because WebView is null. reason=$reason targetChunk=$targetChunk") + chunkTargetOverride = targetChunk + cfiToLoad = sourceCfi + } else { + logTtsChapterDiag("Vertical locate in current chapter. reason=$reason targetChunk=$targetChunk usingHighlight=${ttsState.currentText?.isNotBlank() == true}") + ensureVerticalChunksLoaded(targetChunk) + val chunkText = ttsState.currentText?.takeIf { it.isNotBlank() } + val chunkStartOffset = ttsState.startOffsetInSource.takeIf { it >= 0 } + if (chunkText != null && chunkStartOffset != null) { + webViewRefForTts?.evaluateJavascript( + "javascript:window.highlightFromCfi('${escapeJsString(sourceCfi)}', '${escapeJsString(chunkText)}', $chunkStartOffset);", + null + ) + } else { + webViewRefForTts?.evaluateJavascript( + "javascript:window.scrollToCfi('${escapeJsString(sourceCfi)}');", + null + ) + } + scope.launch { + delay(3000L) + if (isNavigatingToPosition) { + isNavigatingToPosition = false + } + } + } + } + return true + } + + RenderMode.PAGINATED -> { + if (!isPagerInitialized) { + logTtsChapterDiag("Paginated locate aborted: pager not initialized. reason=$reason") + return false + } + val bookPaginator = paginator as? BookPaginator ?: run { + logTtsChapterDiag("Paginated locate aborted: paginator unavailable. reason=$reason") + return false + } + val pageIndex = + sourceOffset?.let { bookPaginator.findPageForCfiAndOffset(chapterIndex, sourceCfi, it) } + ?: bookPaginator.findPageForLocator(locator) + ?: bookPaginator.chapterStartPageIndices[chapterIndex] ?: run { + logTtsChapterDiag("Paginated locate aborted: page lookup failed. reason=$reason chapter=$chapterIndex") + return false + } + + logTtsChapterDiag("Paginated locate scrolling to page=$pageIndex. reason=$reason") + isNavigatingToPosition = true + paginatedPagerState.scrollToPage(pageIndex) + isNavigatingToPosition = false + return true + } + } + } + val onHighlightColorChange: (UserHighlight, HighlightColor) -> Unit = { targetHighlight, newColor -> val index = userHighlights.indexOfFirst { it.cfi == targetHighlight.cfi } if (index != -1) { @@ -1168,6 +1440,7 @@ fun EpubReaderHost( bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, + chapterIndex = chapterIndex, ttsMode = currentTtsMode, playbackSource = "READER", authToken = token @@ -1230,6 +1503,7 @@ fun EpubReaderHost( bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, + chapterIndex = chapterIndex, ttsMode = currentTtsMode, playbackSource = "READER", authToken = token @@ -1269,6 +1543,8 @@ fun EpubReaderHost( ttsChapterIndex = ttsChapterIndex, onTtsChapterIndexChange = { newIndex -> ttsChapterIndex = newIndex }, onNavigateToChapter = { nextIndex -> + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=TTS_CHAPTER_CHANGE, from=$currentChapterIndex, to=$nextIndex") Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("TtsSessionObserver triggered onNavigateToChapter to: $nextIndex") initialScrollTargetForChapter = ChapterScrollPosition.START cfiToLoad = null @@ -1291,6 +1567,7 @@ fun EpubReaderHost( TtsHighlightHandler( ttsState = ttsState, currentRenderMode = currentRenderMode, + currentChapterIndex = currentChapterIndex, webViewRef = webViewRefForTts, paginator = paginator, pagerState = paginatedPagerState, @@ -1355,12 +1632,170 @@ fun EpubReaderHost( val latestChapterIndex by rememberUpdatedState(currentChapterIndex) + LaunchedEffect(ttsState.bookTitle, ttsState.chapterIndex, ttsState.sourceCfi, ttsState.playbackSource) { + if (!hasQueuedInitialTtsLocate && isActiveReaderTtsForCurrentBook()) { + logTtsChapterDiag("Queueing initial TTS locate from active session restoration") + queuePendingTtsLocate(TTS_LOCATE_REASON_INITIAL_RESTORE) + hasQueuedInitialTtsLocate = true + } + } + + LaunchedEffect( + pendingTtsLocateRequest, + pendingTtsLocateReason, + currentRenderMode, + webViewRefForTts, + paginator, + isPagerInitialized, + ttsState.bookTitle, + ttsState.chapterIndex, + ttsChapterIndex, + ttsState.sourceCfi, + loadedChunkCount, + chapterChunks.size, + isDetachedFromVerticalTts + ) { + if (!pendingTtsLocateRequest) return@LaunchedEffect + if (!isActiveReaderTtsForCurrentBook()) { + logTtsChapterDiag("Dropping pending TTS locate because session is no longer active for this book") + pendingTtsLocateRequest = false + pendingTtsLocateReason = null + return@LaunchedEffect + } + + if ( + currentRenderMode == RenderMode.VERTICAL_SCROLL && + isDetachedFromVerticalTts && + pendingTtsLocateReason != TTS_LOCATE_REASON_OVERLAY + ) { + logTtsChapterDiag("Dropping automatic TTS locate because the vertical reader is intentionally detached") + pendingTtsLocateRequest = false + pendingTtsLocateReason = null + return@LaunchedEffect + } + + logTtsChapterDiag("Processing pending TTS locate request") + if (navigateToActiveTtsPosition("pending_request")) { + logTtsChapterDiag("Pending TTS locate request completed successfully") + pendingTtsLocateRequest = false + pendingTtsLocateReason = null + } else { + logTtsChapterDiag("Pending TTS locate request did not navigate yet") + } + } + + LaunchedEffect( + currentRenderMode, + currentChapterIndex, + ttsState.playbackSource, + ttsState.chapterIndex, + ttsChapterIndex + ) { + if (currentRenderMode != RenderMode.VERTICAL_SCROLL) return@LaunchedEffect + if (!isActiveReaderTtsForCurrentBook()) { + logTtsChapterDiag("Vertical detach effect resetting because active reader TTS is unavailable") + isDetachedFromVerticalTts = false + detachedVerticalTtsChunkKey = null + suppressNextVerticalTtsDetach = false + return@LaunchedEffect + } + + val activeTtsChapterIndex = getActiveTtsChapterIndex() ?: return@LaunchedEffect + if (currentChapterIndex == activeTtsChapterIndex) { + logTtsChapterDiag("Vertical detach effect cleared because reader is back on the active TTS chapter") + suppressNextVerticalTtsDetach = false + isDetachedFromVerticalTts = false + detachedVerticalTtsChunkKey = null + return@LaunchedEffect + } + + if (suppressNextVerticalTtsDetach) { + val hasPendingProgrammaticNavigation = + isNavigatingToPosition || chunkTargetOverride != null || !cfiToLoad.isNullOrBlank() + if (hasPendingProgrammaticNavigation) { + logTtsChapterDiag("Vertical detach suppression consumed after programmatic TTS navigation") + suppressNextVerticalTtsDetach = false + return@LaunchedEffect + } + + logTtsChapterDiag("Ignoring stale vertical detach suppression and honoring manual chapter movement") + suppressNextVerticalTtsDetach = false + } + + if (!isDetachedFromVerticalTts) { + detachVerticalReaderFromTts("chapter_mismatch") + } + } + + LaunchedEffect( + currentRenderMode, + isDetachedFromVerticalTts, + ttsState.sourceCfi, + ttsState.startOffsetInSource, + ttsState.chapterIndex, + ttsChapterIndex + ) { + if (currentRenderMode != RenderMode.VERTICAL_SCROLL) return@LaunchedEffect + if (!isDetachedFromVerticalTts) return@LaunchedEffect + if (!isActiveReaderTtsForCurrentBook()) return@LaunchedEffect + + val currentChunkKey = currentTtsChunkKey() ?: return@LaunchedEffect + val detachedChunkKey = detachedVerticalTtsChunkKey + + if (detachedChunkKey == null) { + logTtsChapterDiag("Detached vertical reader recorded first observed TTS chunk key") + detachedVerticalTtsChunkKey = currentChunkKey + return@LaunchedEffect + } + + if (currentChunkKey == detachedChunkKey) { + logTtsChapterDiag("Detached vertical reader waiting for next TTS chunk boundary before rejoining") + return@LaunchedEffect + } + + logTtsChapterDiag("Detached vertical reader detected next TTS chunk boundary and will try to rejoin") + if (navigateToActiveTtsPosition("chunk_follow")) { + logTtsChapterDiag("Detached vertical reader rejoined active TTS chapter successfully") + isDetachedFromVerticalTts = false + detachedVerticalTtsChunkKey = null + } else { + logTtsChapterDiag("Detached vertical reader failed to rejoin on this chunk boundary") + } + } + val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, webViewRefForTts) { + val latestWebViewRefForTts by rememberUpdatedState(webViewRefForTts) + val latestIsActiveReaderTtsForCurrentBook by rememberUpdatedState(isActiveReaderTtsForCurrentBook()) + val latestSaveActiveTtsPosition by rememberUpdatedState Boolean>({ reason -> + saveActiveTtsPosition(reason) + }) + val latestIsDetachedFromVerticalTts by rememberUpdatedState(isDetachedFromVerticalTts) + val latestCurrentRenderMode by rememberUpdatedState(currentRenderMode) + val latestQueueLifecycleTtsLocate by rememberUpdatedState({ + if (latestCurrentRenderMode == RenderMode.VERTICAL_SCROLL && latestIsDetachedFromVerticalTts) { + logTtsChapterDiag("Lifecycle resume skipped automatic TTS locate because the vertical reader is detached") + } else { + logTtsChapterDiag("Lifecycle resume queued a TTS locate request") + queuePendingTtsLocate(TTS_LOCATE_REASON_LIFECYCLE_RESUME) + } + }) + + DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE) { - Timber.d("ON_PAUSE detected. Requesting final CFI for robust save.") - webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) + if (latestIsActiveReaderTtsForCurrentBook) { + scope.launch { + if (!latestSaveActiveTtsPosition("lifecycle_pause")) { + Timber.d("ON_PAUSE detected. Falling back to WebView CFI save.") + latestWebViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) + } + } + } else { + Timber.d("ON_PAUSE detected. Requesting final CFI for robust save.") + latestWebViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) + } + } else if (event == Lifecycle.Event.ON_RESUME && latestIsActiveReaderTtsForCurrentBook) { + latestQueueLifecycleTtsLocate() } } @@ -1479,7 +1914,6 @@ fun EpubReaderHost( systemUiMode = systemUiMode ) - var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) } LaunchedEffect(paginator, currentRenderMode, isPagerInitialized) { Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: Checking paginator init. currentRenderMode=$currentRenderMode, paginator=${paginator != null}, isPagerInitialized=$isPagerInitialized") if (currentRenderMode == RenderMode.PAGINATED && paginator != null && !isPagerInitialized) { @@ -1785,18 +2219,84 @@ fun EpubReaderHost( if (targetChapterIndex != -1) { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + clearPendingTtsRelocationState("toc_entry_vertical") fragmentToLoad = entry.fragmentId if (targetChapterIndex != currentChapterIndex) { + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=TOC_ENTRY, from=$currentChapterIndex, to=$targetChapterIndex, fragment='${entry.fragmentId}', label='${entry.label}'") initialScrollTargetForChapter = null currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = targetChapterIndex + logTtsChapterDiag("Manual vertical chapter switch via TOC entry. targetChapter=$targetChapterIndex fragment=${entry.fragmentId}") } else { if (entry.fragmentId != null) { - webViewRefForTts?.evaluateJavascript( - "javascript:var el = document.getElementById('${entry.fragmentId}'); if(el) { el.scrollIntoView(); }", - null - ) + val js = """ + (function() { + var targetId = '${entry.fragmentId}'; + var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]'); + if (el) { + var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + return -2; + } + if (window.virtualization && window.virtualization.chunksData) { + for (var i = 0; i < window.virtualization.chunksData.length; i++) { + var chunkHtml = window.virtualization.chunksData[i]; + if (chunkHtml && (chunkHtml.indexOf('id="' + targetId + '"') !== -1 || chunkHtml.indexOf('name="' + targetId + '"') !== -1 || chunkHtml.indexOf("id='" + targetId + "'") !== -1 || chunkHtml.indexOf("name='" + targetId + "'") !== -1)) { + return i; + } + } + } + return -1; + })() + """.trimIndent() + webViewRefForTts?.evaluateJavascript(js) { result -> + val chunkIdx = result?.toIntOrNull() ?: -1 + if (chunkIdx >= 0) { + if (chunkIdx >= loadedChunkCount) { + val chunksToInject = (loadedChunkCount..chunkIdx) + chunksToInject.forEach { idx -> + val content = chapterChunks.getOrNull(idx) + if (content != null) { + val escaped = escapeJsString(content) + webViewRefForTts?.evaluateJavascript( + "javascript:window.virtualization.appendChunk($idx, '$escaped');", + null + ) + } + } + loadUpToChunkIndex = chunkIdx + loadedChunkCount = max(loadedChunkCount, chunkIdx + 1) + } + val scrollJs = """ + (function() { + var chunkIndex = $chunkIdx; + var fragmentId = '${entry.fragmentId}'; + var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]'); + if (chunkDiv) { + if (chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { + chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex]; + chunkDiv.style.height = ""; + } + setTimeout(function() { + var el = document.getElementById(fragmentId) || document.querySelector('[name="' + fragmentId + '"]'); + if (el) { + var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + } else { + var targetScrollY = window.scrollY + chunkDiv.getBoundingClientRect().top - window.VIEWPORT_PADDING_TOP; + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + } + }, 150); + } + })() + """.trimIndent() + webViewRefForTts?.evaluateJavascript(scrollJs, null) + } else if (chunkIdx == -1) { + webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) + } + } } else { webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) } @@ -1810,6 +2310,8 @@ fun EpubReaderHost( bookPaginator.findPageForAnchor(targetChapterIndex, entry.fragmentId) { targetPage -> scope.launch { + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=TOC_ENTRY_PAGINATED, from=$currentChapterIndex, to=$targetChapterIndex, page=$targetPage, anchor='${entry.fragmentId}', label='${entry.label}'") Timber.tag("TOC_NAV_DEBUG").d("Scrolling Pager to page: $targetPage") paginatedPagerState.scrollToPage(targetPage) isNavigatingByToc = false @@ -1832,10 +2334,14 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { if (index != currentChapterIndex) { + clearPendingTtsRelocationState("sidebar_chapter_vertical") + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=SIDEBAR_CHAPTER, from=$currentChapterIndex, to=$index") initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = index + logTtsChapterDiag("Manual vertical chapter switch via sidebar. targetChapter=$index") pullToNextProgress = 0f pullToPrevProgress = 0f if (showBars) showBars = false @@ -1848,6 +2354,8 @@ fun EpubReaderHost( if (index != currentFromPager) { val targetPage = bookPaginator.chapterStartPageIndices[index] if (targetPage != null) { + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=SIDEBAR_CHAPTER_PAGINATED, from=$currentFromPager, to=$index, page=$targetPage") paginatedPagerState.scrollToPage(targetPage) if (showBars) showBars = false } @@ -1870,6 +2378,8 @@ fun EpubReaderHost( val targetChunk = locator?.let { it.blockIndex / 20 } if (bookmark.chapterIndex != currentChapterIndex) { + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=BOOKMARK, from=$currentChapterIndex, to=${bookmark.chapterIndex}, cfi='${bookmark.cfi}', label='${bookmark.label}'") chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) { targetChunk } else { @@ -1979,6 +2489,8 @@ fun EpubReaderHost( val targetChunk = locator?.let { it.blockIndex / 20 } if (highlight.chapterIndex != currentChapterIndex) { + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=HIGHLIGHT, from=$currentChapterIndex, to=${highlight.chapterIndex}, cfi='${highlight.cfi}'") chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) targetChunk else 0 currentScrollYPosition = 0 currentScrollHeightValue = 0 @@ -2329,10 +2841,15 @@ fun EpubReaderHost( }, onNavigateChapter = { offset, target -> scope.launch { + clearPendingTtsRelocationState("manual_chapter_change") initialScrollTargetForChapter = target currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex += offset + logTtsChapterDiag( + "Manual vertical chapter switch via volume/button nav. " + + "offset=$offset target=$target newChapter=$currentChapterIndex" + ) } }, onNextPage = { @@ -2362,19 +2879,13 @@ fun EpubReaderHost( ) { when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { - val contentBottomPadding = if (showBars || showFormatAdjustmentBars) { - 0.dp - } else { - if (pageInfoMode == PageInfoMode.DEFAULT) PAGE_INFO_BAR_HEIGHT else 0.dp - } - - val horizontalPadding = if (removeEdgePadding) 0.dp else 16.dp + val contentBottomPadding = if (pageInfoMode != PageInfoMode.HIDDEN) PAGE_INFO_BAR_HEIGHT else 0.dp Box( modifier = Modifier .fillMaxSize() .padding(bottom = contentBottomPadding) - .padding(top = 16.dp, start = horizontalPadding, end = horizontalPadding) + .padding(top = 16.dp) .testTag("ReaderContainer") ) { if (chapters.isEmpty()) { @@ -2554,6 +3065,7 @@ fun EpubReaderHost( onChapterInitiallyScrolled = { val wasCfiScroll = cfiToLoad != null Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll") + logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll") initialScrollTargetForChapter = null cfiToLoad = null fragmentToLoad = null @@ -2573,6 +3085,7 @@ fun EpubReaderHost( if (ttsShouldStartOnChapterLoad && !hasRequestedExtractionForThisChapter) { Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).") + logTtsChapterDiag("Auto-starting TTS extraction for chapter load") hasRequestedExtractionForThisChapter = true scope.launch { delay(200) @@ -2630,11 +3143,15 @@ fun EpubReaderHost( scope.launch { if (currentChapterIndex < chapters.size - 1) { + clearPendingTtsRelocationState("auto_scroll_chapter_end") + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=AUTO_SCROLL_END, from=$currentChapterIndex, to=${currentChapterIndex + 1}") Timber.d("Screen: Moving to next chapter (${currentChapterIndex + 1}).") initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex++ + logTtsChapterDiag("Auto-scroll moved vertical reader to next chapter. newChapter=$currentChapterIndex") isAutoScrollPlaying = true } else { Timber.d("Screen: Reached end of book. Stopping auto-scroll.") @@ -2652,11 +3169,15 @@ fun EpubReaderHost( isSeamlessTransitioning = true webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) scope.launch { + clearPendingTtsRelocationState("overscroll_top_seamless") delay(20) initialScrollTargetForChapter = ChapterScrollPosition.END currentScrollYPosition = 0 currentScrollHeightValue = 0 + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=OVERSCROLL_TOP_SEAMLESS, from=$targetChapterIndex, to=${targetChapterIndex - 1}") currentChapterIndex-- + logTtsChapterDiag("Seamless overscroll moved to previous chapter. newChapter=$currentChapterIndex") if (showBars) showBars = false delay(300) isSeamlessTransitioning = false @@ -2674,11 +3195,15 @@ fun EpubReaderHost( isSeamlessTransitioning = true webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) scope.launch { + clearPendingTtsRelocationState("overscroll_bottom_seamless") delay(20) initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=OVERSCROLL_BOTTOM_SEAMLESS, from=$targetChapterIndex, to=${targetChapterIndex + 1}") currentChapterIndex++ + logTtsChapterDiag("Seamless overscroll moved to next chapter. newChapter=$currentChapterIndex") if (showBars) showBars = false delay(300) isSeamlessTransitioning = false @@ -2695,11 +3220,18 @@ fun EpubReaderHost( null ) scope.launch { + clearPendingTtsRelocationState("pull_to_turn_prev") + if (isActiveReaderTtsForCurrentBook()) { + detachVerticalReaderFromTts("pull_to_turn_prev") + } delay(50) initialScrollTargetForChapter = ChapterScrollPosition.END currentScrollYPosition = 0 currentScrollHeightValue = 0 + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=PULL_TO_TURN_PREV, from=$targetChapterIndex, to=${targetChapterIndex - 1}") currentChapterIndex-- + logTtsChapterDiag("Pull-to-turn moved to previous chapter. newChapter=$currentChapterIndex") if (showBars) showBars = false Timber.d("Changed to previous chapter: $currentChapterIndex, will scroll to END") } @@ -2715,11 +3247,18 @@ fun EpubReaderHost( null ) scope.launch { + clearPendingTtsRelocationState("pull_to_turn_next") + if (isActiveReaderTtsForCurrentBook()) { + detachVerticalReaderFromTts("pull_to_turn_next") + } delay(50) initialScrollTargetForChapter = ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=PULL_TO_TURN_NEXT, from=$targetChapterIndex, to=${targetChapterIndex + 1}") currentChapterIndex++ + logTtsChapterDiag("Pull-to-turn moved to next chapter. newChapter=$currentChapterIndex") if (showBars) showBars = false } } @@ -2751,6 +3290,8 @@ fun EpubReaderHost( currentFontSize = currentFontSizeEm, currentLineHeight = currentLineHeight, currentParagraphGap = currentParagraphGap, + currentImageSize = currentImageSize, + currentHorizontalMargin = currentHorizontalMargin, currentFontFamily = currentFontFamily, customFontPath = currentCustomFontPath, currentTextAlign = currentTextAlign, @@ -2761,6 +3302,111 @@ fun EpubReaderHost( showFormatAdjustmentBars = false Timber.d("Highlight clicked - Forcing bars hidden") }, + onInternalLinkClick = { url -> + scope.launch { + val basePath = "file://${epubBook.extractionBasePath}/" + val relativeUrl = url.removePrefix(basePath) + val pathPart = relativeUrl.substringBefore('#') + val fragmentPart = relativeUrl.substringAfter('#', "").takeIf { it.isNotEmpty() } + + val decodedPath = try { java.net.URLDecoder.decode(pathPart, "UTF-8") } catch(e: Exception) { pathPart } + val targetChapterIndex = chapters.indexOfFirst { it.absPath == decodedPath } + + Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> url: $url") + Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> basePath: $basePath") + Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> relativeUrl: $relativeUrl") + Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> pathPart: $pathPart") + Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> decodedPath: $decodedPath") + Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> fragmentPart: $fragmentPart") + Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> targetChapterIndex: $targetChapterIndex (current is $currentChapterIndex)") + + if (targetChapterIndex != -1) { + if (targetChapterIndex != currentChapterIndex) { + Timber.tag(TAG_LINK_NAV).d("[CHAPTER-NAV] source=INTERNAL_LINK, from=$currentChapterIndex, to=$targetChapterIndex, fragment='$fragmentPart'") + initialScrollTargetForChapter = null + fragmentToLoad = fragmentPart + currentScrollYPosition = 0 + currentScrollHeightValue = 0 + currentChapterIndex = targetChapterIndex + } else { + Timber.tag(TAG_LINK_NAV).d("InternalLinkClick -> Target is current chapter. Evaluating JS for fragment.") + if (fragmentPart != null) { + val js = """ + (function() { + var targetId = '$fragmentPart'; + var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]'); + if (el) { + var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + return -2; + } + if (window.virtualization && window.virtualization.chunksData) { + for (var i = 0; i < window.virtualization.chunksData.length; i++) { + var chunkHtml = window.virtualization.chunksData[i]; + if (chunkHtml && (chunkHtml.indexOf('id="' + targetId + '"') !== -1 || chunkHtml.indexOf('name="' + targetId + '"') !== -1 || chunkHtml.indexOf("id='" + targetId + "'") !== -1 || chunkHtml.indexOf("name='" + targetId + "'") !== -1)) { + return i; + } + } + } + return -1; + })() + """.trimIndent() + webViewRefForTts?.evaluateJavascript(js) { result -> + val chunkIdx = result?.toIntOrNull() ?: -1 + if (chunkIdx >= 0) { + if (chunkIdx >= loadedChunkCount) { + val chunksToInject = (loadedChunkCount..chunkIdx) + chunksToInject.forEach { idx -> + val content = chapterChunks.getOrNull(idx) + if (content != null) { + val escaped = escapeJsString(content) + webViewRefForTts?.evaluateJavascript( + "javascript:window.virtualization.appendChunk($idx, '$escaped');", + null + ) + } + } + loadUpToChunkIndex = chunkIdx + loadedChunkCount = max(loadedChunkCount, chunkIdx + 1) + } + val scrollJs = """ + (function() { + var chunkIndex = $chunkIdx; + var fragmentId = '$fragmentPart'; + var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]'); + if (chunkDiv) { + if (chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { + chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex]; + chunkDiv.style.height = ""; + } + setTimeout(function() { + var el = document.getElementById(fragmentId) || document.querySelector('[name="' + fragmentId + '"]'); + if (el) { + var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10); + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + } else { + var targetScrollY = window.scrollY + chunkDiv.getBoundingClientRect().top - window.VIEWPORT_PADDING_TOP; + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + } + }, 150); + } + })() + """.trimIndent() + webViewRefForTts?.evaluateJavascript(scrollJs, null) + } else if (chunkIdx == -1) { + webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) + } + } + } else { + webViewRefForTts?.evaluateJavascript("javascript:window.scrollTo(0,0);", null) + } + } + if (showBars) showBars = false + } else { + Timber.tag(TAG_LINK_NAV).w("Could not find chapter for internal link: $url") + } + } + }, onWebViewInstanceCreated = { webView -> webViewRefForTts = webView webView.evaluateJavascript( @@ -2809,8 +3455,13 @@ fun EpubReaderHost( } Timber.d("Vertical: Final compiled TTS chunks size: ${ttsChunks.size}") + logTtsChapterDiag( + "Vertical TTS text ready. targetChapter=$targetChapterIndex " + + "chunkCount=${ttsChunks.size} visibleChapter=$currentChapterIndex" + ) if (ttsChunks.isNotEmpty()) { + logTtsChapterDiag("Vertical TTS extraction produced ${ttsChunks.size} chunks for chapter $targetChapterIndex") if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { showInsufficientCreditsDialog = true ttsShouldStartOnChapterLoad = false @@ -2831,17 +3482,22 @@ fun EpubReaderHost( bookTitle = epubBook.title, chapterTitle = chapterTitle, coverImageUri = coverUriString, + chapterIndex = targetChapterIndex, ttsMode = currentTtsMode, playbackSource = "READER", authToken = token ) } else { Timber.w("No TTS chunks were created from JSON, not starting TTS.") + logTtsChapterDiag("Vertical TTS extraction produced 0 chunks for chapter $targetChapterIndex") if (ttsShouldStartOnChapterLoad) { Timber.d("Empty chapter detected during start. Advancing UI to next chapter.") val nextIdx = targetChapterIndex + 1 if (nextIdx < chapters.size) { - initialScrollTargetForChapter = ChapterScrollPosition.START + Timber.tag(TAG_LINK_NAV) + .d("[CHAPTER-NAV] source=TTS_EMPTY_CHAPTER_SKIP, from=$targetChapterIndex, to=$nextIdx") + initialScrollTargetForChapter = + ChapterScrollPosition.START currentScrollYPosition = 0 currentScrollHeightValue = 0 currentChapterIndex = nextIdx @@ -3141,6 +3797,8 @@ fun EpubReaderHost( fontSizeMultiplier = currentFontSizeEm, lineHeightMultiplier = currentLineHeight, paragraphGapMultiplier = currentParagraphGap, + imageSizeMultiplier = currentImageSize, + horizontalMarginMultiplier = currentHorizontalMargin, fontFamily = activeFontFamily, textAlign = currentTextAlign, activeHighlightPalette = currentHighlightPalette, @@ -3152,7 +3810,6 @@ fun EpubReaderHost( offset = ttsState.startOffsetInSource ).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 }, activeTextureId = activeTextureId, - removeEdgePadding = removeEdgePadding, initialChapterIndexInBook = lastKnownLocator?.chapterIndex, modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f), onPaginatorReady = { newPaginator -> @@ -3231,7 +3888,10 @@ fun EpubReaderHost( onStartTtsFromSelection = { cfi, offset -> startTtsFromSelectionPaginated(cfi, offset) }, - userHighlights = userHighlights.filter { it.chapterIndex == (currentChapterInPaginatedMode ?: -1) }, + userHighlights = userHighlights.filter { highlight -> + val currentChapter = currentChapterInPaginatedMode ?: return@filter false + highlight.chapterIndex in (currentChapter - 1)..(currentChapter + 1) + }, onHighlightCreated = { cfi, text, colorId -> Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi") val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW @@ -3910,6 +4570,10 @@ fun EpubReaderHost( currentTtsMode = currentTtsMode, isCollapsed = isTtsCollapsed, onCollapseChange = { isTtsCollapsed = it }, + onLocateCurrentChunk = { + logTtsChapterDiag("Locate current chunk requested from TTS overlay") + queuePendingTtsLocate(TTS_LOCATE_REASON_OVERLAY) + }, onOpenTtsSettings = { showTtsSettingsSheet = true }, onClose = { userStoppedTts = true @@ -4102,6 +4766,10 @@ fun EpubReaderHost( onLineHeightChange = { currentLineHeight = it }, currentParagraphGap = currentParagraphGap, onParagraphGapChange = { currentParagraphGap = it }, + currentImageSize = currentImageSize, + onImageSizeChange = { currentImageSize = it }, + currentHorizontalMargin = currentHorizontalMargin, + onHorizontalMarginChange = { currentHorizontalMargin = it }, currentFont = currentFontFamily, currentCustomFontName = if(currentCustomFontPath != null) { customFonts.find { it.path == currentCustomFontPath }?.displayName ?: "Custom Font" @@ -4118,6 +4786,8 @@ fun EpubReaderHost( currentFontSizeEm = DEFAULT_FONT_SIZE_VAL currentLineHeight = DEFAULT_LINE_HEIGHT_VAL currentParagraphGap = DEFAULT_PARAGRAPH_GAP_VAL + currentImageSize = DEFAULT_IMAGE_SIZE_VAL + currentHorizontalMargin = DEFAULT_HORIZONTAL_MARGIN_VAL currentFontFamily = ReaderFont.ORIGINAL currentCustomFontPath = null currentTextAlign = ReaderTextAlign.DEFAULT @@ -4127,11 +4797,7 @@ fun EpubReaderHost( isFormatLocal = it saveFormatIsLocal(context, bookId, it) }, - onClose = { showFormatAdjustmentBars = false }, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = bottomPadding + 16.dp) - .padding(horizontal = 16.dp) + onClose = { showFormatAdjustmentBars = false } ) val effectiveCurrentChapterIndex = if (currentRenderMode == RenderMode.PAGINATED) { @@ -4195,7 +4861,7 @@ fun EpubReaderHost( onClearRecap = { recapResult = null } ) - if (isNavigatingToPosition) { + if (isNavigatingToPosition && currentRenderMode == RenderMode.PAGINATED) { Box( modifier = Modifier .fillMaxSize() @@ -4472,11 +5138,6 @@ fun EpubReaderHost( pullToTurnEnabled = it savePullToTurn(context, it) }, - removeEdgePadding = removeEdgePadding, - onRemoveEdgePaddingChange = { - removeEdgePadding = it - saveRemoveEdgePadding(context, it) - }, pullToTurnMultiplier = pullToTurnMultiplier, onPullToTurnMultiplierChange = { pullToTurnMultiplier = it @@ -4556,4 +5217,4 @@ fun EpubReaderHost( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt index 7ca4ecf..c724d33 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderSettings.kt @@ -24,17 +24,7 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.BorderStroke -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -42,69 +32,88 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Slider import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.edit import com.aryan.reader.R import com.aryan.reader.data.CustomFontEntity import java.io.File -import androidx.compose.material3.Switch -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource +import kotlin.math.roundToInt const val SETTINGS_PREFS_NAME = "epub_reader_settings" private const val TEXT_ALIGN_KEY = "reader_text_align" private const val FONT_SIZE_KEY = "reader_font_size" private const val LINE_HEIGHT_KEY = "reader_line_height" private const val PARAGRAPH_GAP_KEY = "reader_paragraph_gap" +private const val IMAGE_SIZE_KEY = "reader_image_size" private const val AUTO_SCROLL_SPEED_KEY = "reader_auto_scroll_speed" private const val FONT_FAMILY_KEY = "reader_font_family" private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled" @@ -116,6 +125,8 @@ private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled" const val DEFAULT_FONT_SIZE_VAL = 1.0f const val DEFAULT_LINE_HEIGHT_VAL = 1.0f const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f +const val DEFAULT_IMAGE_SIZE_VAL = 1.0f +const val DEFAULT_HORIZONTAL_MARGIN_VAL = 1.0f private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate" private const val TTS_PITCH_KEY = "tts_pitch" @@ -170,6 +181,8 @@ data class FormatSettings( val fontSize: Float, val lineHeight: Float, val paragraphGap: Float, + val imageSize: Float, + val horizontalMargin: Float, val font: ReaderFont, val customPath: String?, val textAlign: ReaderTextAlign @@ -179,8 +192,11 @@ private const val FORMAT_IS_LOCAL_PREFIX = "format_is_local_" private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_" private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_" private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_" +private const val LOCAL_IMAGE_SIZE_PREFIX = "local_image_size_" +private const val LOCAL_HORIZONTAL_MARGIN_PREFIX = "local_horizontal_margin_" private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_" private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_" +private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin" fun loadFormatIsLocal(context: Context, bookId: String): Boolean { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) @@ -198,6 +214,8 @@ fun saveLocalReaderSettings( fontSize: Float, lineHeight: Float, paragraphGap: Float, + imageSize: Float, + horizontalMargin: Float, fontFamily: ReaderFont, customFontPath: String?, textAlign: ReaderTextAlign @@ -207,6 +225,8 @@ fun saveLocalReaderSettings( putFloat(LOCAL_FONT_SIZE_PREFIX + bookId, fontSize) putFloat(LOCAL_LINE_HEIGHT_PREFIX + bookId, lineHeight) putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap) + putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize) + putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin) if (customFontPath != null) { putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath") } else { @@ -260,6 +280,14 @@ fun loadPullToTurnMultiplier(context: Context): Float { return prefs.getFloat(PULL_TO_TURN_MULTIPLIER_KEY, 1.0f) } +fun loadHorizontalMargin(context: Context): Float { + val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + if (prefs.contains(HORIZONTAL_MARGIN_KEY)) { + return prefs.getFloat(HORIZONTAL_MARGIN_KEY, DEFAULT_HORIZONTAL_MARGIN_VAL) + } + return if (loadRemoveEdgePadding(context)) 0f else DEFAULT_HORIZONTAL_MARGIN_VAL +} + fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) @@ -281,6 +309,18 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form prefs.getFloat(PARAGRAPH_GAP_KEY, DEFAULT_PARAGRAPH_GAP_VAL) } + val imageSize = if (isLocal && prefs.contains(LOCAL_IMAGE_SIZE_PREFIX + bookId)) { + prefs.getFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, DEFAULT_IMAGE_SIZE_VAL) + } else { + prefs.getFloat(IMAGE_SIZE_KEY, DEFAULT_IMAGE_SIZE_VAL) + } + + val horizontalMargin = if (isLocal && prefs.contains(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId)) { + prefs.getFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, DEFAULT_HORIZONTAL_MARGIN_VAL) + } else { + loadHorizontalMargin(context) + } + val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) { prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id } else { @@ -300,7 +340,16 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form } val textAlign = ReaderTextAlign.entries.find { it.id == alignId } ?: ReaderTextAlign.DEFAULT - return FormatSettings(fontSize, lineHeight, paragraphGap, font, customPath, textAlign) + return FormatSettings( + fontSize = fontSize, + lineHeight = lineHeight, + paragraphGap = paragraphGap, + imageSize = imageSize, + horizontalMargin = horizontalMargin, + font = font, + customPath = customPath, + textAlign = textAlign + ) } fun getComposeFontFamily( @@ -339,6 +388,8 @@ fun saveReaderSettings( fontSize: Float, lineHeight: Float, paragraphGap: Float, + imageSize: Float, + horizontalMargin: Float, fontFamily: ReaderFont, customFontPath: String?, textAlign: ReaderTextAlign @@ -348,6 +399,8 @@ fun saveReaderSettings( putFloat(FONT_SIZE_KEY, fontSize) putFloat(LINE_HEIGHT_KEY, lineHeight) putFloat(PARAGRAPH_GAP_KEY, paragraphGap) + putFloat(IMAGE_SIZE_KEY, imageSize) + putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin) if (customFontPath != null) { putString(FONT_FAMILY_KEY, "custom|$customFontPath") } else { @@ -387,6 +440,7 @@ fun loadVolumeScrollSetting(context: Context): Boolean { return prefs.getBoolean(VOLUME_SCROLL_ENABLED_KEY, false) } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReaderTextFormatPanel( isVisible: Boolean, @@ -394,8 +448,12 @@ fun ReaderTextFormatPanel( onFontSizeChange: (Float) -> Unit, currentLineHeight: Float, onLineHeightChange: (Float) -> Unit, - currentParagraphGap: Float, // NEW - onParagraphGapChange: (Float) -> Unit, // NEW + currentParagraphGap: Float, + onParagraphGapChange: (Float) -> Unit, + currentImageSize: Float, + onImageSizeChange: (Float) -> Unit, + currentHorizontalMargin: Float, + onHorizontalMarginChange: (Float) -> Unit, currentFont: ReaderFont, currentCustomFontName: String?, onFontOptionClick: () -> Unit, @@ -404,27 +462,28 @@ fun ReaderTextFormatPanel( onReset: () -> Unit, isLocalMode: Boolean, onLocalModeToggle: (Boolean) -> Unit, - onClose: () -> Unit, - modifier: Modifier = Modifier + onClose: () -> Unit ) { - AnimatedVisibility( - visible = isVisible, - enter = slideInVertically { it } + fadeIn(), - exit = slideOutVertically { it } + fadeOut(), - modifier = modifier - ) { - Surface( - shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.98f), - tonalElevation = 0.dp, - shadowElevation = 8.dp, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)), - modifier = Modifier - .fillMaxWidth() - .animateContentSize() + if (isVisible) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onClose, + sheetState = sheetState, + scrimColor = Color.Transparent, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.9f), + contentWindowInsets = { WindowInsets.navigationBars } ) { + val configuration = LocalConfiguration.current + val maxSheetHeight = (configuration.screenHeightDp * 0.7f).dp + Column( - modifier = Modifier.padding(16.dp) + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxSheetHeight) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp) + .padding(bottom = 24.dp) ) { // Header Row (Local/Global + Close/Reset) Row( @@ -500,7 +559,7 @@ fun ReaderTextFormatPanel( modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) ) - // Font Button (Full width) + // Font Button Surface( onClick = onFontOptionClick, shape = RoundedCornerShape(12.dp), @@ -540,7 +599,7 @@ fun ReaderTextFormatPanel( Spacer(Modifier.height(8.dp)) - // Alignment Button (Full width Segmented) + // Alignment Button (Segmented) Surface( shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceVariant, @@ -586,49 +645,61 @@ fun ReaderTextFormatPanel( style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) + modifier = Modifier.padding(start = 4.dp, bottom = 12.dp) ) - // Sliders - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - // Size - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Text(stringResource(R.string.label_font_size), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp)) - Slider( - value = currentFontSize, - onValueChange = onFontSizeChange, - valueRange = 0.5f..3.0f, - steps = 24, - modifier = Modifier.weight(1f) - ) - Text(if (currentFontSize in 0.99f..1.01f) stringResource(R.string.label_original) else "%.1fx".format(currentFontSize), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End) - } - // Lines - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Text(stringResource(R.string.label_line_height), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp)) - Slider( - value = currentLineHeight, - onValueChange = onLineHeightChange, - valueRange = 1.0f..3.0f, - steps = 19, - modifier = Modifier.weight(1f) - ) - Text(if (currentLineHeight <= 1.01f) stringResource(R.string.label_original) else "%.1fx".format(currentLineHeight), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End) - } - // Paragraph Gap - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Text(stringResource(R.string.label_paragraph_gap), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp)) - Slider( - value = currentParagraphGap, - onValueChange = onParagraphGapChange, - valueRange = 0.0f..3.0f, - steps = 29, - modifier = Modifier.weight(1f) - ) - Text(if (currentParagraphGap in 0.99f..1.01f) stringResource(R.string.label_original) else "%.1fx".format(currentParagraphGap), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End) - } + // Resolve the string once outside the lambdas + val originalLabel = stringResource(R.string.label_original) + val noneLabel = stringResource(R.string.label_none) + + // Wide, smooth sliders without dots + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + FormatSlider( + label = stringResource(R.string.label_font_size), + value = currentFontSize, + onValueChange = onFontSizeChange, + valueRange = 0.5f..3.0f, + formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) } + ) + + FormatSlider( + label = stringResource(R.string.label_line_height), + value = currentLineHeight, + onValueChange = onLineHeightChange, + valueRange = 1.0f..3.0f, + formatValue = { if (it <= 1.01f) originalLabel else "%.1fx".format(it) } + ) + + FormatSlider( + label = stringResource(R.string.label_paragraph_gap), + value = currentParagraphGap, + onValueChange = onParagraphGapChange, + valueRange = 0.0f..3.0f, + formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) } + ) + + FormatSlider( + label = stringResource(R.string.label_image_size), + value = currentImageSize, + onValueChange = onImageSizeChange, + valueRange = 0.5f..2.0f, + formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) } + ) + + FormatSlider( + label = stringResource(R.string.label_horizontal_margin), + value = currentHorizontalMargin, + onValueChange = onHorizontalMarginChange, + valueRange = 0.0f..3.0f, + formatValue = { + when { + it <= 0.01f -> noneLabel + it in 0.99f..1.01f -> originalLabel + else -> "%.1fx".format(it) + } + } + ) } - Spacer(Modifier.height(8.dp)) } } } @@ -761,8 +832,6 @@ fun VisualOptionsSheet( onPageInfoModeChange: (PageInfoMode) -> Unit, pullToTurnEnabled: Boolean, onPullToTurnChange: (Boolean) -> Unit, - removeEdgePadding: Boolean, - onRemoveEdgePaddingChange: (Boolean) -> Unit, pullToTurnMultiplier: Float, onPullToTurnMultiplierChange: (Float) -> Unit, onDismiss: () -> Unit @@ -859,30 +928,6 @@ fun VisualOptionsSheet( } } } - - Spacer(modifier = Modifier.height(24.dp)) - - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - modifier = Modifier - .fillMaxWidth() - .clickable { onRemoveEdgePaddingChange(!removeEdgePadding) } - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.visual_options_edge_padding), style = MaterialTheme.typography.titleMedium) - Text(stringResource(R.string.visual_options_edge_padding_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Spacer(modifier = Modifier.width(16.dp)) - Switch(checked = removeEdgePadding, onCheckedChange = { onRemoveEdgePaddingChange(it) }) - } - } - Spacer(modifier = Modifier.height(32.dp)) } } @@ -922,4 +967,138 @@ fun OptionSegmentedControl( } } } -} \ No newline at end of file +} + +@Composable +fun CustomCanvasSlider( + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + modifier: Modifier = Modifier +) { + val fraction = ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)).coerceIn(0f, 1f) + val activeColor = MaterialTheme.colorScheme.primary + val inactiveColor = MaterialTheme.colorScheme.surfaceVariant + val thumbColor = MaterialTheme.colorScheme.primary + + Box( + modifier = modifier + .height(24.dp) // Keeps the touch target height slim + .pointerInput(valueRange) { + awaitEachGesture { + val down = awaitFirstDown() + fun update(offset: Offset) { + val newFraction = (offset.x / size.width.toFloat()).coerceIn(0f, 1f) + val rawValue = valueRange.start + newFraction * (valueRange.endInclusive - valueRange.start) + // Snap to 0.1 intervals for consistent formatting + onValueChange((rawValue * 10f).roundToInt() / 10f) + } + update(down.position) + drag(down.id) { change -> + change.consume() + update(change.position) + } + } + } + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val trackHeight = 4.dp.toPx() + val cornerRadius = CornerRadius(trackHeight / 2, trackHeight / 2) + val trackY = (size.height - trackHeight) / 2 + + // Draw Inactive Track + drawRoundRect( + color = inactiveColor, + topLeft = Offset(0f, trackY), + size = Size(size.width, trackHeight), + cornerRadius = cornerRadius + ) + + // Draw Active Track + val activeWidth = fraction * size.width + drawRoundRect( + color = activeColor, + topLeft = Offset(0f, trackY), + size = Size(activeWidth, trackHeight), + cornerRadius = cornerRadius + ) + + // Draw Thumb + val thumbRadius = 8.dp.toPx() + drawCircle( + color = thumbColor, + radius = thumbRadius, + center = Offset( + x = activeWidth.coerceIn(thumbRadius, size.width - thumbRadius), + y = size.height / 2 + ) + ) + } + } +} + +@Composable +fun FormatSlider( + label: String, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + stepSize: Float = 0.1f, + formatValue: (Float) -> String +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 4.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = formatValue(value), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + IconButton( + onClick = { + val newValue = (value - stepSize).coerceAtLeast(valueRange.start) + onValueChange((newValue * 10f).roundToInt() / 10f) + }, + modifier = Modifier.size(32.dp) // Slimmer buttons + ) { + Icon(Icons.Default.Remove, contentDescription = "Decrease", tint = MaterialTheme.colorScheme.primary) + } + + // Using our new CustomCanvasSlider here! + CustomCanvasSlider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + modifier = Modifier.weight(1f) + ) + + IconButton( + onClick = { + val newValue = (value + stepSize).coerceAtMost(valueRange.endInclusive) + onValueChange((newValue * 10f).roundToInt() / 10f) + }, + modifier = Modifier.size(32.dp) // Slimmer buttons + ) { + Icon(Icons.Default.Add, contentDescription = "Increase", tint = MaterialTheme.colorScheme.primary) + } + } + } +} diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt index 8540b61..0c5082d 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt @@ -213,6 +213,7 @@ fun TtsSessionObserver( fun TtsHighlightHandler( ttsState: TtsPlaybackManager.TtsState, currentRenderMode: RenderMode, + currentChapterIndex: Int, webViewRef: WebView?, paginator: IPaginator?, pagerState: PagerState, @@ -223,14 +224,42 @@ fun TtsHighlightHandler( val text = ttsState.currentText val cfi = ttsState.sourceCfi val offset = ttsState.startOffsetInSource + val activeTtsChapterIndex = ttsState.chapterIndex ?: ttsChapterIndex + + if ( + currentRenderMode == RenderMode.VERTICAL_SCROLL && + activeTtsChapterIndex != null && + activeTtsChapterIndex != currentChapterIndex + ) { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d( + "Vertical highlight skipped because visible chapter differs from active TTS chapter. " + + "visibleChapter=$currentChapterIndex activeTtsChapter=$activeTtsChapterIndex " + + "cfi=${cfi?.take(48)} offset=$offset" + ) + webViewRef?.evaluateJavascript("javascript:window.removeHighlight();", null) + return@LaunchedEffect + } if (!text.isNullOrBlank() && !cfi.isNullOrBlank() && offset != -1) { val escapedText = escapeJsString(text) val escapedCfi = escapeJsString(cfi) val jsCommand = "javascript:window.highlightFromCfi('$escapedCfi', '$escapedText', $offset);" + if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d( + "Applying vertical TTS highlight. visibleChapter=$currentChapterIndex " + + "activeTtsChapter=$activeTtsChapterIndex cfi=${cfi.take(48)} " + + "offset=$offset textLen=${text.length}" + ) + } webViewRef?.evaluateJavascript(jsCommand, null) } else { if (!ttsState.isPlaying && !ttsState.isLoading) { + if (currentRenderMode == RenderMode.VERTICAL_SCROLL) { + Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d( + "Removing vertical TTS highlight because playback is idle. " + + "visibleChapter=$currentChapterIndex activeTtsChapter=$activeTtsChapterIndex" + ) + } webViewRef?.evaluateJavascript("javascript:window.removeHighlight();", null) } } @@ -298,6 +327,7 @@ private fun handleVerticalAutoAdvance( bookTitle = epubBookTitle, chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title, coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() }, + chapterIndex = currentTtsChapterIndex, ttsMode = currentTtsMode, playbackSource = "READER", authToken = token @@ -327,6 +357,7 @@ private fun handleVerticalAutoAdvance( bookTitle = epubBookTitle, chapterTitle = chapters.getOrNull(nextIdx)?.title, coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() }, + chapterIndex = nextIdx, ttsMode = currentTtsMode, playbackSource = "READER", authToken = token @@ -401,6 +432,7 @@ private fun handlePaginatedAutoAdvance( bookTitle = epubBookTitle, chapterTitle = chapterTitle, coverImageUri = coverUriString, + chapterIndex = chapterToTry, ttsMode = ttsMode, playbackSource = "READER", authToken = token @@ -421,4 +453,4 @@ private fun handlePaginatedAutoAdvance( } else { onUpdateTtsChapter(null) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt b/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt index 0619933..1c07dae 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt @@ -96,7 +96,11 @@ object ExternalDictionaryHelper { putExtra(Intent.EXTRA_PROCESS_TEXT, query) putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true) setPackage(packageName) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + // Only add NEW_TASK if we don't have an Activity context, + // preventing task switch animations for NoDisplay apps like Notification Dictionary + if (context.getActivity() == null) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } } if (processTextIntent.resolveActivity(pm) != null) { @@ -148,7 +152,9 @@ object ExternalDictionaryHelper { putExtra(Intent.EXTRA_PROCESS_TEXT, query) putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true) setPackage(packageName) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (context.getActivity() == null) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } } if (translateIntent.resolveActivity(pm) != null) { context.startActivity(translateIntent) @@ -162,7 +168,9 @@ object ExternalDictionaryHelper { putExtra(Intent.EXTRA_PROCESS_TEXT, query) putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true) setPackage(packageName) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (context.getActivity() == null) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } } if (processTextIntent.resolveActivity(pm) != null) { @@ -281,4 +289,15 @@ object ExternalDictionaryHelper { return apps.sortedBy { it.label } } + + private fun Context.getActivity(): android.app.Activity? { + var currentContext = this + while (currentContext is android.content.ContextWrapper) { + if (currentContext is android.app.Activity) { + return currentContext + } + currentContext = currentContext.baseContext + } + return null + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/ml/ISpeechBubbleDetector.kt b/app/src/main/java/com/aryan/reader/ml/ISpeechBubbleDetector.kt new file mode 100644 index 0000000..1f3dd9e --- /dev/null +++ b/app/src/main/java/com/aryan/reader/ml/ISpeechBubbleDetector.kt @@ -0,0 +1,13 @@ +package com.aryan.reader.ml + +import android.graphics.Bitmap +import android.graphics.RectF + +data class SpeechBubble( + val bounds: RectF, + val maskBitmap: Bitmap? = null +) + +interface ISpeechBubbleDetector : AutoCloseable { + fun detectBubbles(bitmap: Bitmap, confidenceThreshold: Float = 0.1f): List +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt index 1c31026..2ea88bb 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt @@ -119,7 +119,8 @@ class BookPaginator( private val context: Context, private val mathMLRenderer: MathMLRenderer, private val userTextAlign: TextAlign?, - private val paragraphGapMultiplier: Float + private val paragraphGapMultiplier: Float, + private val imageSizeMultiplier: Float ) : IPaginator { override var totalPageCount by mutableIntStateOf(0) private set @@ -267,7 +268,16 @@ class BookPaginator( } private fun generateConfigurationHash(): Int { - val configString = "w:${constraints.maxWidth}-h:${constraints.maxHeight}-fs:${textStyle.fontSize.value}-ta:$userTextAlign-pg:$paragraphGapMultiplier" + val configString = buildString { + append("w:${constraints.maxWidth}") + append("-h:${constraints.maxHeight}") + append("-fs:${textStyle.fontSize.value}") + append("-lh:${textStyle.lineHeight.value}") + append("-ff:${textStyle.fontFamily}") + append("-ta:$userTextAlign") + append("-pg:$paragraphGapMultiplier") + append("-img:$imageSizeMultiplier") + } val hash = configString.hashCode() return hash } @@ -722,7 +732,8 @@ class BookPaginator( textMeasurer = textMeasurer, constraints = constraints, textStyle = textStyle, - density = density + density = density, + imageSizeMultiplier = imageSizeMultiplier ) Timber.d("paginateChapter: Calling PaginatorLogic for chapter $chapterIndex.") val pages = paginate( @@ -971,31 +982,7 @@ class BookPaginator( return@launch } - val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex) - val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex) - - if (chapterPages == null) { - Timber.e("Href Navigation failed: Could not paginate target chapter $targetChapterIndex.") - return@launch - } - - var targetPageInChapter = 0 - if (anchor != null) { - Timber.d("Searching for anchor '$anchor' in chapter $targetChapterIndex.") - pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) { - for (block in page.content) { - if (block.elementId == anchor) { - targetPageInChapter = pageIndex - Timber.i("Found anchor '$anchor' on page $pageIndex in chapter $targetChapterIndex") - break@pageLoop - } - } - } - } - - val finalPageIndex = chapterStartPage + targetPageInChapter - Timber.i("Href navigation complete. Final page index: $finalPageIndex") - withContext(Dispatchers.Main) { onNavigationComplete(finalPageIndex) } + findPageForAnchor(targetChapterIndex, anchor, onNavigationComplete) } } @@ -1304,4 +1291,4 @@ class BookPaginator( return null to null } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index 8d05883..a99aaf8 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -185,11 +185,135 @@ data class PaginatedSelection( val endOffset: Int, val text: String, val rect: Rect, + val startPageIndex: Int, + val endPageIndex: Int, val startBlockCharOffset: Int = 0, val endBlockCharOffset: Int = 0, val textPerBlock: Map = emptyMap() ) +private data class SelectionBlockKey( + val pageIndex: Int, + val blockIndex: Int, + val blockCharOffset: Int +) + +private fun buildSelectionBlockKey( + pageIndex: Int, + blockIndex: Int, + blockCharOffset: Int +): String = "${pageIndex}_${blockIndex}_${blockCharOffset}" + +private fun parseSelectionBlockKey(key: String): SelectionBlockKey? { + val parts = key.split("_") + if (parts.size != 3) return null + return SelectionBlockKey( + pageIndex = parts[0].toIntOrNull() ?: return null, + blockIndex = parts[1].toIntOrNull() ?: return null, + blockCharOffset = parts[2].toIntOrNull() ?: return null + ) +} + +private fun compareSelectionBlockKeys( + firstKey: String, + secondKey: String +): Int { + val first = parseSelectionBlockKey(firstKey) + val second = parseSelectionBlockKey(secondKey) + + if (first == null && second == null) return firstKey.compareTo(secondKey) + if (first == null) return 1 + if (second == null) return -1 + + return compareValuesBy( + first, + second, + SelectionBlockKey::pageIndex, + SelectionBlockKey::blockIndex, + SelectionBlockKey::blockCharOffset + ) +} + +private fun getTextBlockCharOffset(block: TextContentBlock): Int = when (block) { + is ParagraphBlock -> block.startCharOffsetInSource + is HeaderBlock -> block.startCharOffsetInSource + is QuoteBlock -> block.startCharOffsetInSource + is ListItemBlock -> block.startCharOffsetInSource +} + +private fun headerFontScale(level: Int): Float = when (level) { + 1 -> 1.5f + 2 -> 1.4f + 3 -> 1.3f + 4 -> 1.2f + 5 -> 1.1f + else -> 1.0f +} + +private fun createHeaderTextStyle( + baseStyle: TextStyle, + level: Int, + textAlign: TextAlign? +): TextStyle { + val scale = headerFontScale(level) + val scaledFontSize = baseStyle.fontSize * scale + val scaledLineHeight = if (baseStyle.lineHeight != TextUnit.Unspecified) { + baseStyle.lineHeight * scale + } else { + scaledFontSize * 1.2f + } + + return baseStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = scaledFontSize, + lineHeight = scaledLineHeight, + textAlign = textAlign ?: baseStyle.textAlign + ) +} + +private fun compareBlockPositionsOnPage( + firstBlockIndex: Int, + firstBlockCharOffset: Int, + secondBlockIndex: Int, + secondBlockCharOffset: Int +): Int = when { + firstBlockIndex != secondBlockIndex -> firstBlockIndex.compareTo(secondBlockIndex) + else -> firstBlockCharOffset.compareTo(secondBlockCharOffset) +} + +private fun isBlockSelectedOnPage( + block: TextContentBlock, + pageIndex: Int, + selection: PaginatedSelection +): Boolean { + if (pageIndex < selection.startPageIndex || pageIndex > selection.endPageIndex) return false + if (pageIndex > selection.startPageIndex && pageIndex < selection.endPageIndex) return true + + val blockCharOffset = getTextBlockCharOffset(block) + val afterStart = if (pageIndex == selection.startPageIndex) { + compareBlockPositionsOnPage( + block.blockIndex, + blockCharOffset, + selection.startBlockIndex, + selection.startBlockCharOffset + ) >= 0 + } else { + true + } + val beforeEnd = if (pageIndex == selection.endPageIndex) { + compareBlockPositionsOnPage( + block.blockIndex, + blockCharOffset, + selection.endBlockIndex, + selection.endBlockCharOffset + ) <= 0 + } else { + true + } + + return afterStart && beforeEnd +} + class ReactiveBlockMap( private val delegate: MutableMap> = mutableStateMapOf() ) : MutableMap> by delegate { @@ -302,10 +426,57 @@ private fun highlightQueryInText( } } +private fun computeImageRenderSizePx( + block: ImageBlock, + density: Density, + maxWidthPx: Float, + imageSizeMultiplier: Float +): Pair { + val intrinsicWidth = block.intrinsicWidth + val intrinsicHeight = block.intrinsicHeight + if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f || intrinsicHeight <= 0f) { + return 0f to 0f + } + + val aspectRatio = intrinsicHeight / intrinsicWidth + val baseWidth = with(density) { + if (block.style.width.isSpecified && block.style.width > 0.dp) { + block.style.width.toPx() + } else { + maxWidthPx + } + } + + var scaledWidth = baseWidth * imageSizeMultiplier + if (block.style.maxWidth.isSpecified && block.style.maxWidth > 0.dp) { + scaledWidth = scaledWidth.coerceAtMost(with(density) { block.style.maxWidth.toPx() } * imageSizeMultiplier) + } + scaledWidth = scaledWidth.coerceAtMost(maxWidthPx) + + return scaledWidth to (scaledWidth * aspectRatio) +} + +private fun computeImageRenderSizeDp( + block: ImageBlock, + density: Density, + maxWidthDp: Dp, + imageSizeMultiplier: Float +): Pair? { + val (widthPx, heightPx) = computeImageRenderSizePx( + block = block, + density = density, + maxWidthPx = with(density) { maxWidthDp.toPx() }, + imageSizeMultiplier = imageSizeMultiplier + ) + if (widthPx <= 0f || heightPx <= 0f) return null + return with(density) { widthPx.toDp() to heightPx.toDp() } +} + @Composable private fun WrappingContentLayout( block: WrappingContentBlock, textStyle: TextStyle, + imageSizeMultiplier: Float, modifier: Modifier = Modifier, searchQuery: String, ttsHighlightInfo: TtsHighlightInfo?, @@ -378,29 +549,12 @@ private fun WrappingContentLayout( } }) { measurables, constraints -> val (imageRenderWidthPx, imageRenderHeightPx) = run { - val imageStyle = block.floatedImage.style - val intrinsicWidth = block.floatedImage.intrinsicWidth - val intrinsicHeight = block.floatedImage.intrinsicHeight - - if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f) { - 0f to 0f - } else { - val aspectRatio = intrinsicHeight / intrinsicWidth - val renderWidth = with(density) { - var w = intrinsicWidth - - if (imageStyle.width != Dp.Unspecified) { - w = imageStyle.width.toPx() - } - - if (imageStyle.maxWidth != Dp.Unspecified) { - w = w.coerceAtMost(imageStyle.maxWidth.toPx()) - } - - w.coerceAtMost(constraints.maxWidth.toFloat()) - } - renderWidth to (renderWidth * aspectRatio) - } + computeImageRenderSizePx( + block = block.floatedImage, + density = density, + maxWidthPx = constraints.maxWidth.toFloat(), + imageSizeMultiplier = imageSizeMultiplier + ) } val imagePlacable = if (imageRenderWidthPx > 0 && imageRenderHeightPx > 0) { @@ -525,11 +679,12 @@ fun PaginatedReaderScreen( fontSizeMultiplier: Float, lineHeightMultiplier: Float, paragraphGapMultiplier: Float, + imageSizeMultiplier: Float, + horizontalMarginMultiplier: Float, fontFamily: FontFamily, textAlign: ReaderTextAlign, ttsHighlightInfo: TtsHighlightInfo?, initialChapterIndexInBook: Int?, - removeEdgePadding: Boolean = false, onPaginatorReady: (IPaginator) -> Unit, onTap: (Offset?) -> Unit, isProUser: Boolean, @@ -573,6 +728,8 @@ fun PaginatedReaderScreen( } } else Modifier + var isNavigatingByLink by remember { mutableStateOf(false) } + BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg).then(textureModifier)) { val textMeasurer = rememberTextMeasurer() val baseTextStyle = MaterialTheme.typography.bodyLarge @@ -580,6 +737,8 @@ fun PaginatedReaderScreen( var debouncedFontSizeMult by remember { mutableFloatStateOf(fontSizeMultiplier) } var debouncedLineHeightMult by remember { mutableFloatStateOf(lineHeightMultiplier) } var debouncedParagraphGapMult by remember { mutableFloatStateOf(paragraphGapMultiplier) } + var debouncedImageSizeMult by remember { mutableFloatStateOf(imageSizeMultiplier) } + var debouncedHorizontalMarginMult by remember { mutableFloatStateOf(horizontalMarginMultiplier) } var debouncedFontFamily by remember { mutableStateOf(fontFamily) } var debouncedTextAlign by remember { mutableStateOf(textAlign) } @@ -650,8 +809,15 @@ fun PaginatedReaderScreen( } } - LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, fontFamily, textAlign) { - if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || paragraphGapMultiplier != debouncedParagraphGapMult || fontFamily != debouncedFontFamily || textAlign != debouncedTextAlign) { + LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, imageSizeMultiplier, horizontalMarginMultiplier, fontFamily, textAlign) { + if (fontSizeMultiplier != debouncedFontSizeMult || + lineHeightMultiplier != debouncedLineHeightMult || + paragraphGapMultiplier != debouncedParagraphGapMult || + imageSizeMultiplier != debouncedImageSizeMult || + horizontalMarginMultiplier != debouncedHorizontalMarginMult || + fontFamily != debouncedFontFamily || + textAlign != debouncedTextAlign + ) { Timber.d("Formatting changed. Waiting for debounce.") delay(400L) @@ -667,6 +833,8 @@ fun PaginatedReaderScreen( debouncedFontSizeMult = fontSizeMultiplier debouncedLineHeightMult = lineHeightMultiplier debouncedParagraphGapMult = paragraphGapMultiplier + debouncedImageSizeMult = imageSizeMultiplier + debouncedHorizontalMarginMult = horizontalMarginMultiplier debouncedFontFamily = fontFamily debouncedTextAlign = textAlign Timber.d("Debounce complete. Applying new format settings.") @@ -682,7 +850,7 @@ fun PaginatedReaderScreen( } val density = LocalDensity.current - val horizontalPadding = if (removeEdgePadding) 0.dp else 16.dp + val horizontalPadding = 16.dp * debouncedHorizontalMarginMult val verticalPadding = 16.dp val textConstraints = @@ -780,7 +948,8 @@ fun PaginatedReaderScreen( context = context.applicationContext, mathMLRenderer = mathMLRenderer, userTextAlign = userTextAlign, - paragraphGapMultiplier = debouncedParagraphGapMult + paragraphGapMultiplier = debouncedParagraphGapMult, + imageSizeMultiplier = debouncedImageSizeMult ) } @@ -861,6 +1030,7 @@ fun PaginatedReaderScreen( searchQuery = searchQuery, ttsHighlightInfo = ttsHighlightInfo, textStyle = textStyle, + imageSizeMultiplier = debouncedImageSizeMult, horizontalPadding = horizontalPadding, verticalPadding = verticalPadding, onGetPage = { pageIndex -> @@ -887,6 +1057,7 @@ fun PaginatedReaderScreen( }, onLinkClick = { currentChapterPath, href, onNavComplete -> coroutineScope.launch(Dispatchers.IO) { + isNavigatingByLink = true var isFootnote = false var footnoteHtml: String? = null @@ -971,8 +1142,12 @@ fun PaginatedReaderScreen( withContext(Dispatchers.Main) { if (!footnoteHtml.isNullOrBlank()) { onFootnoteRequested(footnoteHtml) + isNavigatingByLink = false } else { - paginator.navigateToHref(currentChapterPath, href, onNavComplete) + paginator.navigateToHref(currentChapterPath, href) { + onNavComplete(it) + isNavigatingByLink = false + } } } } @@ -994,6 +1169,30 @@ fun PaginatedReaderScreen( onUpdatePalette = onUpdatePalette, effectiveText = effectiveText ) + + androidx.compose.animation.AnimatedVisibility( + visible = isNavigatingByLink, + enter = androidx.compose.animation.fadeIn(), + exit = androidx.compose.animation.fadeOut() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)) + .clickable(enabled = true) { }, + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + "Navigating...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground + ) + } + } + } } } @@ -1300,6 +1499,7 @@ private fun TextWithEmphasis( text: AnnotatedString, modifier: Modifier = Modifier, style: TextStyle, + pageIndex: Int, @Suppress("unused") textMeasurer: TextMeasurer, onLinkClick: (String) -> Unit, onGeneralTap: (Offset) -> Unit, @@ -1518,27 +1718,18 @@ private fun TextWithEmphasis( textLayoutResult?.let { layoutResult -> if (activeSelection != null) { - // ADD absolute offset helper: - val currentBlockAbs = when (block) { - is ParagraphBlock -> block.startCharOffsetInSource - is HeaderBlock -> block.startCharOffsetInSource - is QuoteBlock -> block.startCharOffsetInSource - is ListItemBlock -> block.startCharOffsetInSource - } + val currentBlockAbs = getTextBlockCharOffset(block) + val isSelectedOnPage = isBlockSelectedOnPage(block, pageIndex, activeSelection) + val isStart = + pageIndex == activeSelection.startPageIndex && + block.blockIndex == activeSelection.startBlockIndex && + currentBlockAbs == activeSelection.startBlockCharOffset + val isEnd = + pageIndex == activeSelection.endPageIndex && + block.blockIndex == activeSelection.endBlockIndex && + currentBlockAbs == activeSelection.endBlockCharOffset - val isStart = block.blockIndex == activeSelection.startBlockIndex && currentBlockAbs == activeSelection.startBlockCharOffset - val isEnd = block.blockIndex == activeSelection.endBlockIndex && currentBlockAbs == activeSelection.endBlockCharOffset - - val isBetween = when { - block.blockIndex > activeSelection.startBlockIndex && block.blockIndex < activeSelection.endBlockIndex -> true - block.blockIndex == activeSelection.startBlockIndex && block.blockIndex == activeSelection.endBlockIndex -> - currentBlockAbs > activeSelection.startBlockCharOffset && currentBlockAbs < activeSelection.endBlockCharOffset - block.blockIndex == activeSelection.startBlockIndex -> currentBlockAbs > activeSelection.startBlockCharOffset - block.blockIndex == activeSelection.endBlockIndex -> currentBlockAbs < activeSelection.endBlockCharOffset - else -> false - } - - if (isStart || isEnd || isBetween) { + if (isSelectedOnPage) { val sOffset = if (isStart) activeSelection.startOffset else 0 val eOffset = if (isEnd) activeSelection.endOffset else layoutResult.layoutInput.text.length @@ -1682,9 +1873,17 @@ private fun TextWithEmphasis( endOffset = end, text = selText, rect = Rect(topLeftWin, bottomRightWin), + startPageIndex = pageIndex, + endPageIndex = pageIndex, startBlockCharOffset = startBlockAbs, endBlockCharOffset = startBlockAbs, - textPerBlock = mapOf("${block.blockIndex}_${startBlockAbs}" to selText) + textPerBlock = mapOf( + buildSelectionBlockKey( + pageIndex = pageIndex, + blockIndex = block.blockIndex, + blockCharOffset = startBlockAbs + ) to selText + ) ) ) } @@ -1730,10 +1929,14 @@ private fun checkLayoutMismatch( expectedHeight: Int, actualHeight: Int, textSnippet: String, + diagnostics: String = "", @Suppress("SameParameterValue") tolerance: Int = 2 ) { if (expectedHeight == 0) { - Timber.tag("PAGINATION_MISMATCH").w("Block #$blockIndex ($blockType) has expectedHeight=0. Skipping check. Text: '$textSnippet'") + Timber.tag("PAGINATION_MISMATCH").w( + "Block #$blockIndex ($blockType) has expectedHeight=0. Skipping check. Text: '$textSnippet'" + + if (diagnostics.isNotBlank()) "\n -> Diagnostics: $diagnostics" else "" + ) return } @@ -1744,7 +1947,8 @@ private fun checkLayoutMismatch( " -> Expected: ${expectedHeight}px\n" + " -> Actual: ${actualHeight}px\n" + " -> Diff: +${diff}px\n" + - " -> Content: '$textSnippet'" + " -> Content: '$textSnippet'" + + if (diagnostics.isNotBlank()) "\n -> Diagnostics: $diagnostics" else "" ) } } @@ -1763,6 +1967,7 @@ internal fun PaginatedReaderContent( searchQuery: String, ttsHighlightInfo: TtsHighlightInfo?, textStyle: TextStyle, + imageSizeMultiplier: Float, horizontalPadding: Dp, verticalPadding: Dp, onGetPage: (Int) -> Page?, @@ -1962,6 +2167,7 @@ internal fun PaginatedReaderContent( LaunchedEffect(activeSelection, lastTextBlock, isDraggingHandle) { if (isDraggingHandle && activeSelection != null && lastTextBlock != null && + activeSelection!!.endPageIndex == pageIndex && activeSelection!!.endBlockIndex == lastTextBlock.blockIndex && activeSelection!!.endBlockCharOffset == lastBlockAbs) { if (activeSelection!!.endOffset >= lastTextBlock.content.text.length - 3) { @@ -2041,14 +2247,19 @@ internal fun PaginatedReaderContent( } val newTextPerBlock = (previousSel?.textPerBlock ?: emptyMap()).toMutableMap() - newTextPerBlock["${firstTextBlock.blockIndex}_${firstTextBlockAbs}"] = text.substring(0, endIndex) + newTextPerBlock[ + buildSelectionBlockKey( + pageIndex = pageIndex, + blockIndex = firstTextBlock.blockIndex, + blockCharOffset = firstTextBlockAbs + ) + ] = text.substring(0, endIndex) - val newText = newTextPerBlock.entries.sortedBy { - val parts = it.key.split("_") - val idx = parts[0].toIntOrNull() ?: 0 - val abs = parts.getOrNull(1)?.toIntOrNull() ?: 0 - idx * 1000000L + abs - }.joinToString(" ") { it.value } + val newText = newTextPerBlock.entries + .sortedWith { first, second -> + compareSelectionBlockKeys(first.key, second.key) + } + .joinToString(" ") { it.value } activeSelection = PaginatedSelection( startBlockIndex = previousSel?.startBlockIndex ?: firstTextBlock.blockIndex, @@ -2059,6 +2270,8 @@ internal fun PaginatedReaderContent( endOffset = endIndex, text = newText, rect = Rect(windowTopLeft, windowBottomRight), + startPageIndex = previousSel?.startPageIndex ?: pending.fromPageIndex, + endPageIndex = pageIndex, startBlockCharOffset = previousSel?.startBlockCharOffset ?: firstTextBlockAbs, endBlockCharOffset = firstTextBlockAbs, textPerBlock = newTextPerBlock @@ -2181,6 +2394,79 @@ internal fun PaginatedReaderContent( expectedHeight = block.expectedHeight, actualHeight = actualHeight, textSnippet = snippet, + diagnostics = buildString { + append("page=") + append(pageIndex) + append(", width=") + append(coordinates.size.width) + append("px, styleWidth=") + append(block.style.width) + append(", maxWidth=") + append(block.style.maxWidth) + append(", margin=") + append(block.style.margin) + append(", padding=") + append(block.style.padding) + append(", borders=(") + append(block.style.borderLeft?.width ?: 0.dp) + append(", ") + append(block.style.borderTop?.width ?: 0.dp) + append(", ") + append(block.style.borderRight?.width ?: 0.dp) + append(", ") + append(block.style.borderBottom?.width ?: 0.dp) + append(")") + when (block) { + is ParagraphBlock -> { + append(", start=") + append(block.startCharOffsetInSource) + append(", end=") + append(block.endCharOffsetInSource) + append(", chars=") + append(block.content.length) + append(", textAlign=") + append(block.textAlign) + } + + is HeaderBlock -> { + append(", start=") + append(block.startCharOffsetInSource) + append(", end=") + append(block.endCharOffsetInSource) + append(", chars=") + append(block.content.length) + append(", textAlign=") + append(block.textAlign) + } + + is QuoteBlock -> { + append(", start=") + append(block.startCharOffsetInSource) + append(", end=") + append(block.endCharOffsetInSource) + append(", chars=") + append(block.content.length) + append(", textAlign=") + append(block.textAlign) + } + + is ListItemBlock -> { + append(", start=") + append(block.startCharOffsetInSource) + append(", end=") + append(block.endCharOffsetInSource) + append(", chars=") + append(block.content.length) + } + + is TextContentBlock -> { + append(", chars=") + append(block.content.length) + } + + else -> Unit + } + }, tolerance = 2 ) } @@ -2295,6 +2581,7 @@ internal fun PaginatedReaderContent( text = finalContent, style = paragraphStyle, modifier = paddingModifier, + pageIndex = pageIndex, textMeasurer = textMeasurer, onLinkClick = onLinkClickCallback, onGeneralTap = onGeneralTapCallback, @@ -2320,10 +2607,10 @@ internal fun PaginatedReaderContent( } is HeaderBlock -> { - val style = textStyle.copy( - fontWeight = FontWeight.Bold, + val style = createHeaderTextStyle( + baseStyle = textStyle, + level = block.level, textAlign = block.textAlign - ?: textStyle.textAlign ) val searchHighlighted = highlightQueryInText( @@ -2377,6 +2664,7 @@ internal fun PaginatedReaderContent( text = finalContent, style = style, modifier = paddingModifier, + pageIndex = pageIndex, textMeasurer = textMeasurer, onLinkClick = onLinkClickCallback, onGeneralTap = onGeneralTapCallback, @@ -2462,6 +2750,7 @@ internal fun PaginatedReaderContent( text = finalContent, style = quoteStyle, modifier = quoteModifier, + pageIndex = pageIndex, textMeasurer = textMeasurer, onLinkClick = onLinkClickCallback, onGeneralTap = onGeneralTapCallback, @@ -2576,6 +2865,7 @@ internal fun PaginatedReaderContent( text = finalContent, style = textStyle, modifier = Modifier.weight(1f), + pageIndex = pageIndex, textMeasurer = textMeasurer, onLinkClick = onLinkClickCallback, onGeneralTap = onGeneralTapCallback, @@ -2605,6 +2895,7 @@ internal fun PaginatedReaderContent( WrappingContentLayout( block = block, textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, modifier = paddingModifier, searchQuery = searchQuery, ttsHighlightInfo = ttsHighlightInfo, @@ -2639,6 +2930,7 @@ internal fun PaginatedReaderContent( RenderFlexChildBlock( childBlock = childBlock, textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, searchQuery = searchQuery, searchHighlightColor = searchHighlightColor, ttsHighlightInfo = ttsHighlightInfo, @@ -2691,6 +2983,7 @@ internal fun PaginatedReaderContent( RenderFlexChildBlock( childBlock = childBlock, textStyle = textStyle, + imageSizeMultiplier = imageSizeMultiplier, searchQuery = searchQuery, searchHighlightColor = searchHighlightColor, ttsHighlightInfo = ttsHighlightInfo, @@ -2851,23 +3144,6 @@ internal fun PaginatedReaderContent( is ImageBlock -> { val style = block.style - val finalImageModifier = Modifier.then( - if (style.width.isSpecified && style.width > 0.dp) Modifier.width(style.width) - else Modifier.fillMaxWidth() - ).then( - if (style.maxWidth.isSpecified && style.maxWidth > 0.dp) Modifier.widthIn(max = style.maxWidth) - else Modifier - ).then( - if (block.expectedHeight > 0) { - Modifier.height(with(density) { block.expectedHeight.toDp() }) - } else { - Modifier.height(250.dp) - } - ).then(paddingModifier) - .onGloballyPositioned { coords -> - Timber.tag("IMAGE_DIAG").v("Actual Rendered Height for [#${block.blockIndex}]: ${coords.size.height}px") - } - val colorFilter = if (block.style.filter == "invert(100%)") { val matrix = floatArrayOf( @@ -2912,14 +3188,54 @@ internal fun PaginatedReaderContent( ) }).crossfade(true).build() - AsyncImage( - model = imageRequest, - contentDescription = block.altText - ?: "Image from EPUB", - modifier = finalImageModifier, - contentScale = ContentScale.Fit, - colorFilter = colorFilter - ) + BoxWithConstraints(modifier = paddingModifier) { + val scaledSize = computeImageRenderSizeDp( + block = block, + density = density, + maxWidthDp = maxWidth, + imageSizeMultiplier = imageSizeMultiplier + ) + val finalImageModifier = Modifier + .then( + if (scaledSize != null) { + Modifier.width(scaledSize.first).height(scaledSize.second) + } else if (style.width.isSpecified && style.width > 0.dp) { + Modifier.width(style.width) + } else { + Modifier.fillMaxWidth() + } + ) + .then( + if (scaledSize == null && style.maxWidth.isSpecified && style.maxWidth > 0.dp) { + Modifier.widthIn(max = style.maxWidth) + } else { + Modifier + } + ) + .then( + if (scaledSize == null) { + if (block.expectedHeight > 0) { + Modifier.height(with(density) { (block.expectedHeight * imageSizeMultiplier).toDp() }) + } else { + Modifier.height(250.dp) + } + } else { + Modifier + } + ) + .onGloballyPositioned { coords -> + Timber.tag("IMAGE_DIAG").v("Actual Rendered Height for [#${block.blockIndex}]: ${coords.size.height}px") + } + + AsyncImage( + model = imageRequest, + contentDescription = block.altText + ?: "Image from EPUB", + modifier = finalImageModifier, + contentScale = ContentScale.Fit, + colorFilter = colorFilter + ) + } } is SpacerBlock -> { @@ -3073,26 +3389,40 @@ internal fun PaginatedReaderContent( } is ImageBlock -> { - val imageModifier = Modifier.fillMaxWidth().then( - if (blockInCell.expectedHeight > 0) { - Modifier.height(with(density) { blockInCell.expectedHeight.toDp() }) - } else { - Modifier.height(250.dp) - } - ) - AsyncImage( - model = Builder( - LocalContext.current - ).data( - File( - blockInCell.path - ) + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val scaledSize = computeImageRenderSizeDp( + block = blockInCell, + density = density, + maxWidthDp = maxWidth, + imageSizeMultiplier = imageSizeMultiplier ) - .build(), - contentDescription = blockInCell.altText, - contentScale = ContentScale.Fit, - modifier = imageModifier - ) + val imageModifier = Modifier.then( + if (scaledSize != null) { + Modifier.width(scaledSize.first).height(scaledSize.second) + } else { + Modifier.fillMaxWidth().then( + if (blockInCell.expectedHeight > 0) { + Modifier.height(with(density) { (blockInCell.expectedHeight * imageSizeMultiplier).toDp() }) + } else { + Modifier.height(250.dp) + } + ) + } + ) + AsyncImage( + model = Builder( + LocalContext.current + ).data( + File( + blockInCell.path + ) + ) + .build(), + contentDescription = blockInCell.altText, + contentScale = ContentScale.Fit, + modifier = imageModifier + ) + } } is TextContentBlock -> { @@ -3139,7 +3469,7 @@ internal fun PaginatedReaderContent( val currentPageBlocks = blockLayoutMap.filterKeys { it.endsWith(currentPageSuffix) }.values.filter { it.second.isAttached } val visibleSelectedBlocks = - currentPageBlocks.filter { it.third.blockIndex in sel.startBlockIndex..sel.endBlockIndex } + currentPageBlocks.filter { isBlockSelectedOnPage(it.third, pagerState.currentPage, sel) } if (!isDraggingHandle && visibleSelectedBlocks.isNotEmpty()) { val menuAnchorRect = run { @@ -3151,14 +3481,15 @@ internal fun PaginatedReaderContent( visibleSelectedBlocks.forEach { triple -> val (textLayout, coords, block) = triple - val currentBlockAbs = when (block) { - is ParagraphBlock -> block.startCharOffsetInSource - is HeaderBlock -> block.startCharOffsetInSource - is QuoteBlock -> block.startCharOffsetInSource - is ListItemBlock -> block.startCharOffsetInSource - } - val isStartBlockPart = block.blockIndex == sel.startBlockIndex && currentBlockAbs == sel.startBlockCharOffset - val isEndBlockPart = block.blockIndex == sel.endBlockIndex && currentBlockAbs == sel.endBlockCharOffset + val currentBlockAbs = getTextBlockCharOffset(block) + val isStartBlockPart = + pagerState.currentPage == sel.startPageIndex && + block.blockIndex == sel.startBlockIndex && + currentBlockAbs == sel.startBlockCharOffset + val isEndBlockPart = + pagerState.currentPage == sel.endPageIndex && + block.blockIndex == sel.endBlockIndex && + currentBlockAbs == sel.endBlockCharOffset val blockStartOffset = if (isStartBlockPart) sel.startOffset else 0 val blockEndOffset = if (isEndBlockPart) sel.endOffset else textLayout.layoutInput.text.length @@ -3304,30 +3635,28 @@ internal fun PaginatedReaderContent( var newEndOffset = if (isStartHandle) sel.endOffset else offset var newStartCfi = if (isStartHandle) block.cfi!! else sel.startBaseCfi var newEndCfi = if (isStartHandle) sel.endBaseCfi else block.cfi!! + var newStartPageIdx = if (isStartHandle) pagerState.currentPage else sel.startPageIndex + var newEndPageIdx = if (isStartHandle) sel.endPageIndex else pagerState.currentPage - val currentBlockAbs = when (block) { - is ParagraphBlock -> block.startCharOffsetInSource - is HeaderBlock -> block.startCharOffsetInSource - is QuoteBlock -> block.startCharOffsetInSource - is ListItemBlock -> block.startCharOffsetInSource - } + val currentBlockAbs = getTextBlockCharOffset(block) var newStartBlockAbs = if (isStartHandle) currentBlockAbs else sel.startBlockCharOffset var newEndBlockAbs = if (!isStartHandle) currentBlockAbs else sel.endBlockCharOffset - // UPDATE Swap conditions completely: val isReversed = when { - newStartIdx > newEndIdx -> true - newStartIdx < newEndIdx -> false + newStartPageIdx != newEndPageIdx -> newStartPageIdx > newEndPageIdx else -> { - when { - newStartBlockAbs > newEndBlockAbs -> true - newStartBlockAbs < newEndBlockAbs -> false - else -> newStartOffset > newEndOffset - } + val blockCompare = compareBlockPositionsOnPage( + newStartIdx, + newStartBlockAbs, + newEndIdx, + newEndBlockAbs + ) + if (blockCompare != 0) blockCompare > 0 else newStartOffset > newEndOffset } } if (isReversed) { + newStartPageIdx = newEndPageIdx.also { newEndPageIdx = newStartPageIdx } newStartIdx = newEndIdx.also { newEndIdx = newStartIdx } newStartOffset = newEndOffset.also { newEndOffset = newStartOffset } newStartCfi = newEndCfi.also { newEndCfi = newStartCfi } @@ -3335,33 +3664,48 @@ internal fun PaginatedReaderContent( activeDragHandle = if (activeDragHandle == SelectionHandle.START) SelectionHandle.END else SelectionHandle.START } - if (newStartIdx != sel.startBlockIndex || newEndIdx != sel.endBlockIndex || newStartOffset != sel.startOffset || newEndOffset != sel.endOffset) { + if ( + newStartPageIdx != sel.startPageIndex || + newEndPageIdx != sel.endPageIndex || + newStartIdx != sel.startBlockIndex || + newEndIdx != sel.endBlockIndex || + newStartOffset != sel.startOffset || + newEndOffset != sel.endOffset + ) { hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) - val relevantBlocks = attachedBlocks.filter { it.third.blockIndex in newStartIdx..newEndIdx } - .sortedWith(compareBy({ it.third.blockIndex }, { b -> - when(b.third) { - is ParagraphBlock -> (b.third as ParagraphBlock).startCharOffsetInSource - is HeaderBlock -> (b.third as HeaderBlock).startCharOffsetInSource - is QuoteBlock -> (b.third as QuoteBlock).startCharOffsetInSource - is ListItemBlock -> (b.third as ListItemBlock).startCharOffsetInSource - } - })) + val relevantBlocks = attachedBlocks + .filter { + isBlockSelectedOnPage( + block = it.third, + pageIndex = pagerState.currentPage, + selection = PaginatedSelection( + startBlockIndex = newStartIdx, + endBlockIndex = newEndIdx, + startBaseCfi = newStartCfi, + endBaseCfi = newEndCfi, + startOffset = newStartOffset, + endOffset = newEndOffset, + text = sel.text, + rect = sel.rect, + startPageIndex = newStartPageIdx, + endPageIndex = newEndPageIdx, + startBlockCharOffset = newStartBlockAbs, + endBlockCharOffset = newEndBlockAbs, + textPerBlock = sel.textPerBlock + ) + ) + } + .sortedWith(compareBy({ it.third.blockIndex }, { getTextBlockCharOffset(it.third) })) val newTextPerBlock = sel.textPerBlock.toMutableMap() newTextPerBlock.keys.removeAll { keyStr -> - val bIdx = keyStr.split("_").firstOrNull()?.toIntOrNull() ?: -1 - bIdx !in newStartIdx..newEndIdx + parseSelectionBlockKey(keyStr)?.pageIndex == pagerState.currentPage } for (b in relevantBlocks) { val txt = b.third.content.text - val bAbs = when(b.third) { - is ParagraphBlock -> b.third.startCharOffsetInSource - is HeaderBlock -> b.third.startCharOffsetInSource - is QuoteBlock -> b.third.startCharOffsetInSource - is ListItemBlock -> b.third.startCharOffsetInSource - } + val bAbs = getTextBlockCharOffset(b.third) val isStartBlockPart = b.third.blockIndex == newStartIdx && bAbs == newStartBlockAbs val isEndBlockPart = b.third.blockIndex == newEndIdx && bAbs == newEndBlockAbs @@ -3372,30 +3716,36 @@ internal fun PaginatedReaderContent( val safeE = e.coerceIn(safeS, txt.length) if (safeS < safeE) { - newTextPerBlock["${b.third.blockIndex}_${bAbs}"] = txt.substring(safeS, safeE) + newTextPerBlock[ + buildSelectionBlockKey( + pageIndex = pagerState.currentPage, + blockIndex = b.third.blockIndex, + blockCharOffset = bAbs + ) + ] = txt.substring(safeS, safeE) } else { - newTextPerBlock.remove("${b.third.blockIndex}_${bAbs}") + newTextPerBlock.remove( + buildSelectionBlockKey( + pageIndex = pagerState.currentPage, + blockIndex = b.third.blockIndex, + blockCharOffset = bAbs + ) + ) } } - val newText = newTextPerBlock.entries.sortedBy { - val parts = it.key.split("_") - val idx = parts[0].toIntOrNull() ?: 0 - val abs = parts.getOrNull(1)?.toIntOrNull() ?: 0 - idx * 1000000L + abs - }.joinToString(" ") { it.value } - - val sLayout = blockLayoutMap["${newStartCfi}$currentPageSuffix"]?.takeIf { - val abs = when(it.third) { - is ParagraphBlock -> it.third.startCharOffsetInSource - is HeaderBlock -> it.third.startCharOffsetInSource - is QuoteBlock -> it.third.startCharOffsetInSource - is ListItemBlock -> it.third.startCharOffsetInSource + val newText = newTextPerBlock.entries + .sortedWith { first, second -> + compareSelectionBlockKeys(first.key, second.key) } + .joinToString(" ") { it.value } + + val sLayout = blockLayoutMap["${newStartCfi}_$newStartPageIdx"]?.takeIf { + val abs = getTextBlockCharOffset(it.third) abs == newStartBlockAbs } - val eLayout = blockLayoutMap["${newEndCfi}$currentPageSuffix"] + val eLayout = blockLayoutMap["${newEndCfi}_$newEndPageIdx"] var newRect = sel.rect if (sLayout != null && eLayout != null && sLayout.second.isAttached && eLayout.second.isAttached) { @@ -3453,6 +3803,8 @@ internal fun PaginatedReaderContent( endOffset = newEndOffset, text = newText, rect = newRect, + startPageIndex = newStartPageIdx, + endPageIndex = newEndPageIdx, startBlockCharOffset = newStartBlockAbs, endBlockCharOffset = newEndBlockAbs, textPerBlock = newTextPerBlock @@ -3476,34 +3828,34 @@ internal fun PaginatedReaderContent( @Suppress("UNUSED_VARIABLE") val isScrolling = pagerState.isScrollInProgress @Suppress("UNUSED_VARIABLE") val tick = blockLayoutMap.tick - val selCfi = if (isStart) sel.startBaseCfi else sel.endBaseCfi - val selOffset = if (isStart) sel.startOffset else sel.endOffset - val targetBlockAbs = if (isStart) sel.startBlockCharOffset else sel.endBlockCharOffset - val layoutInfo = blockLayoutMap["${selCfi}$currentPageSuffix"]?.takeIf { - val blockAbs = when (val block = it.third) { - is ParagraphBlock -> block.startCharOffsetInSource - is HeaderBlock -> block.startCharOffsetInSource - is QuoteBlock -> block.startCharOffsetInSource - is ListItemBlock -> block.startCharOffsetInSource + val handlePageIndex = if (isStart) sel.startPageIndex else sel.endPageIndex + val pos = if (handlePageIndex == pagerState.currentPage) { + val selCfi = if (isStart) sel.startBaseCfi else sel.endBaseCfi + val selOffset = if (isStart) sel.startOffset else sel.endOffset + val targetBlockAbs = if (isStart) sel.startBlockCharOffset else sel.endBlockCharOffset + val layoutInfo = blockLayoutMap["${selCfi}_$handlePageIndex"]?.takeIf { + val blockAbs = getTextBlockCharOffset(it.third) + blockAbs == targetBlockAbs } - blockAbs == targetBlockAbs - } - val pos = if (layoutInfo != null && layoutInfo.second.isAttached && rootCoords != null && rootCoords!!.isAttached) { - val textLayout = layoutInfo.first - val coords = layoutInfo.second - val maxIdx = maxOf(0, textLayout.layoutInput.text.length - 1) - val safeOffset = selOffset.coerceIn(0, textLayout.layoutInput.text.length) - val safeOffsetForLine = safeOffset.coerceIn(0, maxIdx) + if (layoutInfo != null && layoutInfo.second.isAttached && rootCoords != null && rootCoords!!.isAttached) { + val textLayout = layoutInfo.first + val coords = layoutInfo.second + val maxIdx = maxOf(0, textLayout.layoutInput.text.length - 1) + val safeOffset = selOffset.coerceIn(0, textLayout.layoutInput.text.length) + val safeOffsetForLine = safeOffset.coerceIn(0, maxIdx) - val line = textLayout.getLineForOffset(safeOffsetForLine) - val x = textLayout.getHorizontalPosition(safeOffset, usePrimaryDirection = true) - val y = textLayout.getLineBottom(line) + val line = textLayout.getLineForOffset(safeOffsetForLine) + val x = textLayout.getHorizontalPosition(safeOffset, usePrimaryDirection = true) + val y = textLayout.getLineBottom(line) - try { - val windowPos = coords.localToWindow(Offset(x, y)) - rootCoords!!.windowToLocal(windowPos) - } catch (e: Exception) { + try { + val windowPos = coords.localToWindow(Offset(x, y)) + rootCoords!!.windowToLocal(windowPos) + } catch (e: Exception) { + Offset.Unspecified + } + } else { Offset.Unspecified } } else { @@ -3663,6 +4015,7 @@ private fun ChapterLoadingPlaceholder(title: String?) { private fun RenderFlexChildBlock( childBlock: ContentBlock, textStyle: TextStyle, + imageSizeMultiplier: Float, searchQuery: String, searchHighlightColor: Color, ttsHighlightInfo: TtsHighlightInfo?, @@ -3711,17 +4064,11 @@ private fun RenderFlexChildBlock( // Apply block specific styles (like header font weight) val finalStyle = if (block is HeaderBlock) { - textStyle.copy( - fontWeight = FontWeight.Bold, fontSize = textStyle.fontSize * block.level.let { - when (it) { - 1 -> 1.5f - 2 -> 1.4f - 3 -> 1.3f - 4 -> 1.2f - 5 -> 1.1f - else -> 1.0f - } - }) + createHeaderTextStyle( + baseStyle = textStyle, + level = block.level, + textAlign = block.textAlign + ) } else { textStyle } @@ -3730,6 +4077,7 @@ private fun RenderFlexChildBlock( text = finalContent, style = finalStyle, modifier = Modifier, + pageIndex = pageIndex, textMeasurer = textMeasurer, onLinkClick = onLinkClickCallback, onGeneralTap = onGeneralTapCallback, @@ -3785,23 +4133,6 @@ private fun RenderFlexChildBlock( is TextContentBlock -> renderTextBlock(childBlock) is ImageBlock -> { val style = childBlock.style - val imageModifier = Modifier - .then( - if (style.width != Dp.Unspecified && style.width > 0.dp) Modifier.width(style.width) - else Modifier - ) - .then( - if (style.maxWidth != Dp.Unspecified && style.maxWidth > 0.dp) Modifier.widthIn(max = style.maxWidth) - else Modifier - ) - .then( - if (childBlock.expectedHeight > 0) { - Modifier.height(with(density) { childBlock.expectedHeight.toDp() }) - } else { - Modifier.height(250.dp) - } - ) - val colorFilter = if (childBlock.style.filter == "invert(100%)") { val matrix = floatArrayOf( -1f, @@ -3828,15 +4159,52 @@ private fun RenderFlexChildBlock( ColorFilter.colorMatrix(ColorMatrix(matrix)) } else null - AsyncImage( - model = Builder(LocalContext.current).data(File(childBlock.path)).crossfade(true) - .build(), - contentDescription = childBlock.altText, - modifier = imageModifier, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - imageLoader = imageLoader - ) + BoxWithConstraints { + val scaledSize = computeImageRenderSizeDp( + block = childBlock, + density = density, + maxWidthDp = maxWidth, + imageSizeMultiplier = imageSizeMultiplier + ) + val imageModifier = Modifier + .then( + if (scaledSize != null) { + Modifier.width(scaledSize.first).height(scaledSize.second) + } else if (style.width != Dp.Unspecified && style.width > 0.dp) { + Modifier.width(style.width) + } else { + Modifier + } + ) + .then( + if (scaledSize == null && style.maxWidth != Dp.Unspecified && style.maxWidth > 0.dp) { + Modifier.widthIn(max = style.maxWidth) + } else { + Modifier + } + ) + .then( + if (scaledSize == null) { + if (childBlock.expectedHeight > 0) { + Modifier.height(with(density) { (childBlock.expectedHeight * imageSizeMultiplier).toDp() }) + } else { + Modifier.height(250.dp) + } + } else { + Modifier + } + ) + + AsyncImage( + model = Builder(LocalContext.current).data(File(childBlock.path)).crossfade(true) + .build(), + contentDescription = childBlock.altText, + modifier = imageModifier, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + imageLoader = imageLoader + ) + } } is SpacerBlock -> { @@ -3909,23 +4277,37 @@ private fun RenderFlexChildBlock( modifier = Modifier.fillMaxWidth() ) } else if (blockInCell is ImageBlock) { - val imageModifier = Modifier.fillMaxWidth().then( - if (blockInCell.expectedHeight > 0) { - Modifier.height(with(density) { blockInCell.expectedHeight.toDp() }) - } else { - Modifier.height(250.dp) - } - ) - AsyncImage( - model = Builder(LocalContext.current).data( - File( - blockInCell.path - ) - ).build(), - contentDescription = blockInCell.altText, - contentScale = ContentScale.Fit, - modifier = imageModifier - ) + 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( + model = Builder(LocalContext.current).data( + File( + blockInCell.path + ) + ).build(), + contentDescription = blockInCell.altText, + contentScale = ContentScale.Fit, + modifier = imageModifier + ) + } } } } @@ -4281,4 +4663,4 @@ fun Modifier.drawCssBorders( style = Stroke(width = bottomWidth) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt index eab8b7e..02a3234 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModel.kt @@ -144,7 +144,8 @@ class PaginatedReaderViewModel : ViewModel() { context = context.applicationContext, mathMLRenderer = mathMLRenderer, userTextAlign = null, - paragraphGapMultiplier = paragraphGapMultiplier + paragraphGapMultiplier = paragraphGapMultiplier, + imageSizeMultiplier = 1.0f ) paginator = newPaginator @@ -174,4 +175,4 @@ class PaginatedReaderViewModel : ViewModel() { fun onLinkClick(currentChapterPath: String, href: String, onNavigationComplete: (Int) -> Unit) { paginator?.navigateToHref(currentChapterPath, href, onNavigationComplete) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt index a8a766a..978ea41 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt @@ -50,7 +50,8 @@ class SuspendingAndroidBlockMeasurementProvider( private val textMeasurer: TextMeasurer, private val constraints: Constraints, private val textStyle: TextStyle, - private val density: Density + private val density: Density, + private val imageSizeMultiplier: Float ) : BlockMeasurementProvider { override suspend fun measure(block: ContentBlock): Int { @@ -60,7 +61,8 @@ class SuspendingAndroidBlockMeasurementProvider( constraints = constraints, defaultStyle = textStyle, headerStyle = textStyle.copy(fontWeight = FontWeight.Bold), - density = density + density = density, + imageSizeMultiplier = imageSizeMultiplier ) } @@ -79,29 +81,12 @@ class SuspendingAndroidBlockMeasurementProvider( val imageBlock = block.floatedImage val (imageWidthPx, imageHeightPx) = run { - val imageStyle = imageBlock.style - val intrinsicWidth = imageBlock.intrinsicWidth - val intrinsicHeight = imageBlock.intrinsicHeight - - if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f) { - 0f to 0f - } else { - val aspectRatio = intrinsicHeight / intrinsicWidth - val renderWidth = with(density) { - var w = intrinsicWidth - - if (imageStyle.width != Dp.Unspecified) { - w = imageStyle.width.toPx() - } - - if (imageStyle.maxWidth != Dp.Unspecified) { - w = w.coerceAtMost(imageStyle.maxWidth.toPx()) - } - - w.coerceAtMost(constraints.maxWidth.toFloat()) - } - renderWidth to (renderWidth * aspectRatio) - } + measureScaledImageSizePx( + block = imageBlock, + density = density, + maxWidthPx = constraints.maxWidth.toFloat(), + imageSizeMultiplier = imageSizeMultiplier + ) } if (imageWidthPx <= 0 || imageHeightPx <= 0) { @@ -668,62 +653,25 @@ private suspend fun measureBlockHeight( constraints: Constraints, defaultStyle: TextStyle, headerStyle: TextStyle, - density: Density + density: Density, + imageSizeMultiplier: Float = 1.0f ): Int { - var verticalPaddingPx = 0f - var horizontalPaddingPx = 0f - var verticalBorderPx = 0f - var horizontalBorderPx = 0f - - with(density) { - verticalPaddingPx = block.style.padding.top.toPx() + block.style.padding.bottom.toPx() - horizontalPaddingPx = block.style.padding.left.toPx() + block.style.padding.right.toPx() - - verticalBorderPx = (block.style.borderTop?.width?.toPx() ?: 0f) + (block.style.borderBottom?.width?.toPx() ?: 0f) - horizontalBorderPx = (block.style.borderLeft?.width?.toPx() ?: 0f) + (block.style.borderRight?.width?.toPx() ?: 0f) - } - - val isBorderBox = block.style.boxSizing == "border-box" - val specifiedWidthDp = block.style.width - val specifiedMaxWidthDp = block.style.maxWidth - - val blockOuterWidthPx = with(density) { - var effectiveWidthPx = constraints.maxWidth.toFloat() - if (specifiedWidthDp != Dp.Unspecified) { - effectiveWidthPx = specifiedWidthDp.toPx() - } - if (specifiedMaxWidthDp != Dp.Unspecified) { - val maxWidthPx = specifiedMaxWidthDp.toPx() - if (effectiveWidthPx > maxWidthPx) { - effectiveWidthPx = maxWidthPx - } - } - effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat()) - } - - val contentMaxWidth = if (specifiedWidthDp == Dp.Unspecified) { - (blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx) - } else if (isBorderBox) { - (blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx) - } else { - blockOuterWidthPx - } - - val adjustedConstraints = constraints.copy( - maxWidth = contentMaxWidth.roundToInt().coerceAtLeast(0), - maxHeight = Constraints.Infinity - ) + val boxMetrics = computeBlockBoxMetrics(block, constraints, density) + val verticalPaddingPx = boxMetrics.verticalPaddingPx + val verticalBorderPx = boxMetrics.verticalBorderPx + val adjustedConstraints = boxMetrics.contentConstraints val contentHeight = when (block) { is ParagraphBlock -> { + val paragraphStyle = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign) val height = withContext(Dispatchers.Main) { textMeasurer.measure( text = block.content, - style = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign), + style = paragraphStyle, constraints = adjustedConstraints ).size.height } - height + height + centeredTextSafetyPaddingPx(paragraphStyle, density) } is HeaderBlock -> { val style = headerStyle.copy( @@ -736,27 +684,15 @@ private suspend fun measureBlockHeight( constraints = adjustedConstraints ).size.height } - height + height + centeredTextSafetyPaddingPx(style, density) } is ImageBlock -> { - val imageIntrinsicWidth = block.intrinsicWidth - val imageIntrinsicHeight = block.intrinsicHeight - - val styledHeightPx = if (block.style.height.isSpecified) with(density) { block.style.height.toPx() } else null - val styledWidthPx = if (block.style.width.isSpecified) with(density) { block.style.width.toPx() } else null - - val measuredHeight = when { - styledHeightPx != null && styledHeightPx > 0f -> styledHeightPx - styledWidthPx != null && styledWidthPx > 0f && imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> { - val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth - styledWidthPx * aspectRatio - } - imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> { - val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth - contentMaxWidth * aspectRatio - } - else -> with(density) { 250.dp.toPx() } - } + val measuredHeight = measureScaledImageHeightPx( + block = block, + density = density, + contentMaxWidth = adjustedConstraints.maxWidth.toFloat(), + imageSizeMultiplier = imageSizeMultiplier + ) ?: with(density) { 250.dp.toPx() } val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt() Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})") @@ -767,14 +703,15 @@ private suspend fun measureBlockHeight( height } is QuoteBlock -> { + val quoteStyle = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign) val height = withContext(Dispatchers.Main) { textMeasurer.measure( text = block.content, - style = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign), + style = quoteStyle, constraints = adjustedConstraints ).size.height } - height + height + centeredTextSafetyPaddingPx(quoteStyle, density) } is ListItemBlock -> { val markerWidthPx = with(density) { 32.dp.toPx() }.toInt() @@ -811,7 +748,7 @@ private suspend fun measureBlockHeight( val cellConstraints = adjustedConstraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0)) - val cellContentHeight = calculateContentHeightWithMargins(cell.content, textMeasurer, cellConstraints, defaultStyle, headerStyle, density) + val cellContentHeight = calculateContentHeightWithMargins(cell.content, textMeasurer, cellConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier) var cellDecorationHeight = 0f with(density) { @@ -829,35 +766,18 @@ private suspend fun measureBlockHeight( val imageBlock = block.floatedImage val (imageWidthPx, imageHeightPx) = run { - val imageStyle = imageBlock.style - val intrinsicWidth = imageBlock.intrinsicWidth - val intrinsicHeight = imageBlock.intrinsicHeight - - if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f) { - 0f to 0f - } else { - val aspectRatio = intrinsicHeight / intrinsicWidth - val renderWidth = with(density) { - var w = intrinsicWidth - - if (imageStyle.width != Dp.Unspecified) { - w = imageStyle.width.toPx() - } - - if (imageStyle.maxWidth != Dp.Unspecified) { - w = w.coerceAtMost(imageStyle.maxWidth.toPx()) - } - - w.coerceAtMost(adjustedConstraints.maxWidth.toFloat()) - } - renderWidth to (renderWidth * aspectRatio) - } + measureScaledImageSizePx( + block = imageBlock, + density = density, + maxWidthPx = adjustedConstraints.maxWidth.toFloat(), + imageSizeMultiplier = imageSizeMultiplier + ) } // If image has no size, it can't float. Just measure the paragraphs. if (imageWidthPx <= 0 || imageHeightPx <= 0) { val height = block.paragraphsToWrap.sumOf { p -> - measureBlockHeight(p, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density) + measureBlockHeight(p, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier) } return height } @@ -949,10 +869,10 @@ private suspend fun measureBlockHeight( val isRow = block.style.flexDirection == "row" val height = if (isRow) { block.children.maxOfOrNull { child -> - measureBlockHeight(child, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density) + measureBlockHeight(child, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier) } ?: 0 } else { - calculateContentHeightWithMargins(block.children, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density) + calculateContentHeightWithMargins(block.children, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier) } height } @@ -980,7 +900,7 @@ private suspend fun measureBlockHeight( } } val specifiedHeightDp = block.style.height - val finalHeight = if (isBorderBox && specifiedHeightDp != Dp.Unspecified) { + val finalHeight = if (block.style.boxSizing == "border-box" && specifiedHeightDp != Dp.Unspecified) { with(density) { specifiedHeightDp.toPx().roundToInt() } } else { (contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt() @@ -1000,6 +920,10 @@ private suspend fun splitParagraphBlock( ): Pair? { val text = block.content if (text.isEmpty()) return null + val boxMetrics = computeBlockBoxMetrics(block, constraints, density) + val paragraphConstraints = boxMetrics.contentConstraints + val paragraphStyle = textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign) + val centeredSafetyPaddingPx = centeredTextSafetyPaddingPx(paragraphStyle, density) val decorationTop = with(density) { block.style.padding.top.toPx() + (block.style.borderTop?.width?.toPx() ?: 0f) @@ -1009,7 +933,7 @@ private suspend fun splitParagraphBlock( block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f) }.roundToInt() - val availableTextHeight = availableHeight - decorationTop - decorationBottom + val availableTextHeight = availableHeight - decorationTop - decorationBottom - centeredSafetyPaddingPx Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight") @@ -1021,8 +945,8 @@ private suspend fun splitParagraphBlock( val layoutResult = withContext(Dispatchers.Main) { textMeasurer.measure( text = text, - style = textStyle, - constraints = constraints.copy(maxHeight = Constraints.Infinity) + style = paragraphStyle, + constraints = paragraphConstraints ) } @@ -1036,7 +960,7 @@ private suspend fun splitParagraphBlock( var lastVisibleLine = layoutResult.getLineForVerticalPosition(availableTextHeight.toFloat()) - if (layoutResult.getLineBottom(lastVisibleLine) > availableHeight.toFloat()) { + if (layoutResult.getLineBottom(lastVisibleLine) > availableTextHeight.toFloat()) { lastVisibleLine-- } @@ -1056,7 +980,8 @@ private suspend fun splitParagraphBlock( val part2Layout = withContext(Dispatchers.Main) { textMeasurer.measure( text = part2CheckText, - constraints = constraints + style = paragraphStyle, + constraints = paragraphConstraints ) } if (part2Layout.lineCount == 1) { @@ -1149,11 +1074,12 @@ private suspend fun calculateContentHeightWithMargins( constraints: Constraints, defaultStyle: TextStyle, headerStyle: TextStyle, - density: Density + density: Density, + imageSizeMultiplier: Float = 1.0f ): Int { var totalHeight = 0 children.forEachIndexed { index, child -> - val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density) + val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density, imageSizeMultiplier) val margin = with(density) { if (index > 0) { val prevMargin = children[index - 1].style.margin.bottom.toPx() @@ -1172,6 +1098,117 @@ private suspend fun calculateContentHeightWithMargins( return totalHeight } +private data class BlockBoxMetrics( + val verticalPaddingPx: Float, + val verticalBorderPx: Float, + val contentConstraints: Constraints +) + +private fun computeBlockBoxMetrics( + block: ContentBlock, + constraints: Constraints, + density: Density +): BlockBoxMetrics { + val verticalPaddingPx: Float + val horizontalPaddingPx: Float + val verticalBorderPx: Float + val horizontalBorderPx: Float + + with(density) { + verticalPaddingPx = block.style.padding.top.toPx() + block.style.padding.bottom.toPx() + horizontalPaddingPx = block.style.padding.left.toPx() + block.style.padding.right.toPx() + verticalBorderPx = (block.style.borderTop?.width?.toPx() ?: 0f) + (block.style.borderBottom?.width?.toPx() ?: 0f) + horizontalBorderPx = (block.style.borderLeft?.width?.toPx() ?: 0f) + (block.style.borderRight?.width?.toPx() ?: 0f) + } + + val isBorderBox = block.style.boxSizing == "border-box" + val specifiedWidthDp = block.style.width + val specifiedMaxWidthDp = block.style.maxWidth + + val blockOuterWidthPx = with(density) { + var effectiveWidthPx = constraints.maxWidth.toFloat() + if (specifiedWidthDp != Dp.Unspecified) { + effectiveWidthPx = specifiedWidthDp.toPx() + } + if (specifiedMaxWidthDp != Dp.Unspecified) { + val maxWidthPx = specifiedMaxWidthDp.toPx() + if (effectiveWidthPx > maxWidthPx) { + effectiveWidthPx = maxWidthPx + } + } + effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat()) + } + + val contentMaxWidth = if (specifiedWidthDp == Dp.Unspecified || isBorderBox) { + blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx + } else { + blockOuterWidthPx + } + + return BlockBoxMetrics( + verticalPaddingPx = verticalPaddingPx, + verticalBorderPx = verticalBorderPx, + contentConstraints = constraints.copy( + maxWidth = contentMaxWidth.roundToInt().coerceAtLeast(0), + maxHeight = Constraints.Infinity + ) + ) +} + +private fun centeredTextSafetyPaddingPx( + style: TextStyle, + density: Density +): Int { + if (style.textAlign != androidx.compose.ui.text.style.TextAlign.Center) return 0 + + val fallbackLineHeight = if (style.fontSize.isSpecified) { + style.fontSize * 1.2f + } else { + 16.sp * 1.2f + } + val effectiveLineHeight = if (style.lineHeight.isSpecified) style.lineHeight else fallbackLineHeight + + return with(density) { effectiveLineHeight.toPx().roundToInt() } +} + +private fun measureScaledImageHeightPx( + block: ImageBlock, + density: Density, + contentMaxWidth: Float, + imageSizeMultiplier: Float +): Float? = measureScaledImageSizePx( + block = block, + density = density, + maxWidthPx = contentMaxWidth, + imageSizeMultiplier = imageSizeMultiplier +).second.takeIf { it > 0f } + +private fun measureScaledImageSizePx( + block: ImageBlock, + density: Density, + maxWidthPx: Float, + imageSizeMultiplier: Float +): Pair { + val intrinsicWidth = block.intrinsicWidth + val intrinsicHeight = block.intrinsicHeight + if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f || intrinsicHeight <= 0f) { + return 0f to 0f + } + + val aspectRatio = intrinsicHeight / intrinsicWidth + val baseWidth = with(density) { + if (block.style.width.isSpecified) block.style.width.toPx() else maxWidthPx + } + + var scaledWidth = baseWidth * imageSizeMultiplier + if (block.style.maxWidth.isSpecified) { + scaledWidth = scaledWidth.coerceAtMost(with(density) { block.style.maxWidth.toPx() } * imageSizeMultiplier) + } + scaledWidth = scaledWidth.coerceAtMost(maxWidthPx) + + return scaledWidth to (scaledWidth * aspectRatio) +} + private fun zeroOutBottomMargin(blocks: MutableList) { if (blocks.isNotEmpty()) { val lastBlock = blocks.last() @@ -1180,4 +1217,4 @@ private fun zeroOutBottomMargin(blocks: MutableList) { setBlockExpectedHeight(copyBlockWithNewStyle(lastBlock, newLastStyle), lastBlock.expectedHeight) blocks[blocks.size - 1] = newLastBlock } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt index 2324cff..04c0e3a 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt @@ -198,7 +198,7 @@ abstract class BookCacheDao { ConfigurationCache::class, AnchorIndexEntry::class ], - version = 7, + version = 8, exportSchema = false ) abstract class BookCacheDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt index 4661cac..62d39b2 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt @@ -25,7 +25,7 @@ import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -const val LATEST_PROCESSING_VERSION = 7 +const val LATEST_PROCESSING_VERSION = 8 @Entity(tableName = "processed_books") data class ProcessedBook( diff --git a/app/src/main/java/com/aryan/reader/pdf/DemoAnnotationGenerator.kt b/app/src/main/java/com/aryan/reader/pdf/DemoAnnotationGenerator.kt index 0d085bf..852ee9d 100644 --- a/app/src/main/java/com/aryan/reader/pdf/DemoAnnotationGenerator.kt +++ b/app/src/main/java/com/aryan/reader/pdf/DemoAnnotationGenerator.kt @@ -26,10 +26,8 @@ import com.aryan.reader.pdf.data.PdfAnnotation object DemoAnnotationGenerator { - // --- SVG Configuration --- private const val SVG_WIDTH = 800f - // Extracted from your Figma SVG private val DECORATIVE_DOTS = listOf( DotData(120f, 90f, 5f, Color(0xFFF59E0B), 0.7f), DotData(680f, 210f, 6f, Color(0xFFEC4899), 0.7f), @@ -72,47 +70,36 @@ object DemoAnnotationGenerator { fun generateDemoAnnotations(pageIndex: Int): List { val annotations = mutableListOf() - // --- Layout Calculation --- - // We want the SVG to occupy 80% of the page width, centered. - // PDF coordinates are 0..1. val targetWidthPercent = 0.8f - // SVG aspect ratio 300 / 800 = 0.375 - // Calculate scale factor relative to normalized page coordinates val scaleX = targetWidthPercent / SVG_WIDTH - val scaleY = scaleX // Keep uniform scale in abstract space // Center offsets (0.5 is middle of page) val startX = (1f - targetWidthPercent) / 2f - val startY = 0.4f // Position slightly above center vertically + val startY = 0.2f var currentTime = System.currentTimeMillis() - // Helper to transform SVG points to PDF Page Points fun transformPoint(x: Float, y: Float): PdfPoint { val pdfX = startX + (x * scaleX) - val pdfY = startY + (y * scaleY) + val pdfY = startY + (y * scaleX) return PdfPoint(pdfX, pdfY, currentTime) } - // 1. Render Decorative Dots DECORATIVE_DOTS.forEach { dot -> val pdfPoint = transformPoint(dot.cx, dot.cy) - // To make a "Dot" with the pen, we need at least 2 points very close together - // or a single point might not render depending on the implementation. val points = listOf( pdfPoint, pdfPoint.copy(x = pdfPoint.x + 0.0001f, timestamp = currentTime + 10) ) - // Convert SVG radius to stroke width val relativeThickness = (dot.r / SVG_WIDTH) * 2.5f annotations.add( PdfAnnotation( type = AnnotationType.INK, - inkType = InkType.PEN, // Standard pen for dots + inkType = InkType.PEN, pageIndex = pageIndex, points = points, color = dot.color.copy(alpha = dot.alpha), @@ -122,7 +109,6 @@ object DemoAnnotationGenerator { currentTime += 50 } - // 2. Render Text ("Try Episteme!") val textPaths = splitSvgPaths(TEXT_STROKES_DATA) textPaths.forEach { pathString -> val path = PathParser.createPathFromPathData(pathString) @@ -130,7 +116,6 @@ object DemoAnnotationGenerator { if (flattenedPoints.isNotEmpty()) { val pdfPoints = flattenedPoints.mapIndexed { _, p -> - // Increment time to simulate drawing speed for Fountain Pen physics currentTime += 8 transformPoint(p.x, p.y).copy(timestamp = currentTime) } @@ -138,18 +123,17 @@ object DemoAnnotationGenerator { annotations.add( PdfAnnotation( type = AnnotationType.INK, - inkType = InkType.FOUNTAIN_PEN, // Handwriting looks best with this + inkType = InkType.FOUNTAIN_PEN, pageIndex = pageIndex, points = pdfPoints, - color = Color(0xFF418377), // Updated Green - strokeWidth = 0.004f // Fine tip + color = Color(0xFF418377), + strokeWidth = 0.004f ) ) - currentTime += 150 // Pen lift delay + currentTime += 150 } } - // 3. Render Underline val underlinePath = PathParser.createPathFromPathData(UNDERLINE_DATA) val underlinePointsRaw = flattenPath(underlinePath) val underlinePdfPoints = underlinePointsRaw.map { p -> @@ -160,10 +144,10 @@ object DemoAnnotationGenerator { annotations.add( PdfAnnotation( type = AnnotationType.INK, - inkType = InkType.PEN, // Consistent width for underline + inkType = InkType.PEN, pageIndex = pageIndex, points = underlinePdfPoints, - color = Color(0xFFEC4899).copy(alpha = 0.6f), // Pink + color = Color(0xFFEC4899).copy(alpha = 0.6f), strokeWidth = 0.005f ) ) @@ -171,8 +155,6 @@ object DemoAnnotationGenerator { return annotations } - // --- Helpers --- - private data class DotData(val cx: Float, val cy: Float, val r: Float, val color: Color, val alpha: Float) private data class PointF(val x: Float, val y: Float) @@ -180,11 +162,9 @@ object DemoAnnotationGenerator { * Android's Path doesn't give us points directly. We use approximate(). */ private fun flattenPath(path: Path): List { - // Approximate the path with error tolerance 0.5 (pixels in SVG space) val approximation = path.approximate(0.5f) val points = mutableListOf() - // approximation array format: [t0, x0, y0, t1, x1, y1, ...] var i = 0 while (i < approximation.size) { val x = approximation[i + 1] @@ -204,15 +184,12 @@ object DemoAnnotationGenerator { val result = mutableListOf() rawPaths.forEach { fullPathString -> - // Clean up and standardize val cleanStr = fullPathString.trim() - // Split by "M" (Move command). val parts = cleanStr.split("M") parts.forEach { part -> if (part.isNotBlank()) { - // Re-prepend M because split removed it result.add("M ${part.trim()}") } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationDrawerContent.kt b/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt similarity index 60% rename from app/src/main/java/com/aryan/reader/pdf/PdfNavigationDrawerContent.kt rename to app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt index 2437e73..a7b813d 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationDrawerContent.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfDrawer.kt @@ -1,9 +1,24 @@ -// PdfNavigationDrawerContent.kt package com.aryan.reader.pdf +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed @@ -13,29 +28,260 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +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.ScrollableTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import io.legere.pdfiumandroid.api.Bookmark +import io.legere.pdfiumandroid.suspend.PdfDocumentKt import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import timber.log.Timber +import androidx.core.graphics.createBitmap + +private const val MAX_FIXED_RECURSION = 128 + +internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int) + +internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int) + +/** + * Patches the library bug where siblings are truncated due to depth-state leakage. + */ +suspend fun PdfDocumentKt.getFixedTableOfContents(): List { + val tag = "PdfTocFix" + Timber.tag(tag).i("Starting Pure Reflection Traversal...") + + return try { + // 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt + val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true } + val docUInstance = documentField.get(this) ?: return getTableOfContents() + + // 2. Get the 'nativeDocument' field from PdfDocumentU + val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true } + val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents() + + // 3. Get the native pointer (long) from PdfDocumentU + val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true } + val mNativeDocPtr = ptrField.get(docUInstance) as Long + + // 4. Look up native methods using primitive 'long' types (mandatory for JNI) + val nClass = nativeDocInstance.javaClass + val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long' + + val getTitleM = nClass.getMethod("getBookmarkTitle", lp) + val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp) + val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp) + val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp) + + val topLevel = mutableListOf() + val visited = mutableSetOf() + + /** + * Corrected traversal: Iterative for siblings, recursive for children. + */ + fun walk(parentList: MutableList, startPtr: Long, level: Int) { + var currentPtr = startPtr + var itemIndex = 0 + + while (currentPtr != 0L) { + if (visited.contains(currentPtr)) break + visited.add(currentPtr) + + val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled" + val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long + + Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title") + + val bookmark = Bookmark().apply { + this.mNativePtr = currentPtr + this.title = title + this.pageIdx = pageIdx + } + parentList.add(bookmark) + + // Recursive dive into children + val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long + if (firstChild != 0L && level < MAX_FIXED_RECURSION) { + walk(bookmark.children, firstChild, level + 1) + } + + // Iterative move to next sibling + currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long + itemIndex++ + } + } + + // 5. Start from the root (Pass 0L as primitive long) + val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long + if (firstRoot != 0L) { + walk(topLevel, firstRoot, 0) + } + + if (topLevel.isEmpty()) { + Timber.tag(tag).w("No items found, falling back to library.") + getTableOfContents() + } else { + Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}") + topLevel + } + } catch (e: Exception) { + Timber.tag(tag).e(e, "Reflection traversal critical error.") + this.getTableOfContents() + } +} + +internal fun flattenToc(bookmarks: List, level: Int = 0): List { + Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items") + val entries = mutableListOf() + for ((index, bookmark) in bookmarks.withIndex()) { + val title = bookmark.title ?: "Untitled Chapter" + val childCount = bookmark.children.size + + Timber.tag("PdfTocDebug").d( + "Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount" + ) + + entries.add( + TocEntry( + title = title, + pageIndex = bookmark.pageIdx.toInt(), + nestLevel = level + ) + ) + + if (childCount > 0) { + Timber.tag("PdfTocDebug").v("Entering children of \"$title\"") + entries.addAll(flattenToc(bookmark.children, level + 1)) + Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"") + } + } + return entries +} + +internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set { + if (bookmarksJson.isNullOrBlank()) return emptySet() + return try { + val jsonArray = JSONArray(bookmarksJson) + (0 until jsonArray.length()).mapNotNull { i -> + try { + val json = jsonArray.getJSONObject(i) + PdfBookmark( + pageIndex = json.getInt("pageIndex"), + title = json.getString("title"), + totalPages = json.getInt("totalPages") + ) + } catch (e: Exception) { + Timber.e(e, "Failed to parse bookmark from JSON object") + null + } + }.toSet() + } catch (e: Exception) { + Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson") + emptySet() + } +} + +@Composable +internal fun PdfTocTreeItem( + label: String, + nestLevel: Int, + isExpanded: Boolean, + hasChildren: Boolean, + isCurrent: Boolean, + onToggleExpand: () -> Unit, + onClick: () -> Unit +) { + val backgroundColor by animateColorAsState( + targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent, + label = "TocItemBackground" + ) + + val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .background(backgroundColor) + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width((16 * nestLevel).dp)) + + Box( + modifier = Modifier + .size(40.dp) + .clickable(enabled = hasChildren, onClick = onToggleExpand), + contentAlignment = Alignment.Center + ) { + if (hasChildren) { + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Text( + text = label, + style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium, + fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal, + color = contentColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(end = 16.dp) + ) + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun PdfNavigationDrawerContent( + pdfDocument: ReaderDocument?, flatTableOfContents: List, bookmarks: Set, userHighlights: List, currentPage: Int, + totalPages: Int, customHighlightColors: Map, onPageSelected: (Int) -> Unit, onRenameBookmark: (PdfBookmark, String) -> Unit, @@ -44,11 +290,15 @@ internal fun PdfNavigationDrawerContent( onNoteRequested: (String?) -> Unit, onCloseDrawer: () -> Unit ) { - val drawerPagerState = rememberPagerState(pageCount = { 3 }) + val drawerPagerState = rememberPagerState(pageCount = { 4 }) val drawerScope = rememberCoroutineScope() Column(modifier = Modifier.fillMaxSize()) { - TabRow(selectedTabIndex = drawerPagerState.currentPage) { + ScrollableTabRow( + selectedTabIndex = drawerPagerState.currentPage, + edgePadding = 8.dp, + modifier = Modifier.fillMaxWidth() + ) { Tab(selected = drawerPagerState.currentPage == 0, onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(0) } }, text = { Text("Chapters") }) @@ -68,6 +318,14 @@ internal fun PdfNavigationDrawerContent( text = { Text("Highlights") }, modifier = Modifier.testTag("HighlightsTab") ) + Tab( + selected = drawerPagerState.currentPage == 3, + onClick = { + drawerScope.launch { drawerPagerState.animateScrollToPage(3) } + }, + text = { Text("Pages") }, + modifier = Modifier.testTag("PagesTab") + ) } HorizontalPager( @@ -531,6 +789,130 @@ internal fun PdfNavigationDrawerContent( } } } + 3 -> { // Pages Page + val listState = rememberLazyListState() + val pageRows = remember(totalPages) { (0 until totalPages).chunked(3) } + + val currentRowIndex = currentPage / 3 + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + TextButton( + onClick = { + drawerScope.launch { + if (currentRowIndex in pageRows.indices) { + listState.animateScrollToItem(currentRowIndex) + } + } + } + ) { + Text("Locate") + } + } + + HorizontalDivider() + + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxHeight() + .padding(end = 12.dp) + ) { + items(pageRows, key = { it.firstOrNull() ?: 0 }) { row -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + row.forEach { pageIdx -> + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(0.707f) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp) + ) + .border( + width = if (currentPage == pageIdx) 2.dp else 1.dp, + color = if (currentPage == pageIdx) MaterialTheme.colorScheme.primary else Color.Black.copy(alpha = 0.1f), + shape = RoundedCornerShape(4.dp) + ) + .clickable { + onCloseDrawer() + onPageSelected(pageIdx) + }, + contentAlignment = Alignment.Center + ) { + var thumb by remember { mutableStateOf(PdfThumbnailCache.get(pageIdx)) } + + LaunchedEffect(pageIdx, pdfDocument) { + if (thumb == null && pdfDocument != null) { + withContext(kotlinx.coroutines.Dispatchers.IO) { + try { + val cached = PdfThumbnailCache.get(pageIdx) + if (cached != null) { + thumb = cached + } else { + pdfDocument.openPage(pageIdx)?.use { p -> + val w = p.getPageWidthPoint() + val h = p.getPageHeightPoint() + val ratio = if (h > 0) w.toFloat() / h.toFloat() else 1f + val thumbW = 200 + val thumbH = (thumbW / ratio).toInt().coerceAtLeast(1) + val bmp = createBitmap(thumbW, thumbH) + bmp.eraseColor(android.graphics.Color.WHITE) + p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false) + PdfThumbnailCache.put(pageIdx, bmp) + thumb = bmp + } + } + } catch (_: Exception) { } + } + } + } + + if (thumb != null) { + Image( + bitmap = thumb!!.asImageBitmap(), + contentDescription = "Page ${pageIdx + 1}", + modifier = Modifier.fillMaxSize() + ) + } + + Text( + text = "${pageIdx + 1}", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold + ), + color = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .background( + Color.Black.copy(alpha = 0.5f), + RoundedCornerShape(6.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + repeat(3 - row.size) { Spacer(modifier = Modifier.weight(1f)) } + } + } + } + VerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } + } } } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt b/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt index 898456e..624a5bb 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfNavigationUI.kt @@ -239,15 +239,45 @@ internal fun BookmarkButton( } @Composable -internal fun ZoomPercentageIndicator(percentage: Int) { +internal fun ZoomPercentageIndicator( + percentage: Int, + onResetZoomClick: () -> Unit +) { Surface( shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f) ) { - Text( - text = "$percentage%", - color = Color.White, - style = MaterialTheme.typography.bodyLarge, + androidx.compose.foundation.layout.Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) - ) + ) { + Text( + text = "$percentage%", + color = Color.White, + style = MaterialTheme.typography.bodyLarge + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Divider + Box( + modifier = Modifier + .width(1.dp) + .height(16.dp) + .background(Color.White.copy(alpha = 0.5f)) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Reset Zoom Button + Icon( + painter = painterResource(id = R.drawable.zoom_out), + contentDescription = "Reset Zoom", + tint = Color.White, + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onResetZoomClick) + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index bcf1d4f..b7c07c0 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -13,6 +13,7 @@ import android.graphics.RectF import android.graphics.Shader import android.util.LruCache import androidx.activity.compose.BackHandler +import androidx.compose.ui.graphics.drawscope.withTransform import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -83,6 +84,9 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.isTertiaryPressed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.input.pointer.util.VelocityTracker @@ -115,6 +119,7 @@ import androidx.core.graphics.scale import androidx.core.graphics.set import com.aryan.reader.R import com.aryan.reader.SearchResult +import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.VirtualPage @@ -187,6 +192,78 @@ data class PageLink( val source: LinkSource ) +private data class ExpandedBubbleRender( + val bitmap: Bitmap, + val zoomFactor: Float +) + +private fun computeDynamicBubbleZoomFactor( + bubbleBounds: RectF, + viewportWidth: Float, + viewportHeight: Float +): Float { + if (bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) return 1.5f + val targetWidth = viewportWidth * 0.6f + val targetHeight = viewportHeight * 0.32f + return min(targetWidth / bubbleBounds.width(), targetHeight / bubbleBounds.height()) + .coerceIn(1.35f, 4.25f) +} + +private fun isTapInsideBubble( + bubble: SpeechBubble, + tapX: Float, + tapY: Float, + hitSlopPx: Float +): Boolean { + val expandedBounds = RectF(bubble.bounds) + expandedBounds.inset(-hitSlopPx, -hitSlopPx) + if (!expandedBounds.contains(tapX, tapY)) return false + + val mask = bubble.maskBitmap ?: return true + if (!bubble.bounds.contains(tapX, tapY)) return true + + val normalizedX = ((tapX - bubble.bounds.left) / bubble.bounds.width()).coerceIn(0f, 0.999f) + val normalizedY = ((tapY - bubble.bounds.top) / bubble.bounds.height()).coerceIn(0f, 0.999f) + val maskX = (normalizedX * mask.width).toInt().coerceIn(0, mask.width - 1) + val maskY = (normalizedY * mask.height).toInt().coerceIn(0, mask.height - 1) + return AndroidColor.alpha(mask.getPixel(maskX, maskY)) > 24 +} + +private suspend fun renderExpandedBubbleBitmap( + document: ReaderDocument, + pageIndex: Int, + bubbleBounds: RectF, + pageWidth: Int, + pageHeight: Int, + renderScale: Float +): Bitmap? = withContext(Dispatchers.IO) { + if (pageWidth <= 0 || pageHeight <= 0 || bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) { + return@withContext null + } + + document.openPage(pageIndex)?.use { page -> + val cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1) + val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1) + val bitmap = createBitmap(cropWidth, cropHeight) + + try { + page.renderPageBitmap( + bitmap = bitmap, + startX = (-bubbleBounds.left * renderScale).roundToInt(), + startY = (-bubbleBounds.top * renderScale).roundToInt(), + drawSizeX = (pageWidth * renderScale).roundToInt().coerceAtLeast(cropWidth), + drawSizeY = (pageHeight * renderScale).roundToInt().coerceAtLeast(cropHeight), + renderAnnot = true + ) + bitmap + } catch (t: Throwable) { + bitmap.recycle() + Timber.tag("BubbleZoom").w(t, "Failed to render expanded bubble bitmap for page $pageIndex") + null + } + } +} + object PdfInkGeometry { fun calculateFountainPenPoints( points: List, baseWidth: Float, pageWidth: Float, pageHeight: Float @@ -394,7 +471,8 @@ internal fun PdfPageComposable( searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL, searchResultToHighlight: SearchResult?, ocrHoverHighlights: StableHolder> = StableHolder(emptyList()), - onSingleTap: () -> Unit, + onPreSingleTap: ((Offset) -> Boolean)? = null, + onSingleTap: (Offset?) -> Unit, isProUser: Boolean, onShowDictionaryUpsellDialog: () -> Unit, onWordSelectedForAiDefinition: (String) -> Unit, @@ -415,6 +493,7 @@ internal fun PdfPageComposable( isVerticalScroll: Boolean = false, visualScaleProvider: () -> Float = { 1f }, clearSelectionTrigger: Long = 0L, + resetZoomTrigger: Long = 0L, onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null, onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null, activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false), @@ -423,8 +502,8 @@ internal fun PdfPageComposable( isEditMode: Boolean = false, drawingState: PdfDrawingState? = null, pageAnnotations: () -> List = { emptyList() }, - onDrawStart: (PdfPoint) -> Unit = {}, - onDraw: (PdfPoint) -> Unit = {}, + onDrawStart: (PdfPoint, Boolean) -> Unit = { _, _ -> }, + onDraw: (PdfPoint, Boolean) -> Unit = { _, _ -> }, onDrawEnd: () -> Unit = {}, visibleScreenRect: () -> IntRect? = { null }, selectedTool: InkType = InkType.PEN, @@ -441,6 +520,7 @@ internal fun PdfPageComposable( isScrollLocked: Boolean = false, isVisible: Boolean = true, isActivePage: Boolean = true, + isBubbleZoomModeActive: Boolean = false, isStylusOnlyMode: Boolean = false, isAutoScrollPlaying: Boolean = false, isHighlighterSnapEnabled: Boolean = false, @@ -455,7 +535,7 @@ internal fun PdfPageComposable( onPaletteClick: (() -> Unit)? = null, lockedState: Triple? = null, onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null, - onDetectPanels: suspend (Bitmap) -> List = { emptyList() }, + onDetectBubbles: suspend (Int, Bitmap) -> List = { _, _ -> emptyList() }, onShowPanelPopup: (Bitmap) -> Unit = {} ) { val pdfDocumentItem = pdfDocument.item @@ -475,10 +555,7 @@ internal fun PdfPageComposable( LocalContext.current val viewConfiguration = LocalViewConfiguration.current val coroutineScope = rememberCoroutineScope() - - Timber.d( - "PdfPageComposable recompose: page=$pageIndex, isScrolling=$isScrolling, visualScale=$visualScaleProvider" - ) + var isStylusEraserOverride by remember { mutableStateOf(false) } var layoutCoordinates by remember { mutableStateOf(null) } @@ -493,6 +570,7 @@ internal fun PdfPageComposable( } val currentOnSingleTap by rememberUpdatedState(onSingleTap) + val currentOnPreSingleTap by rememberUpdatedState(onPreSingleTap) val currentOnDoubleTap by rememberUpdatedState(onDoubleTap) val effectiveScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale @@ -618,9 +696,7 @@ internal fun PdfPageComposable( } LaunchedEffect(centeringOffsetX, centeringOffsetY, pageIndex) { - Timber.d( - "PdfPageComposable Page $pageIndex | Centering Offset: x=$centeringOffsetX, y=$centeringOffsetY" - ) + } var showMagnifier by remember { mutableStateOf(false) } @@ -646,6 +722,132 @@ internal fun PdfPageComposable( screenOffset } + var detectedBubbles by remember(targetPageId) { mutableStateOf>(emptyList()) } + var expandedBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) } + var animatingBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) } + val bubbleExpansionProgress = remember(targetPageId) { Animatable(0f) } + var isDetectingBubbles by remember(targetPageId) { mutableStateOf(false) } + var expandedBubbleRender by remember(targetPageId) { mutableStateOf(null) } + val currentDetectedBubbles by rememberUpdatedState(detectedBubbles) + val currentExpandedBubbleIndex by rememberUpdatedState(expandedBubbleIndex) + val currentBubbleZoomModeActive by rememberUpdatedState(isBubbleZoomModeActive) + val bubbleTapSlopPx = with(density) { 18.dp.toPx() } + + LaunchedEffect(expandedBubbleIndex) { + if (expandedBubbleIndex != -1) { + if (animatingBubbleIndex != -1 && animatingBubbleIndex != expandedBubbleIndex) { + bubbleExpansionProgress.animateTo(0f, tween(150)) + } + animatingBubbleIndex = expandedBubbleIndex + bubbleExpansionProgress.animateTo(1f, tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing)) + } else { + bubbleExpansionProgress.animateTo(0f, tween(200, easing = androidx.compose.animation.core.FastOutLinearInEasing)) + animatingBubbleIndex = -1 + } + } + + LaunchedEffect( + isBubbleZoomModeActive, + isActivePage, + isPdfPage, + pdfPageIndex, + bitmapState, + actualBitmapWidthPx, + actualBitmapHeightPx + ) { + Timber.tag("BubbleZoom").d("LaunchedEffect triggered. modeActive=$isBubbleZoomModeActive, activePage=$isActivePage, hasBitmap=${bitmapState != null}, dims=${actualBitmapWidthPx}x${actualBitmapHeightPx}") + + if (isBubbleZoomModeActive && isActivePage && isPdfPage && bitmapState != null && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0) { + Timber.tag("BubbleZoom").d("Conditions met. Starting detection...") + isDetectingBubbles = true + try { + val rawBubbles = onDetectBubbles(pdfPageIndex, bitmapState!!) + Timber.tag("BubbleZoom").d("Detection complete. Found ${rawBubbles.size} raw bubbles.") + + // NEW: Scale bubbles down from render bitmap space to logical screen space + val scaleX = actualBitmapWidthPx.toFloat() / bitmapState!!.width.toFloat() + val scaleY = actualBitmapHeightPx.toFloat() / bitmapState!!.height.toFloat() + + val logicalBubbles = rawBubbles.map { b -> + b.copy(bounds = android.graphics.RectF( + b.bounds.left * scaleX, + b.bounds.top * scaleY, + b.bounds.right * scaleX, + b.bounds.bottom * scaleY + )) + } + + val rowHeight = actualBitmapHeightPx * 0.1f + detectedBubbles = logicalBubbles.sortedWith(compareBy { (it.bounds.centerY() / rowHeight).roundToInt() }.thenBy { it.bounds.centerX() }) + expandedBubbleIndex = -1 + + Timber.tag("BubbleZoom").d("Sorted logical bubbles count: ${detectedBubbles.size}") + } catch (e: Exception) { + Timber.tag("BubbleZoom").e(e, "Bubble detection failed with exception") + } finally { + isDetectingBubbles = false + } + } else { + Timber.tag("BubbleZoom").d("Conditions NOT met or mode disabled. Clearing bubbles.") + detectedBubbles = emptyList() + expandedBubbleIndex = -1 + expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle() + expandedBubbleRender = null + if (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) { + coroutineScope.launch { + Animatable(scale).animateTo(1f, tween(300)) { + scale = this.value + offset = Offset.Zero + onScaleChanged(scale) + } + } + } + } + } + + LaunchedEffect( + animatingBubbleIndex, + detectedBubbles, + actualBitmapWidthPx, + actualBitmapHeightPx, + canvasWidthPx.floatValue, + canvasHeightPx.floatValue, + isBubbleZoomModeActive, + isPdfPage, + pdfPageIndex + ) { + val previousRender = expandedBubbleRender + expandedBubbleRender = null + previousRender?.bitmap?.takeUnless { it.isRecycled }?.recycle() + + if (!isBubbleZoomModeActive || !isPdfPage || animatingBubbleIndex !in detectedBubbles.indices) { + return@LaunchedEffect + } + + val bubble = detectedBubbles[animatingBubbleIndex] + val zoomFactor = computeDynamicBubbleZoomFactor( + bubbleBounds = bubble.bounds, + viewportWidth = canvasWidthPx.floatValue.coerceAtLeast(actualBitmapWidthPx.toFloat()), + viewportHeight = canvasHeightPx.floatValue.coerceAtLeast(actualBitmapHeightPx.toFloat()) + ) + val renderScale = (zoomFactor * 1.2f).coerceAtLeast(1.6f) + val renderedBubble = renderExpandedBubbleBitmap( + document = pdfDocumentItem, + pageIndex = pdfPageIndex, + bubbleBounds = bubble.bounds, + pageWidth = actualBitmapWidthPx, + pageHeight = actualBitmapHeightPx, + renderScale = renderScale + ) + + if (renderedBubble != null) { + expandedBubbleRender = ExpandedBubbleRender( + bitmap = renderedBubble, + zoomFactor = zoomFactor + ) + } + } + DisposableEffect(Unit) { onDispose { val currentBitmap = bitmapState @@ -653,6 +855,7 @@ internal fun PdfPageComposable( if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) { currentBitmap.recycle() } + expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle() } } @@ -1646,6 +1849,32 @@ internal fun PdfPageComposable( } } + LaunchedEffect(resetZoomTrigger) { + if (resetZoomTrigger != 0L && scale > 1f && isZoomEnabled && !isVerticalScroll && !isScrollLocked) { + coroutineScope.launch { + val startScale = scale + val startOffset = offset + Animatable(0f).animateTo( + 1f, animationSpec = tween(durationMillis = 300) + ) { + val progress = value + scale = androidx.compose.ui.util.lerp( + startScale, 1f, progress + ) + offset = androidx.compose.ui.geometry.lerp( + startOffset, Offset.Zero, progress + ) + onScaleChanged(scale) + } + if (scale <= 1.05f) { + scale = 1f + offset = Offset.Zero + onScaleChanged(scale) + } + } + } + } + val errorSelection = stringResource(R.string.error_selection) val errorOcrSelection = stringResource(R.string.error_ocr_selection) val errorProcessingPage = stringResource(R.string.error_processing_page) @@ -2487,7 +2716,8 @@ internal fun PdfPageComposable( isEditMode, selectedTool, isStylusOnlyMode, - userHighlightScreenRects + userHighlightScreenRects, + bubbleTapSlopPx ) { val isTapDetectionAllowed = !isEditMode || selectedTool == InkType.TEXT || @@ -2496,9 +2726,48 @@ internal fun PdfPageComposable( if (!isTapDetectionAllowed) return@pointerInput detectTapGestures(onTap = { tapOffset -> + if (currentOnPreSingleTap?.invoke(tapOffset) == true) { + return@detectTapGestures + } + val tapInContentCoords = screenToContentCoordinates(tapOffset) val tapXInBitmap = tapInContentCoords.x val tapYInBitmap = tapInContentCoords.y + val isWithinContentBounds = + tapXInBitmap in 0f..actualBitmapWidthPx.toFloat() && + tapYInBitmap in 0f..actualBitmapHeightPx.toFloat() + + if (!isWithinContentBounds) { + currentOnSingleTap(tapOffset) + return@detectTapGestures + } + + Timber.tag("BubbleZoom").d("Tap inside bounds. modeActive=$currentBubbleZoomModeActive, detectedBubbles=${currentDetectedBubbles.size}, tapPos=($tapXInBitmap, $tapYInBitmap)") + + if (currentBubbleZoomModeActive && currentDetectedBubbles.isNotEmpty()) { + val tappedBubbleIndex = currentDetectedBubbles.indexOfFirst { bubble -> + isTapInsideBubble( + bubble = bubble, + tapX = tapXInBitmap, + tapY = tapYInBitmap, + hitSlopPx = bubbleTapSlopPx + ) + } + + Timber.tag("BubbleZoom").d("Tapped bubble index: $tappedBubbleIndex (expandedIndex=$currentExpandedBubbleIndex)") + + if (tappedBubbleIndex != -1) { + expandedBubbleIndex = if (currentExpandedBubbleIndex == tappedBubbleIndex) { + -1 + } else { + tappedBubbleIndex + } + return@detectTapGestures + } else if (currentExpandedBubbleIndex != -1) { + expandedBubbleIndex = -1 + return@detectTapGestures + } + } coroutineScope.launch { val nativeResult = withContext(Dispatchers.IO) { @@ -2660,7 +2929,7 @@ internal fun PdfPageComposable( currentPageRotation, ) } else { - currentOnSingleTap() + currentOnSingleTap(tapOffset) } } }, onDoubleTap = { tapOffset -> @@ -2670,37 +2939,6 @@ internal fun PdfPageComposable( val startScale = scale val targetScale = if (startScale > 1.1f) 1f else 2.5f - if (com.aryan.reader.BuildConfig.DEBUG && startScale <= 1.1f && bitmapState != null) { - val tapInContentCoords = screenToContentCoordinates(tapOffset) - - val ratioX = bitmapState!!.width.toFloat() / actualBitmapWidthPx.toFloat() - val ratioY = bitmapState!!.height.toFloat() / actualBitmapHeightPx.toFloat() - val tapXInBitmap = tapInContentCoords.x * ratioX - val tapYInBitmap = tapInContentCoords.y * ratioY - - val panels = onDetectPanels(bitmapState!!) - - val tappedPanel = panels.firstOrNull { - it.contains(tapXInBitmap, tapYInBitmap) - } - - if (tappedPanel != null) { - Timber.d("Popup: Cropping panel $tappedPanel") - val left = tappedPanel.left.coerceAtLeast(0f).toInt() - val top = tappedPanel.top.coerceAtLeast(0f).toInt() - val right = tappedPanel.right.coerceAtMost(bitmapState!!.width.toFloat()).toInt() - val bottom = tappedPanel.bottom.coerceAtMost(bitmapState!!.height.toFloat()).toInt() - val width = right - left - val height = bottom - top - - if (width > 0 && height > 0) { - val cropped = android.graphics.Bitmap.createBitmap(bitmapState!!, left, top, width, height) - onShowPanelPopup(cropped) - return@launch - } - } - } - val startOffset = offset val targetOffsetUnbounded = if (targetScale <= 1.1f) { Offset.Zero @@ -3017,12 +3255,20 @@ internal fun PdfPageComposable( return@awaitEachGesture } + val buttons = currentEvent.buttons + Timber.tag("StylusEraserDiagnostic").d( + "Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons" + ) + + val isEraserOverride = down.type == PointerType.Eraser || (down.type == PointerType.Stylus && currentEvent.buttons.isSecondaryPressed) + isStylusEraserOverride = isEraserOverride + val dragPointerId = down.id val startPos = down.position var dragStarted = false val touchSlop = viewConfiguration.touchSlop - if (selectedTool == InkType.ERASER) { + if (selectedTool == InkType.ERASER || isEraserOverride) { eraserPosition = down.position } @@ -3034,6 +3280,7 @@ internal fun PdfPageComposable( drawingState?.onDrawCancel() } eraserPosition = null + isStylusEraserOverride = false return@awaitEachGesture } @@ -3051,12 +3298,13 @@ internal fun PdfPageComposable( val normY = (contentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f) - onDrawStart(PdfPoint(normX, normY)) + onDrawStart(PdfPoint(normX, normY), isEraserOverride) onDrawEnd() } else { onDrawEnd() } eraserPosition = null + isStylusEraserOverride = false return@awaitEachGesture } @@ -3077,7 +3325,7 @@ internal fun PdfPageComposable( 0f, 1f ) onDrawStart( - PdfPoint(startNormX, startNormY) + PdfPoint(startNormX, startNormY), isEraserOverride ) val currContentPos = screenToContentCoordinates( @@ -3091,9 +3339,9 @@ internal fun PdfPageComposable( (currContentPos.y / actualBitmapHeightPx).coerceIn( 0f, 1f ) - onDraw(PdfPoint(currNormX, currNormY)) + onDraw(PdfPoint(currNormX, currNormY), isEraserOverride) - if (selectedTool == InkType.ERASER) { + if (selectedTool == InkType.ERASER || isEraserOverride) { eraserPosition = change.position } change.consume() @@ -3106,9 +3354,9 @@ internal fun PdfPageComposable( (currContentPos.x / actualBitmapWidthPx).coerceIn(0f, 1f) val currNormY = (currContentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f) - onDraw(PdfPoint(currNormX, currNormY)) + onDraw(PdfPoint(currNormX, currNormY), isEraserOverride) - if (selectedTool == InkType.ERASER) { + if (selectedTool == InkType.ERASER || isEraserOverride) { eraserPosition = change.position } change.consume() @@ -3118,6 +3366,7 @@ internal fun PdfPageComposable( } } finally { eraserPosition = null + isStylusEraserOverride = false } }, contentAlignment = Alignment.Center ) { @@ -3230,10 +3479,6 @@ internal fun PdfPageComposable( offset = Offset.Zero onScaleChanged(1f) } - - Timber.d( - "PdfPageComposable Page $pageIndex initialized/resized/locked. scale=$scale, offset=$offset" - ) } LaunchedEffect( @@ -3386,15 +3631,8 @@ internal fun PdfPageComposable( val viewContainerHeightPx = with(density) { currentContainerMaxHeight.toPx().toInt() } - Timber.d( - "PdfPageComposable Page $pageIndex | viewContainerPx: ${viewContainerWidthPx}x${viewContainerHeightPx}" - ) - if (viewContainerWidthPx <= 0 || viewContainerHeightPx <= 0) { if (bitmapState == null) isLoadingPage = true - Timber.d( - "PdfPageComposable: viewContainer dimensions invalid ($viewContainerWidthPx x $viewContainerHeightPx), waiting." - ) return@LaunchedEffect } @@ -3948,6 +4186,7 @@ internal fun PdfPageComposable( isEditMode = isEditMode, selectedTool = selectedTool, eraserPosition = eraserPosition, + isStylusEraserOverride = isStylusEraserOverride, activeToolThickness = activeToolThickness, richTextController = richTextController, textBoxes = textBoxes, @@ -3960,7 +4199,14 @@ internal fun PdfPageComposable( onDragPageTurn = onDragPageTurn, draggingBoxId = draggingBoxId, customHighlightColors = customHighlightColors, - onPaletteClick = onPaletteClick + onPaletteClick = onPaletteClick, + isBubbleZoomModeActive = isBubbleZoomModeActive, + isActivePage = isActivePage, + isDetectingBubbles = isDetectingBubbles, + detectedBubbles = detectedBubbles, + animatingBubbleIndex = animatingBubbleIndex, + bubbleExpansionProgress = bubbleExpansionProgress.value, + expandedBubbleRender = expandedBubbleRender ) } @@ -4080,15 +4326,10 @@ private fun PdfBitmapLayer( if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) { imageRects.forEach { imgRect -> - val scaledImgRectLeft = (imgRect.left * effectiveScale).roundToInt() - val scaledImgRectTop = (imgRect.top * effectiveScale).roundToInt() - val scaledImgRectRight = (imgRect.right * effectiveScale).roundToInt() - val scaledImgRectBottom = (imgRect.bottom * effectiveScale).roundToInt() - - val intersectLeft = max(scaledImgRectLeft, tile.renderRect.left) - val intersectTop = max(scaledImgRectTop, tile.renderRect.top) - val intersectRight = min(scaledImgRectRight, tile.renderRect.right) - val intersectBottom = min(scaledImgRectBottom, tile.renderRect.bottom) + val intersectLeft = max(imgRect.left, tile.renderRect.left) + val intersectTop = max(imgRect.top, tile.renderRect.top) + val intersectRight = min(imgRect.right, tile.renderRect.right) + val intersectBottom = min(imgRect.bottom, tile.renderRect.bottom) val iw = intersectRight - intersectLeft val ih = intersectBottom - intersectTop @@ -4151,7 +4392,6 @@ private fun PdfHighlightsLayer( selectionHighlightColor: Color, customHighlightColors: Map = emptyMap() ) { - Timber.d("PdfHighlightsLayer Recompose") Canvas(modifier = Modifier .fillMaxSize() .graphicsLayer()) { @@ -4751,6 +4991,7 @@ private fun PdfPageRenderer( isEditMode: Boolean, selectedTool: InkType, eraserPosition: Offset?, + isStylusEraserOverride: Boolean, richTextController: RichTextController?, textBoxes: List, selectedTextBoxId: String?, @@ -4769,6 +5010,13 @@ private fun PdfPageRenderer( onTts: (Int, Int) -> Unit, activeToolThickness: Float, onNote: (String?) -> Unit, + isBubbleZoomModeActive: Boolean = false, + isActivePage: Boolean = true, + isDetectingBubbles: Boolean = false, + detectedBubbles: List = emptyList(), + animatingBubbleIndex: Int = -1, + bubbleExpansionProgress: Float = 0f, + expandedBubbleRender: ExpandedBubbleRender? = null ) { Box(modifier = Modifier.fillMaxSize()) { Box( @@ -4976,7 +5224,7 @@ private fun PdfPageRenderer( val teardropPainter = painterResource(id = R.drawable.teardrop) - if (isEditMode && selectedTool == InkType.ERASER && eraserPosition != null) { + if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) { Canvas(modifier = Modifier.fillMaxSize()) { val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) { activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale @@ -5203,6 +5451,150 @@ private fun PdfPageRenderer( if (isPerformingOcr && ocrRipplePos != null) { OcrProcessingIndicator(position = ocrRipplePos) } + + if (isBubbleZoomModeActive && isActivePage) { + if (isDetectingBubbles) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } else if (detectedBubbles.isNotEmpty()) { + Canvas(modifier = Modifier.fillMaxSize().zIndex(20f)) { + // Draw shadow-like hints for unexpanded bubbles + detectedBubbles.forEachIndexed { index, bubble -> + val hintAlpha = if (index == animatingBubbleIndex) 0.35f * (1f - bubbleExpansionProgress) else 0.35f + if (hintAlpha > 0f) { + val left = bubble.bounds.left + staticData.centeringOffsetX + val top = bubble.bounds.top + staticData.centeringOffsetY + val width = bubble.bounds.width() + val height = bubble.bounds.height() + + if (bubble.maskBitmap != null) { + drawImage( + image = bubble.maskBitmap.asImageBitmap(), + dstOffset = IntOffset(left.toInt(), top.toInt()), + dstSize = IntSize(width.toInt(), height.toInt()), + colorFilter = ColorFilter.tint(Color.Black.copy(alpha = hintAlpha)), + filterQuality = androidx.compose.ui.graphics.FilterQuality.High + ) + } else { + drawRoundRect( + color = Color.Black.copy(alpha = hintAlpha), + topLeft = Offset(left, top), + size = Size(width, height), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f) + ) + } + } + } + + if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) { + val bubble = detectedBubbles[animatingBubbleIndex] + val left = bubble.bounds.left + staticData.centeringOffsetX + val top = bubble.bounds.top + staticData.centeringOffsetY + val logicalWidth = bubble.bounds.width() + val logicalHeight = bubble.bounds.height() + val pivotX = left + logicalWidth / 2f + val pivotY = top + logicalHeight / 2f + val targetZoomFactor = expandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor( + bubbleBounds = bubble.bounds, + viewportWidth = staticData.canvasWidth, + viewportHeight = staticData.canvasHeight + ) + val zoomFactor = androidx.compose.ui.util.lerp(1f, targetZoomFactor, bubbleExpansionProgress) + + withTransform({ + scale(zoomFactor, zoomFactor, Offset(pivotX, pivotY)) + }) { + val dstOffset = IntOffset(left.toInt(), top.toInt()) + val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt()) + + val renderScaleX = staticData.bitmap.item.width.toFloat() / staticData.targetWidth.toFloat() + val renderScaleY = staticData.bitmap.item.height.toFloat() / staticData.targetHeight.toFloat() + + val srcOffset = IntOffset( + (bubble.bounds.left * renderScaleX).toInt(), + (bubble.bounds.top * renderScaleY).toInt() + ) + val srcSize = IntSize( + (logicalWidth * renderScaleX).toInt(), + (logicalHeight * renderScaleY).toInt() + ) + + if (bubble.maskBitmap != null) { + drawImage( + image = bubble.maskBitmap.asImageBitmap(), + dstOffset = IntOffset(left.toInt() + 12, top.toInt() + 12), + dstSize = dstSize, + colorFilter = ColorFilter.tint(Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress)), + filterQuality = androidx.compose.ui.graphics.FilterQuality.High + ) + } else { + drawRoundRect( + color = Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress), + topLeft = Offset(left + 12f, top + 12f), + size = Size(logicalWidth, logicalHeight), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f) + ) + } + + if (bubble.maskBitmap != null) { + val rect = androidx.compose.ui.geometry.Rect( + dstOffset.x.toFloat(), + dstOffset.y.toFloat(), + dstOffset.x.toFloat() + dstSize.width, + dstOffset.y.toFloat() + dstSize.height + ) + drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint()) + drawImage( + image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(), + srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset, + srcSize = if (expandedBubbleRender != null) { + IntSize( + expandedBubbleRender.bitmap.width, + expandedBubbleRender.bitmap.height) + } else { + srcSize + }, + dstOffset = dstOffset, + dstSize = dstSize, + filterQuality = androidx.compose.ui.graphics.FilterQuality.High + ) + drawImage( + image = bubble.maskBitmap.asImageBitmap(), + dstOffset = dstOffset, + dstSize = dstSize, + blendMode = BlendMode.DstIn, + filterQuality = androidx.compose.ui.graphics.FilterQuality.High + ) + drawContext.canvas.restore() + } else { + clipRect(left, top, left + logicalWidth, top + logicalHeight) { + drawImage( + image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(), + srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset, + srcSize = if (expandedBubbleRender != null) { + IntSize( + expandedBubbleRender.bitmap.width, + expandedBubbleRender.bitmap.height) + } else { + srcSize + }, + dstOffset = dstOffset, + dstSize = dstSize + ) + } + drawRect( + color = Color.White.copy(alpha = 0.5f * bubbleExpansionProgress), + topLeft = Offset(left, top), + size = Size(logicalWidth, logicalHeight), + style = Stroke(width = 4f) + ) + } + } + } + } + } + } } } @@ -5409,4 +5801,4 @@ private fun getNativePointer(obj: Any): Long { } catch (_: Exception) {} return 0L -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt index 8ad1040..044edde 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPreferences.kt @@ -47,6 +47,7 @@ enum class PdfReaderTool(val title: String, val category: String) { THEME("Theme Settings", "Top Bar"), LOCK_PANNING("Lock Panning", "Top Bar"), VISUAL_OPTIONS("Visual Options", "Overflow Menu"), + TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"), FULL_SCREEN("Full Screen", "Top Bar"), SLIDER("Navigation Slider", "Bottom Bar"), TOC("Sidebar", "Bottom Bar"), @@ -370,4 +371,4 @@ internal fun savePdfDarkMode(context: Context, isDark: Boolean) { internal fun loadPdfDarkMode(context: Context): Boolean { val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) return prefs.getBoolean(PDF_DARK_MODE_KEY, false) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfTocAndBookmarks.kt b/app/src/main/java/com/aryan/reader/pdf/PdfTocAndBookmarks.kt deleted file mode 100644 index 440a571..0000000 --- a/app/src/main/java/com/aryan/reader/pdf/PdfTocAndBookmarks.kt +++ /dev/null @@ -1,229 +0,0 @@ -package com.aryan.reader.pdf - -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import io.legere.pdfiumandroid.api.Bookmark -import io.legere.pdfiumandroid.suspend.PdfDocumentKt -import org.json.JSONArray -import timber.log.Timber - -private const val MAX_FIXED_RECURSION = 128 - -internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int) - -internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int) - -/** - * Patches the library bug where siblings are truncated due to depth-state leakage. - */ -suspend fun PdfDocumentKt.getFixedTableOfContents(): List { - val tag = "PdfTocFix" - Timber.tag(tag).i("Starting Pure Reflection Traversal...") - - return try { - // 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt - val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true } - val docUInstance = documentField.get(this) ?: return getTableOfContents() - - // 2. Get the 'nativeDocument' field from PdfDocumentU - val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true } - val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents() - - // 3. Get the native pointer (long) from PdfDocumentU - val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true } - val mNativeDocPtr = ptrField.get(docUInstance) as Long - - // 4. Look up native methods using primitive 'long' types (mandatory for JNI) - val nClass = nativeDocInstance.javaClass - val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long' - - val getTitleM = nClass.getMethod("getBookmarkTitle", lp) - val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp) - val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp) - val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp) - - val topLevel = mutableListOf() - val visited = mutableSetOf() - - /** - * Corrected traversal: Iterative for siblings, recursive for children. - */ - fun walk(parentList: MutableList, startPtr: Long, level: Int) { - var currentPtr = startPtr - var itemIndex = 0 - - while (currentPtr != 0L) { - if (visited.contains(currentPtr)) break - visited.add(currentPtr) - - val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled" - val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long - - Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title") - - val bookmark = Bookmark().apply { - this.mNativePtr = currentPtr - this.title = title - this.pageIdx = pageIdx - } - parentList.add(bookmark) - - // Recursive dive into children - val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long - if (firstChild != 0L && level < MAX_FIXED_RECURSION) { - walk(bookmark.children, firstChild, level + 1) - } - - // Iterative move to next sibling - currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long - itemIndex++ - } - } - - // 5. Start from the root (Pass 0L as primitive long) - val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long - if (firstRoot != 0L) { - walk(topLevel, firstRoot, 0) - } - - if (topLevel.isEmpty()) { - Timber.tag(tag).w("No items found, falling back to library.") - getTableOfContents() - } else { - Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}") - topLevel - } - } catch (e: Exception) { - Timber.tag(tag).e(e, "Reflection traversal critical error.") - this.getTableOfContents() - } -} - -internal fun flattenToc(bookmarks: List, level: Int = 0): List { - Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items") - val entries = mutableListOf() - for ((index, bookmark) in bookmarks.withIndex()) { - val title = bookmark.title ?: "Untitled Chapter" - val childCount = bookmark.children.size - - Timber.tag("PdfTocDebug").d( - "Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount" - ) - - entries.add( - TocEntry( - title = title, - pageIndex = bookmark.pageIdx.toInt(), - nestLevel = level - ) - ) - - if (childCount > 0) { - Timber.tag("PdfTocDebug").v("Entering children of \"$title\"") - entries.addAll(flattenToc(bookmark.children, level + 1)) - Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"") - } - } - return entries -} - -internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set { - if (bookmarksJson.isNullOrBlank()) return emptySet() - return try { - val jsonArray = JSONArray(bookmarksJson) - (0 until jsonArray.length()).mapNotNull { i -> - try { - val json = jsonArray.getJSONObject(i) - PdfBookmark( - pageIndex = json.getInt("pageIndex"), - title = json.getString("title"), - totalPages = json.getInt("totalPages") - ) - } catch (e: Exception) { - Timber.e(e, "Failed to parse bookmark from JSON object") - null - } - }.toSet() - } catch (e: Exception) { - Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson") - emptySet() - } -} - -@Composable -internal fun PdfTocTreeItem( - label: String, - nestLevel: Int, - isExpanded: Boolean, - hasChildren: Boolean, - isCurrent: Boolean, - onToggleExpand: () -> Unit, - onClick: () -> Unit -) { - val backgroundColor by animateColorAsState( - targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent, - label = "TocItemBackground" - ) - - val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .background(backgroundColor) - .clickable(onClick = onClick) - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(modifier = Modifier.width((16 * nestLevel).dp)) - - Box( - modifier = Modifier - .size(40.dp) - .clickable(enabled = hasChildren, onClick = onToggleExpand), - contentAlignment = Alignment.Center - ) { - if (hasChildren) { - Icon( - imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = if (isExpanded) "Collapse" else "Expand", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Text( - text = label, - style = if (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium, - fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal, - color = contentColor, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f).padding(end = 16.dp) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt index 245e085..2874eed 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToolbars.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Undo import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -33,6 +34,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.sp import com.aryan.reader.BuildConfig import com.aryan.reader.FileType import com.aryan.reader.R @@ -81,6 +83,8 @@ internal fun PdfTopBar( onShowCustomizeTools: () -> Unit, onShowOcrLanguage: () -> Unit, onShowVisualOptions: () -> Unit, + tapToNavigateEnabled: Boolean, + onToggleTapToNavigate: () -> Unit, onChangeDisplayMode: (DisplayMode) -> Unit, onToggleKeepScreenOn: () -> Unit, onStartAutoScroll: () -> Unit, @@ -94,7 +98,8 @@ internal fun PdfTopBar( onPrint: () -> Unit, onTabClick: (String) -> Unit, onTabClose: (String) -> Unit, - onNewTabClick: () -> Unit + onNewTabClick: () -> Unit, + onGenerateDemoAnnotations: () -> Unit ) { AnimatedVisibility( visible = showStandardBars, @@ -179,6 +184,9 @@ internal fun PdfTopBar( } if (BuildConfig.DEBUG) { + TooltipIconButton(text = "Demo Annotations", onClick = onGenerateDemoAnnotations) { + Icon(Icons.Default.BugReport, contentDescription = "Generate Demo Annotations", tint = MaterialTheme.colorScheme.secondary) + } TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) { Icon(Icons.Default.Star, contentDescription = "Open Pen Playground", tint = MaterialTheme.colorScheme.primary) } @@ -238,6 +246,26 @@ internal fun PdfTopBar( HorizontalDivider() } + if (!hiddenTools.contains(PdfReaderTool.TAP_TO_TURN.name)) { + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) }, + enabled = displayMode == DisplayMode.PAGINATION, + onClick = { + onToggleTapToNavigate() + showMoreMenu = false + }, + trailingIcon = { + if (tapToNavigateEnabled) { + Icon( + Icons.Filled.Check, + contentDescription = stringResource(R.string.content_desc_enabled) + ) + } + } + ) + HorizontalDivider() + } + if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) { DropdownMenuItem( text = { Text(stringResource(R.string.menu_keep_screen_on)) }, @@ -433,13 +461,17 @@ fun PdfBottomBar( isEditMode: Boolean, isTtsSessionActive: Boolean, ttsErrorMessage: String?, + jumpBackPage: Int?, + onJumpBack: () -> Unit, onShowSlider: () -> Unit, onShowToc: () -> Unit, onSearchClick: () -> Unit, onToggleHighlights: () -> Unit, onShowAiHub: () -> Unit, onToggleEditMode: () -> Unit, - onToggleTts: () -> Unit + onToggleTts: () -> Unit, + isBubbleZoomModeActive: Boolean, + onToggleBubbleZoom: () -> Unit ) { AnimatedVisibility( visible = showStandardBars && !searchStateActive, @@ -456,8 +488,35 @@ fun PdfBottomBar( Row( modifier = Modifier.fillMaxWidth().padding(bottom = bottomBarPadding).height(56.dp).padding(horizontal = 8.dp).horizontalScroll(bottomBarScrollState), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.SpaceEvenly ) { + if (jumpBackPage != null) { + TooltipIconButton( + text = "Jump Back to Page ${jumpBackPage + 1}", + description = "Return to previous page", + onClick = onJumpBack + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + Icons.AutoMirrored.Filled.Undo, + contentDescription = "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 + ) + } + } + } + if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) { TooltipIconButton( text = stringResource(R.string.tooltip_slider), @@ -532,10 +591,24 @@ fun PdfBottomBar( } } + if (BuildConfig.FLAVOR != "oss") { + TooltipIconButton( + text = if (isBubbleZoomModeActive) "Exit Smart Zoom" else "Smart Comic Zoom", + description = "Toggle Smart Comic Zoom", + onClick = onToggleBubbleZoom + ) { + Icon( + painterResource(R.drawable.comic_bubble), + contentDescription = "Smart Comic Zoom", + tint = if (isBubbleZoomModeActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ttsErrorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f).padding(start = 8.dp), maxLines = 2, overflow = TextOverflow.Ellipsis) } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index 2e7ba9d..95f4947 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -23,6 +23,7 @@ package com.aryan.reader.pdf import android.annotation.SuppressLint +import android.graphics.Bitmap import android.graphics.RectF import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState @@ -90,6 +91,9 @@ import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.isTertiaryPressed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.input.pointer.util.VelocityTracker @@ -108,6 +112,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import com.aryan.reader.SearchResult +import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.VirtualPage @@ -217,8 +222,8 @@ internal fun PdfVerticalReader( isEditMode: Boolean = false, allAnnotations: () -> Map> = { emptyMap() }, drawingState: PdfDrawingState, - onDrawStart: (Int, PdfPoint) -> Unit, - onDraw: (Int, PdfPoint) -> Unit, + onDrawStart: (Int, PdfPoint, Boolean) -> Unit, + onDraw: (Int, PdfPoint, Boolean) -> Unit, onDrawEnd: () -> Unit, onOcrModelDownloading: () -> Unit = {}, selectedTool: InkType, @@ -247,7 +252,10 @@ internal fun PdfVerticalReader( customHighlightColors: Map = emptyMap(), onPaletteClick: () -> Unit = {}, lockedState: Triple? = null, - onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null + onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null, + resetZoomTrigger: Long = 0L, + isBubbleZoomModeActive: Boolean = false, + onDetectBubbles: suspend (Int, Bitmap) -> List = { _, _ -> emptyList() } ) { SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") } DisposableEffect(state) { @@ -260,6 +268,7 @@ internal fun PdfVerticalReader( } } var globalEraserPosition by remember { mutableStateOf(null) } + var isStylusEraserOverride by remember { mutableStateOf(false) } val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse" BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { val imeInsets = WindowInsets.ime @@ -393,18 +402,17 @@ internal fun PdfVerticalReader( val zoomedDocHeight = totalDocHeight * savedScale val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx) - val maxPanY = headerHeightPx zoomAnimatable.stop() panXAnimatable.stop() panYAnimatable.stop() panXAnimatable.updateBounds(minPanX, maxPanX) - panYAnimatable.updateBounds(minPanY, maxPanY) + panYAnimatable.updateBounds(minPanY, headerHeightPx) zoomAnimatable.snapTo(savedScale) panXAnimatable.snapTo(savedPanX) - panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, maxPanY)) + panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, headerHeightPx)) Timber.tag("PdfLockDiagnostic").d("RESTORE SNAP COMPLETE: Scale=${zoomAnimatable.value}, X=${panXAnimatable.value}, Y=${panYAnimatable.value}") @@ -492,6 +500,65 @@ internal fun PdfVerticalReader( return clampValues(targetZoom, targetPanX, targetPanY) } + LaunchedEffect(resetZoomTrigger) { + if (resetZoomTrigger != 0L && zoomAnimatable.value > fitZoom && !isScrollLocked) { + scope.launch { + zoomAnimatable.stop() + panXAnimatable.stop() + panYAnimatable.stop() + + val startZoom = zoomAnimatable.value + val startPanX = panXAnimatable.value + val startPanY = panYAnimatable.value + + val pivotScreenX = screenWidth / 2f + val pivotScreenY = screenHeight / 2f + + val pivotContentX = (pivotScreenX - startPanX) / startZoom + val pivotContentY = (pivotScreenY - startPanY) / startZoom + + val rawNextPanX = pivotScreenX - (pivotContentX * fitZoom) + val rawNextPanY = pivotScreenY - (pivotContentY * fitZoom) + + val (finalZoom, finalX, finalY) = clampCamera(fitZoom, rawNextPanX, rawNextPanY) + + panXAnimatable.updateBounds( + lowerBound = minOf(panXAnimatable.lowerBound ?: finalX, finalX, startPanX), + upperBound = maxOf(panXAnimatable.upperBound ?: finalX, finalX, startPanX) + ) + panYAnimatable.updateBounds( + lowerBound = minOf(panYAnimatable.lowerBound ?: finalY, finalY, startPanY), + upperBound = maxOf(panYAnimatable.upperBound ?: finalY, finalY, startPanY) + ) + + coroutineScope { + launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(400, easing = FastOutSlowInEasing)) } + launch { panXAnimatable.animateTo(finalX, animationSpec = tween(400, easing = FastOutSlowInEasing)) } + launch { panYAnimatable.animateTo(finalY, animationSpec = tween(400, easing = FastOutSlowInEasing)) } + } + + onZoomChange(zoomAnimatable.value) + + val zoomedDocWidth = screenWidth * finalZoom + val finalMinX: Float + val finalMaxX: Float + if (zoomedDocWidth < screenWidth) { + val centeredX = (screenWidth - zoomedDocWidth) / 2f + finalMinX = centeredX + finalMaxX = centeredX + } else { + finalMinX = -(zoomedDocWidth - screenWidth) + finalMaxX = 0f + } + panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX) + + val zDocH = totalDocHeight * finalZoom + val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx) + panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx) + } + } + } + LaunchedEffect( totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing ) { @@ -920,6 +987,15 @@ internal fun PdfVerticalReader( return@awaitEachGesture } + val buttons = currentEvent.buttons + Timber.tag("StylusEraserDiagnostic").d( + "VerticalReader | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons" + ) + + val isEraserOverride = down.type == PointerType.Eraser || + (down.type == PointerType.Stylus && currentEvent.buttons.isSecondaryPressed) + isStylusEraserOverride = isEraserOverride + fun getPageAndPoint(screenOffset: Offset): Pair? { val zoom = zoomAnimatable.value val panX = panXAnimatable.value @@ -943,14 +1019,14 @@ internal fun PdfVerticalReader( var isCanceled = false try { - if (selectedTool == InkType.ERASER) { + if (selectedTool == InkType.ERASER || isEraserOverride) { globalEraserPosition = down.position } val startData = getPageAndPoint(down.position) if (startData != null) { val (pageIndex, point) = startData - onDrawStart(pageIndex, point) + onDrawStart(pageIndex, point, isEraserOverride) down.consume() } @@ -969,7 +1045,7 @@ internal fun PdfVerticalReader( if (change == null || !change.pressed) break if (change.positionChanged()) { - if (selectedTool == InkType.ERASER) { + if (selectedTool == InkType.ERASER || isEraserOverride) { globalEraserPosition = change.position } @@ -977,11 +1053,11 @@ internal fun PdfVerticalReader( if (dragData != null) { val (pageIndex, point) = dragData - if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER) { + if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER && !isEraserOverride) { onDrawEnd() - onDrawStart(pageIndex, point) + onDrawStart(pageIndex, point, isEraserOverride) } else { - onDraw(pageIndex, point) + onDraw(pageIndex, point, isEraserOverride) } lastPageIndex = pageIndex } @@ -993,6 +1069,7 @@ internal fun PdfVerticalReader( onDrawEnd() } globalEraserPosition = null + isStylusEraserOverride = false } } } @@ -1472,16 +1549,20 @@ internal fun PdfVerticalReader( } val onDrawStartLambda = remember(page.index, onDrawStart) { - { point: PdfPoint -> onDrawStart(page.index, point) } + { point: PdfPoint, isEraserOverride: Boolean -> + onDrawStart(page.index, point, isEraserOverride) + } } val currentOnDraw by rememberUpdatedState(onDraw) val onDrawLambda = remember(page.index) { - { point: PdfPoint -> currentOnDraw(page.index, point) } + { point: PdfPoint, isEraserOverride: Boolean -> + currentOnDraw(page.index, point, isEraserOverride) + } } val onSingleTapLambda = remember(onPageClick) { - { + { _: Offset? -> selectionClearTrigger++ onPageClick() } @@ -1759,7 +1840,9 @@ internal fun PdfVerticalReader( draggingBoxId = null } }, - draggingBoxId = draggingBoxId + draggingBoxId = draggingBoxId, + isBubbleZoomModeActive = isBubbleZoomModeActive, + onDetectBubbles = onDetectBubbles ) } @@ -1875,6 +1958,7 @@ internal fun PdfVerticalReader( animationSpec = tween(durationMillis = 300), label = "scrollbarAlpha" ) + val safeCurrentPage = if (totalPages > 0) state.currentPage.coerceIn(0, totalPages - 1) else 0 val samsungBlue = Color(0xFF4285F4) val samsungBlueDark = Color(0xFF1976D2) @@ -1934,7 +2018,7 @@ internal fun PdfVerticalReader( .alpha(scrollbarAlpha)) { Row(verticalAlignment = Alignment.CenterVertically) { AnimatedVisibility( - visible = isDraggingScrollbar, + visible = isDraggingScrollbar && totalPages > 0, enter = fadeIn() + androidx.compose.animation.slideInHorizontally { it / 2 }, @@ -1948,7 +2032,7 @@ internal fun PdfVerticalReader( modifier = Modifier.padding(end = 12.dp) ) { Text( - text = "${state.currentPage + 1}/${totalPages}", + text = "${safeCurrentPage + 1}/$totalPages", style = MaterialTheme.typography.titleMedium.copy( fontSize = 16.sp, fontWeight = FontWeight.Bold ), @@ -2060,7 +2144,7 @@ internal fun PdfVerticalReader( } } - if (isEditMode && selectedTool == InkType.ERASER && globalEraserPosition != null) { + if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && globalEraserPosition != null) { Canvas(modifier = Modifier.fillMaxSize()) { val pos = globalEraserPosition!! val radiusPx = if (activeToolThickness > 0f) { @@ -2127,4 +2211,4 @@ internal fun PdfVerticalReader( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index d901006..4e44028 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -92,6 +92,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Undo import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Close @@ -209,11 +210,14 @@ import com.aryan.reader.SearchResult import com.aryan.reader.SummarizationResult import com.aryan.reader.SummaryCacheManager import com.aryan.reader.TtsSettingsSheet +import com.aryan.reader.ml.SpeechBubble import com.aryan.reader.epubreader.AutoScrollControls import com.aryan.reader.epubreader.DictionarySettingsDialog import com.aryan.reader.epubreader.ExternalDictionaryHelper import com.aryan.reader.epubreader.SystemUiMode import com.aryan.reader.epubreader.TtsOverlayControls +import com.aryan.reader.epubreader.loadTapToNavigateSetting +import com.aryan.reader.epubreader.saveTapToNavigateSetting import com.aryan.reader.fetchAiDefinition import com.aryan.reader.loadCustomThemes import com.aryan.reader.paginatedreader.TtsChunk @@ -252,6 +256,7 @@ import org.json.JSONObject import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.File +import java.util.LinkedHashSet import java.net.HttpURLConnection import java.net.URL import kotlin.math.PI @@ -290,6 +295,7 @@ fun PdfViewerScreen( val focusManager = LocalFocusManager.current val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) var displayMode by remember { mutableStateOf(loadDisplayMode(context)) } + var tapToNavigateEnabled by remember { mutableStateOf(loadTapToNavigateSetting(context)) } var showThemePanel by remember { mutableStateOf(false) } var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) } var excludeImages by remember { mutableStateOf(com.aryan.reader.loadExcludeImages(context)) } @@ -335,9 +341,11 @@ fun PdfViewerScreen( savePdfHiddenTools(context, newSet) } + val isOss = BuildConfig.FLAVOR == "oss" + val executeWithOcrCheck = remember(hasSelectedOcrLanguage) { { action: () -> Unit -> - if (hasSelectedOcrLanguage) { + if (isOss || hasSelectedOcrLanguage) { action() } else { pendingActionAfterOcrSelection = action @@ -584,6 +592,9 @@ fun PdfViewerScreen( var customHighlightColors by remember { mutableStateOf(loadCustomHighlightColors(context)) } var showHighlightColorPicker by remember { mutableStateOf(false) } var highlightColorPickerInitialSlot by remember { mutableStateOf(PdfHighlightColor.YELLOW) } + var isBubbleZoomModeActive by remember { mutableStateOf(false) } + var showBubbleZoomDownloadDialog by remember { mutableStateOf(false) } + val bubbleZoomDownloadProgress by viewModel.speechBubbleModelDownloadProgress.collectAsState() var dockLocation by remember { mutableStateOf(initialDockLocation) } var dockOffset by remember { mutableStateOf(initialDockOffset) } @@ -655,22 +666,11 @@ fun PdfViewerScreen( snapPreviewLocation, isEditMode, isDockDragging, - showStandardBars, systemUiMode, statusBarHeightDp ) { if (!isEditMode) { - var h = 0.dp - if (showStandardBars) { - h += 56.dp - } - - val isStatusBarVisible = systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars) - - if (isStatusBarVisible) { - h += statusBarHeightDp - } - h + 0.dp } else { val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging val isPreviewingTop = snapPreviewLocation == DockLocation.TOP @@ -686,6 +686,31 @@ fun PdfViewerScreen( label = "verticalHeaderHeight" ) + val targetTopOverlayInset = remember( + showStandardBars, + systemUiMode, + statusBarHeightDp + ) { + if (!showStandardBars) { + 0.dp + } else { + var inset = 56.dp + val isStatusBarVisible = + systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars) + + if (isStatusBarVisible) { + inset += statusBarHeightDp + } + inset + } + } + + val topOverlayInset by animateDpAsState( + targetValue = targetTopOverlayInset, + animationSpec = tween(durationMillis = 200), + label = "topOverlayInset" + ) + val verticalFooterHeight by remember( dockLocation, snapPreviewLocation, @@ -820,9 +845,155 @@ fun PdfViewerScreen( } } } - var isDocumentReady by remember { mutableStateOf(false) } + suspend fun renderSpeechBubblePrefetchBitmap( + document: ReaderDocument, + sourcePageIndex: Int + ): Bitmap? = withContext(Dispatchers.IO) { + document.openPage(sourcePageIndex)?.use { page -> + val pageWidth = page.getPageWidthPoint() + val pageHeight = page.getPageHeightPoint() + if (pageWidth <= 0 || pageHeight <= 0) { + return@withContext null + } + + val longEdge = max(pageWidth, pageHeight).toFloat() + val targetLongEdge = when (document) { + is PdfDocumentWrapper -> 1600f.coerceAtLeast(longEdge) + else -> min(longEdge, 1600f) + } + val renderScale = (targetLongEdge / longEdge).coerceAtLeast(1f) + val renderWidth = (pageWidth * renderScale).roundToInt().coerceAtLeast(1) + val renderHeight = (pageHeight * renderScale).roundToInt().coerceAtLeast(1) + val renderBitmap = Bitmap.createBitmap(renderWidth, renderHeight, Bitmap.Config.ARGB_8888) + + try { + page.renderPageBitmap( + bitmap = renderBitmap, + startX = 0, + startY = 0, + drawSizeX = renderWidth, + drawSizeY = renderHeight, + renderAnnot = true + ) + renderBitmap + } catch (t: Throwable) { + renderBitmap.recycle() + Timber.tag("BubbleZoom").w(t, "Failed to render bubble prefetch bitmap for page $sourcePageIndex") + null + } + } + } + + fun buildSpeechBubblePrefetchOrder(): List { + if (totalDisplayPages <= 0) return emptyList() + val ordered = LinkedHashSet() + ordered += currentPage.coerceIn(0, totalDisplayPages - 1) + for (distance in 1 until totalDisplayPages) { + val next = currentPage + distance + val previous = currentPage - distance + if (next in 0 until totalDisplayPages) ordered += next + if (previous in 0 until totalDisplayPages) ordered += previous + } + return ordered.toList() + } + + suspend fun detectSpeechBubblesForPage( + sourcePageIndex: Int, + fallbackBitmap: Bitmap, + allowHighQualityFallback: Boolean = true + ): List { + val document = pdfDocument + val shouldUsePrefetchBitmap = + allowHighQualityFallback && + document != null && + !viewModel.hasCachedSpeechBubbles(bookId, sourcePageIndex) + val detectionBitmap = if (shouldUsePrefetchBitmap) { + renderSpeechBubblePrefetchBitmap(document!!, sourcePageIndex) ?: fallbackBitmap + } else { + fallbackBitmap + } + val ownsBitmap = detectionBitmap !== fallbackBitmap + + return try { + val detected = viewModel.detectSpeechBubblesCached( + documentId = bookId, + pageIndex = sourcePageIndex, + bitmap = detectionBitmap, + context = context + ) + if (ownsBitmap) { + viewModel.detectSpeechBubblesCached( + documentId = bookId, + pageIndex = sourcePageIndex, + bitmap = fallbackBitmap, + context = context + ) + } else { + detected + } + } finally { + if (ownsBitmap && !detectionBitmap.isRecycled) { + detectionBitmap.recycle() + } + } + } + + LaunchedEffect( + isBubbleZoomModeActive, + isDocumentReady, + pdfDocument, + bookId, + currentPage, + totalDisplayPages, + virtualPages + ) { + val document = pdfDocument ?: return@LaunchedEffect + if (!isBubbleZoomModeActive || !isDocumentReady || totalDisplayPages <= 0) { + return@LaunchedEffect + } + + for (displayPageIndex in buildSpeechBubblePrefetchOrder()) { + if (!isActive) break + + val sourcePageIndex = when (val virtualPage = virtualPages.getOrNull(displayPageIndex)) { + is VirtualPage.PdfPage -> virtualPage.pdfIndex + null -> displayPageIndex + else -> continue + } + + if (viewModel.hasCachedSpeechBubbles(bookId, sourcePageIndex)) { + continue + } + + val prefetchBitmap = renderSpeechBubblePrefetchBitmap(document, sourcePageIndex) ?: continue + try { + detectSpeechBubblesForPage( + sourcePageIndex = sourcePageIndex, + fallbackBitmap = prefetchBitmap, + allowHighQualityFallback = false + ) + } finally { + if (!prefetchBitmap.isRecycled) { + prefetchBitmap.recycle() + } + } + + kotlinx.coroutines.yield() + } + } + + val jumpHistory = remember { mutableStateListOf() } + var showJumpPill by remember { mutableStateOf(false) } + + LaunchedEffect(showJumpPill, jumpHistory.size) { + if (showJumpPill && jumpHistory.isNotEmpty()) { + delay(4000) + showJumpPill = false + } + } + LaunchedEffect(currentPage, isDocumentReady, totalPages, initialScrollDone) { if (isDocumentReady && totalPages > 0) { if (initialScrollDone) { @@ -989,6 +1160,7 @@ fun PdfViewerScreen( var isLoadingDocument by remember { mutableStateOf(true) } var selectionClearTrigger by remember { mutableLongStateOf(0L) } + var resetZoomTrigger by remember { mutableLongStateOf(0L) } val displayPageRatios by remember(pageAspectRatios, virtualPages) { derivedStateOf { @@ -1879,7 +2051,6 @@ fun PdfViewerScreen( val onDictionaryLookupStable = remember(executeWithOcrCheck, useOnlineDictionary, selectedDictPackage, uiState.credits, isProUser) { { text: String -> executeWithOcrCheck { - val isOss = BuildConfig.FLAVOR == "oss" val effectiveUseOnline = !isOss && useOnlineDictionary if (effectiveUseOnline) { @@ -1954,6 +2125,14 @@ fun PdfViewerScreen( { targetPage: Int -> coroutineScope.launch { if (targetPage in 0 until totalPages) { + val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + + if (current != targetPage) { + if (jumpHistory.size > 20) jumpHistory.removeAt(0) + jumpHistory.add(current) + showJumpPill = true + } + if (displayMode == DisplayMode.PAGINATION) { pagerState.animateScrollToPage(targetPage) } else { @@ -2700,19 +2879,8 @@ fun PdfViewerScreen( } } - LaunchedEffect(pagerState.isScrollInProgress) { - if (pagerState.isScrollInProgress && showBars) { - showBars = false - Timber.d("Pager scroll detected, hiding bars.") - } - } - LaunchedEffect(pagerState.isScrollInProgress) { if (pagerState.isScrollInProgress) { - if (showBars) { - showBars = false - Timber.d("Pager scroll detected, hiding bars.") - } if (displayMode == DisplayMode.PAGINATION && !isAutoPagingForTts && (ttsState.isPlaying || ttsState.isLoading)) { ttsController.stop() } @@ -2986,6 +3154,14 @@ fun PdfViewerScreen( val onInternalLinkNav: (Int) -> Unit = { targetPage -> coroutineScope.launch { if (targetPage in 0 until totalPages) { + val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + + if (current != targetPage) { + if (jumpHistory.size > 20) jumpHistory.removeAt(0) + jumpHistory.add(current) + showJumpPill = true + } + if (displayMode == DisplayMode.PAGINATION) { pagerState.animateScrollToPage(targetPage) } else { @@ -3014,16 +3190,24 @@ fun PdfViewerScreen( fun navigateToPdfSearchResult(result: SearchResult) { currentPdfSearchResult = result - searchHighlightTarget = result coroutineScope.launch { + val targetPage = result.locationInSource + val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + + if (current != targetPage) { + if (jumpHistory.size > 20) jumpHistory.removeAt(0) + jumpHistory.add(current) + showJumpPill = true + } + if (displayMode == DisplayMode.PAGINATION) { - if (pagerState.currentPage != result.locationInSource) { - pagerState.scrollToPage(result.locationInSource) + if (pagerState.currentPage != targetPage) { + pagerState.scrollToPage(targetPage) } } else { - verticalReaderState.scrollToPage(result.locationInSource) + verticalReaderState.scrollToPage(targetPage) } } } @@ -3092,13 +3276,23 @@ fun PdfViewerScreen( drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = { ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) { PdfNavigationDrawerContent( + pdfDocument = pdfDocument, flatTableOfContents = flatTableOfContents, bookmarks = bookmarks, userHighlights = userHighlights, currentPage = currentPage, + totalPages = totalDisplayPages, customHighlightColors = customHighlightColors, onPageSelected = { targetPage -> coroutineScope.launch { + val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + + if (current != targetPage) { + if (jumpHistory.size > 20) jumpHistory.removeAt(0) + jumpHistory.add(current) + showJumpPill = true + } + if (displayMode == DisplayMode.PAGINATION) { pagerState.scrollToPage(targetPage) } else { @@ -3203,6 +3397,44 @@ fun PdfViewerScreen( val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) } when (displayMode) { DisplayMode.PAGINATION -> { + val onPaginationPreSingleTap: (Offset) -> Boolean = { tapOffset -> + val canTurnPagesByTap = tapToNavigateEnabled && + (currentPageScale <= 1.02f || isScrollLocked) + + if (!canTurnPagesByTap) { + false + } else { + val oneQuarterWidthPx = boxMaxWidthFloat / 4f + when { + tapOffset.x < oneQuarterWidthPx -> { + coroutineScope.launch { + val targetPage = + (pagerState.currentPage - 1).coerceAtLeast(0) + if (targetPage != pagerState.currentPage) { + pagerState.scrollToPage(targetPage) + } + } + true + } + + tapOffset.x > (boxMaxWidthFloat - oneQuarterWidthPx) -> { + coroutineScope.launch { + val targetPage = + (pagerState.currentPage + 1).coerceAtMost( + pagerState.pageCount - 1 + ) + if (targetPage != pagerState.currentPage) { + pagerState.scrollToPage(targetPage) + } + } + true + } + + else -> false + } + } + } + Box(modifier = Modifier.fillMaxSize()) { HorizontalPager( state = pagerState, @@ -3291,9 +3523,10 @@ fun PdfViewerScreen( @Suppress("ControlFlowWithEmptyBody") val onDrawPagination = remember(pageIndex) { - { point: PdfPoint -> - if (currentSelectedTool == InkType.TEXT) { - } else if (currentSelectedTool == InkType.ERASER) { + { point: PdfPoint, isEraserOverride: Boolean -> + val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool + if (effectiveTool == InkType.TEXT) { + } else if (effectiveTool == InkType.ERASER) { val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { @@ -3328,12 +3561,13 @@ fun PdfViewerScreen( @Suppress("ControlFlowWithEmptyBody") val onDrawStartPagination = remember(pageIndex) { - { point: PdfPoint -> + { point: PdfPoint, isEraserOverride: Boolean -> if (showToolSettings) { showToolSettings = false } else { - if (currentSelectedTool == InkType.TEXT) { - } else if (currentSelectedTool == InkType.ERASER) { + val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool + if (effectiveTool == InkType.TEXT) { + } else if (effectiveTool == InkType.ERASER) { lastEraserPoint = point erasedAnnotationsFromStroke.clear() val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } @@ -3362,7 +3596,7 @@ fun PdfViewerScreen( drawingState.onDrawStart( pageIndex, pointWithTime, - currentSelectedTool, + effectiveTool, currentStrokeColorState, currentStrokeWidthState ) @@ -3403,7 +3637,8 @@ fun PdfViewerScreen( modifier = Modifier.fillMaxSize(), showAllTextHighlights = showAllTextHighlights, onHighlightLoading = { /* no-op for paginated mode */ }, - onSingleTap = onSingleTapStable, + onPreSingleTap = onPaginationPreSingleTap, + onSingleTap = { _ -> onSingleTapStable() }, isProUser = isProUser, onShowDictionaryUpsellDialog = { if (useOnlineDictionary) { @@ -3420,6 +3655,7 @@ fun PdfViewerScreen( onBookmarkClick = { onToggleBookmark(pageIndex) }, isZoomEnabled = true, clearSelectionTrigger = selectionClearTrigger, + resetZoomTrigger = resetZoomTrigger, pageAnnotations = pageAnnotationsProvider, drawingState = drawingState, onDrawStart = onDrawStartPagination, @@ -3470,12 +3706,11 @@ fun PdfViewerScreen( currentActiveOffset = newOffset } }, - onDetectPanels = { bitmap -> - Toast.makeText(context, "Scanning for panels...", Toast.LENGTH_SHORT).show() - viewModel.detectComicPanels(bitmap, context) + onDetectBubbles = { sourcePageIndex, bitmap -> + detectSpeechBubblesForPage(sourcePageIndex, bitmap) }, - onShowPanelPopup = { croppedBitmap -> - poppedUpPanelBitmap = croppedBitmap + onShowPanelPopup = { bitmapWithRects -> + poppedUpPanelBitmap = bitmapWithRects }, onTwoFingerSwipe = { direction -> coroutineScope.launch { @@ -3647,7 +3882,15 @@ fun PdfViewerScreen( paginationDraggingBoxId = null } }, - onDragPageTurn = { /* Handled in onTextBoxDrag */ }, + onDragPageTurn = { direction -> + coroutineScope.launch { + val targetPage = pagerState.currentPage + direction + if (targetPage in 0 until totalDisplayPages) { + pagerState.animateScrollToPage(targetPage) + } + } + }, + isBubbleZoomModeActive = isBubbleZoomModeActive, isVisible = isVisiblePage, isActivePage = pagerState.currentPage == pageIndex, isScrolling = pagerState.isScrollInProgress @@ -3718,12 +3961,13 @@ fun PdfViewerScreen( @Suppress("ControlFlowWithEmptyBody") val onDrawStartStable = remember { - { pageIndex: Int, point: PdfPoint -> + { pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean -> if (showToolSettings) { showToolSettings = false } else { - if (currentSelectedTool == InkType.TEXT) { - } else if (currentSelectedTool == InkType.ERASER) { + val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool + if (effectiveTool == InkType.TEXT) { + } else if (effectiveTool == InkType.ERASER) { lastEraserPoint = point erasedAnnotationsFromStroke.clear() @@ -3753,7 +3997,7 @@ fun PdfViewerScreen( drawingState.onDrawStart( pageIndex, pointWithTime, - currentSelectedTool, + effectiveTool, currentStrokeColorState, currentStrokeWidthState ) @@ -3763,8 +4007,9 @@ fun PdfViewerScreen( } val onDrawStable = remember(isHighlighterSnapEnabled, isCurrentToolHighlighter, calculateSnappedPoint) { - { pageIndex: Int, point: PdfPoint -> - if (currentSelectedTool == InkType.ERASER) { + { pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean -> + val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool + if (effectiveTool == InkType.ERASER) { val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val existing = allAnnotations[pageIndex] ?: emptyList() val toRemove = existing.filter { @@ -3915,6 +4160,11 @@ fun PdfViewerScreen( onZoomAndPanChanged = { newScale, newOffset -> currentActiveScale = newScale currentActiveOffset = newOffset + }, + resetZoomTrigger = resetZoomTrigger, + isBubbleZoomModeActive = isBubbleZoomModeActive, + onDetectBubbles = { sourcePageIndex, bitmap -> + detectSpeechBubblesForPage(sourcePageIndex, bitmap) } ) } @@ -4108,7 +4358,7 @@ fun PdfViewerScreen( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() - .padding(top = if (showBars) verticalHeaderHeight else 0.dp) + .padding(top = topOverlayInset) .padding(8.dp) ) { Surface( @@ -4138,6 +4388,55 @@ fun PdfViewerScreen( } } + AnimatedVisibility( + visible = bubbleZoomDownloadProgress != null, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + // shift down slightly if the OCR indicator is also showing + .padding(top = topOverlayInset + if (isOcrModelDownloading) 64.dp else 0.dp) + .padding(8.dp) + ) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = RoundedCornerShape(8.dp), + shadowElevation = 4.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + val progress = bubbleZoomDownloadProgress ?: 0f + if (progress > 0f) { + CircularProgressIndicator( + progress = { progress }, + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onTertiaryContainer, + trackColor = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.2f) + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Downloading Bubble Zoom model... ${(progress * 100).toInt()}%", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + // --- Slider UI Overlay --- AnimatedVisibility( visible = isPageSliderVisible, @@ -4209,6 +4508,15 @@ fun PdfViewerScreen( scrubDebounceJob.value = coroutineScope.launch { delay(200) if (isActive) { + val targetPage = newValue.roundToInt() + + if (targetPage != sliderStartPage) { + if (jumpHistory.lastOrNull() != sliderStartPage) { + if (jumpHistory.size > 20) jumpHistory.removeAt(0) + jumpHistory.add(sliderStartPage) + } + showJumpPill = true + } if (displayMode == DisplayMode.PAGINATION) { pagerState.scrollToPage( newValue.roundToInt() @@ -4418,10 +4726,17 @@ fun PdfViewerScreen( }, onShowCustomizeTools = { showCustomizeToolsSheet = true }, onShowOcrLanguage = { - hasSelectedOcrLanguage = true - showOcrLanguageDialog = true + if (!isOss) { + hasSelectedOcrLanguage = true + showOcrLanguageDialog = true + } }, onShowVisualOptions = { showVisualOptionsSheet = true }, + tapToNavigateEnabled = tapToNavigateEnabled, + onToggleTapToNavigate = { + tapToNavigateEnabled = !tapToNavigateEnabled + saveTapToNavigateSetting(context, tapToNavigateEnabled) + }, onChangeDisplayMode = { displayMode = it }, onToggleKeepScreenOn = { isKeepScreenOn = !isKeepScreenOn @@ -4481,13 +4796,28 @@ fun PdfViewerScreen( } } }, - onNewTabClick = { showNewTabSheet = true } + onNewTabClick = { showNewTabSheet = true }, + onGenerateDemoAnnotations = { + val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage + val demoAnnots = DemoAnnotationGenerator.generateDemoAnnotations(page) + + if (demoAnnots.isNotEmpty()) { + Timber.d("Debug: Generating ${demoAnnots.size} demo annotations for page $page") + val existing = allAnnotations[page] ?: emptyList() + allAnnotations = allAnnotations + (page to (existing + demoAnnots)) + + demoAnnots.forEach { annot -> + undoStack.add(HistoryAction.Add(page, annot)) + } + redoStack.clear() + } + } ) ReflowProgressOverlay( modifier = Modifier .align(Alignment.TopCenter) - .padding(top = verticalHeaderHeight) + .padding(top = topOverlayInset) .fillMaxWidth() .padding(horizontal = 8.dp), showStandardBars = showStandardBars, @@ -4502,7 +4832,7 @@ fun PdfViewerScreen( Column( modifier = Modifier .fillMaxSize() - .padding(top = verticalHeaderHeight) + .padding(top = topOverlayInset) .background(MaterialTheme.colorScheme.surface) ) { if (isBackgroundIndexing) { @@ -4673,6 +5003,56 @@ fun PdfViewerScreen( ) } + val effectiveNavBarForPill = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp + + val isBottomBarVisibleForPill = showStandardBars && !searchState.isSearchActive + val targetPillBottomPadding = if (isBottomBarVisibleForPill) 56.dp + 16.dp + effectiveNavBarForPill else 16.dp + effectiveNavBarForPill + + val pillBottomPadding by animateDpAsState( + targetValue = targetPillBottomPadding, + label = "PillBottomPadding" + ) + + AnimatedVisibility( + visible = showJumpPill && jumpHistory.isNotEmpty(), + enter = fadeIn() + slideInVertically { it }, + exit = fadeOut() + slideOutVertically { it }, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(bottom = pillBottomPadding) + .padding(start = 16.dp) + ) { + val lastPage = jumpHistory.lastOrNull() ?: 0 + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + shadowElevation = 6.dp, + onClick = { + val target = jumpHistory.removeLastOrNull() + if (target != null) { + showJumpPill = false + coroutineScope.launch { + if (displayMode == DisplayMode.PAGINATION) { + pagerState.animateScrollToPage(target) + } else { + verticalReaderState.scrollToPage(target) + } + } + } + } + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = "Jump Back", modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Back to Pg ${lastPage + 1}", style = MaterialTheme.typography.labelLarge) + } + } + } + // Bottom Bar PdfBottomBar( modifier = Modifier.align(Alignment.BottomCenter), @@ -4687,6 +5067,20 @@ fun PdfViewerScreen( isEditMode = isEditMode, isTtsSessionActive = isTtsSessionActive, ttsErrorMessage = ttsState.errorMessage, + jumpBackPage = jumpHistory.lastOrNull(), + onJumpBack = { + val target = jumpHistory.removeLastOrNull() + if (target != null) { + showJumpPill = false + coroutineScope.launch { + if (displayMode == DisplayMode.PAGINATION) { + pagerState.animateScrollToPage(target) + } else { + verticalReaderState.scrollToPage(target) + } + } + } + }, onShowSlider = { val currentPageForSlider = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage sliderStartPage = currentPageForSlider @@ -4736,6 +5130,16 @@ fun PdfViewerScreen( } else { startTtsWithPermissionCheck(null, null) } + }, + isBubbleZoomModeActive = isBubbleZoomModeActive, + onToggleBubbleZoom = { + if (isOss) { + coroutineScope.launch { snackbarHostState.showSnackbar("Bubble Zoom is only available in Playstore version of Episteme") } + } else if (!isBubbleZoomModeActive && !viewModel.isSpeechBubbleModelAvailable(context)) { + showBubbleZoomDownloadDialog = true + } else { + isBubbleZoomModeActive = !isBubbleZoomModeActive + } } ) @@ -5145,7 +5549,12 @@ fun PdfViewerScreen( exit = fadeOut() ) { val percentage = (currentPageScale * 100).roundToInt() - ZoomPercentageIndicator(percentage = percentage) + ZoomPercentageIndicator( + percentage = percentage, + onResetZoomClick = { + resetZoomTrigger = System.currentTimeMillis() + } + ) } val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0 @@ -5465,7 +5874,7 @@ fun PdfViewerScreen( ) { Image( bitmap = poppedUpPanelBitmap!!.asImageBitmap(), - contentDescription = "Zoomed Panel", + contentDescription = "Annotated Page", modifier = Modifier .fillMaxWidth() .padding(16.dp) @@ -5485,7 +5894,7 @@ fun PdfViewerScreen( ) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Close Panel", + contentDescription = "Close Image", tint = Color.White ) } @@ -5500,6 +5909,30 @@ fun PdfViewerScreen( onConfirm = { password -> documentPassword = password }) } + if (showBubbleZoomDownloadDialog) { + AlertDialog( + onDismissRequest = { showBubbleZoomDownloadDialog = false }, + icon = { Icon(Icons.Default.Info, contentDescription = null) }, + title = { Text("Download Bubble Zoom Model") }, + text = { + Text("To use the Bubble Zoom feature, an AI model needs to be downloaded (~134 MB). Do you want to download it now?") + }, + confirmButton = { + TextButton(onClick = { + showBubbleZoomDownloadDialog = false + viewModel.downloadSpeechBubbleModel(context) + }) { + Text("Download") + } + }, + dismissButton = { + TextButton(onClick = { showBubbleZoomDownloadDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + } + ) + } + if (showNewTabSheet) { ModalBottomSheet( onDismissRequest = { showNewTabSheet = false }, @@ -5656,7 +6089,7 @@ fun PdfViewerScreen( } } - if (showOcrLanguageDialog) { + if (showOcrLanguageDialog && !isOss) { OcrLanguageSelectionDialog( currentLanguage = ocrLanguage, isFirstRun = !hasSelectedOcrLanguage, @@ -6037,6 +6470,17 @@ fun PdfViewerScreen( currentTtsMode = currentTtsMode, isCollapsed = isTtsCollapsed, onCollapseChange = { isTtsCollapsed = it }, + onLocateCurrentChunk = { + ttsPageData?.pageIndex?.let { targetPage -> + coroutineScope.launch { + if (displayMode == DisplayMode.PAGINATION) { + pagerState.scrollToPage(targetPage) + } else { + verticalReaderState.scrollToPage(targetPage) + } + } + } + }, onOpenTtsSettings = { showTtsSettingsSheet = true }, onClose = { ttsController.stop() @@ -6165,4 +6609,4 @@ fun PdfViewerScreen( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt index bfbdb58..0dbe2ec 100644 --- a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt +++ b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt @@ -147,7 +147,35 @@ class BaseTtsSynthesizer(private val context: Context) { if (tts == null) return try { - val preferredVoiceName = loadNativeVoice(context) ?: return + val preferredVoiceName = loadNativeVoice(context) + + if (preferredVoiceName.isNullOrBlank()) { + val defaultLocale = Locale.getDefault() + try { + tts?.language = defaultLocale + } catch (e: Exception) { + Timber.e(e, "BaseTts: Failed to restore default language") + } + + val defaultVoice = try { + tts?.defaultVoice ?: tts?.voices?.firstOrNull { voice -> + voice.locale == defaultLocale && !voice.isNetworkConnectionRequired + } ?: tts?.voices?.firstOrNull { voice -> + voice.locale == defaultLocale + } + } catch (e: Exception) { + Timber.e(e, "BaseTts: Failed to query default voice") + null + } + + if (defaultVoice != null && tts?.voice?.name != defaultVoice.name) { + Timber.d("BaseTts: Restoring system default voice to ${defaultVoice.name} (${defaultVoice.locale})") + tts?.voice = defaultVoice + } else { + Timber.d("BaseTts: Using engine default voice for locale $defaultLocale") + } + return + } if (tts?.voice?.name == preferredVoiceName) return @@ -269,4 +297,4 @@ class BaseTtsSynthesizer(private val context: Context) { } private class ZombieEngineException : Exception("Engine failed to start") -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/tts/TtsController.kt b/app/src/main/java/com/aryan/reader/tts/TtsController.kt index f0e521a..69cc39e 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsController.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsController.kt @@ -157,6 +157,7 @@ class TtsController(context: Context) : Player.Listener { bookTitle: String, chapterTitle: String?, coverImageUri: String?, + chapterIndex: Int? = null, ttsMode: TtsPlaybackManager.TtsMode, playbackSource: String = "READER", authToken: String? = null @@ -179,6 +180,7 @@ class TtsController(context: Context) : Player.Listener { putString(KEY_BOOK_TITLE, bookTitle) putString(KEY_CHAPTER_TITLE, chapterTitle) putString(KEY_COVER_IMAGE_URI, coverImageUri) + chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) } putString(KEY_TTS_MODE, ttsMode.name) putString(KEY_PLAYBACK_SOURCE, playbackSource) putString(KEY_AUTH_TOKEN, authToken) @@ -252,6 +254,8 @@ class TtsController(context: Context) : Player.Listener { val isLoading = customState.getBoolean("isLoading", false) val sessionFinished = customState.getBoolean("sessionFinished", false) val playbackSource = customState.getString("playbackSource") + val serviceBookTitle = customState.getString("bookTitle") + val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 } val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras val sourceCfi = mediaItemExtras?.getString("sourceCfi") @@ -270,6 +274,16 @@ class TtsController(context: Context) : Player.Listener { if (isLoading) currentState.currentText else null }, errorMessage = customState.getString("errorMessage"), + bookTitle = if (isPlaybackActive) { + currentMediaItem?.mediaMetadata?.artist?.toString() ?: serviceBookTitle + } else { + if (isLoading) currentState.bookTitle else serviceBookTitle + }, + chapterIndex = if (isPlaybackActive || isLoading) { + serviceChapterIndex ?: currentState.chapterIndex + } else { + serviceChapterIndex + }, speakerId = serviceSpeaker, sourceCfi = if (isPlaybackActive) { sourceCfi @@ -338,4 +352,4 @@ fun rememberTtsController(): TtsController { } return controller -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt index 3d1bbf0..60d7dfa 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -70,6 +70,7 @@ const val KEY_WORD_TIMESTAMPS = "KEY_WORD_TIMESTAMPS" const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS" const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE" const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN" +const val KEY_CHAPTER_INDEX = "KEY_CHAPTER_INDEX" private const val PREFETCH_LOOKAHEAD = 3 @@ -100,6 +101,8 @@ class TtsPlaybackManager( val isLoading: Boolean = false, val currentText: String? = null, val errorMessage: String? = null, + val bookTitle: String? = null, + val chapterIndex: Int? = null, val speakerId: String = DEFAULT_SPEAKER_ID, val sourceCfi: String? = null, val startOffsetInSource: Int = -1, @@ -189,6 +192,7 @@ class TtsPlaybackManager( val bookTitle = args.getString(KEY_BOOK_TITLE) val chapterTitle = args.getString(KEY_CHAPTER_TITLE) val coverImageUri = args.getString(KEY_COVER_IMAGE_URI) + val chapterIndex = args.getInt(KEY_CHAPTER_INDEX, -1).takeIf { it >= 0 } val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name) val playbackSource = args.getString(KEY_PLAYBACK_SOURCE) val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD } @@ -204,7 +208,7 @@ class TtsPlaybackManager( val authToken = args.getString(KEY_AUTH_TOKEN) Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}") - handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, ttsMode, playbackSource, args) + handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, ttsMode, playbackSource, args) } STOP_TTS_COMMAND -> { Timber.d("Received STOP command.") @@ -337,6 +341,7 @@ class TtsPlaybackManager( bookTitle: String?, chapterTitle: String?, coverImageUri: String?, + chapterIndex: Int?, ttsMode: TtsMode, playbackSource: String?, args: Bundle // Added this parameter @@ -375,6 +380,8 @@ class TtsPlaybackManager( _ttsState.value = TtsState( isLoading = true, + bookTitle = bookTitle, + chapterIndex = chapterIndex, speakerId = speakerId, playbackSource = playbackSource, ttsMode = ttsMode.name @@ -857,6 +864,8 @@ class TtsPlaybackManager( val bundle = Bundle().apply { putBoolean("isLoading", state.isLoading) putString("errorMessage", state.errorMessage) + putString("bookTitle", state.bookTitle) + putInt("chapterIndex", state.chapterIndex ?: -1) putString("speakerId", state.speakerId) putBoolean("sessionEndedByStop", state.sessionEndedByStop) putString("currentWordSourceCfi", state.currentWordSourceCfi) @@ -897,4 +906,4 @@ class TtsPlaybackManager( } Timber.tag("TTS_CLOUD_DIAG").d("ExoPlayer playback state changed: $stateName") } -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable-nodpi/comic_bubble.xml b/app/src/main/res/drawable-nodpi/comic_bubble.xml new file mode 100644 index 0000000..8eddef1 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/comic_bubble.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/pin_drop.xml b/app/src/main/res/drawable-nodpi/pin_drop.xml new file mode 100644 index 0000000..80c7dff --- /dev/null +++ b/app/src/main/res/drawable-nodpi/pin_drop.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/tag.xml b/app/src/main/res/drawable-nodpi/tag.xml new file mode 100644 index 0000000..ab04cfe --- /dev/null +++ b/app/src/main/res/drawable-nodpi/tag.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable-nodpi/zoom_out.xml b/app/src/main/res/drawable-nodpi/zoom_out.xml new file mode 100644 index 0000000..6c02e88 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/zoom_out.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/font_certs.xml b/app/src/main/res/values/font_certs.xml new file mode 100644 index 0000000..969397a --- /dev/null +++ b/app/src/main/res/values/font_certs.xml @@ -0,0 +1,15 @@ + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + +AR+A7/jH//X4ZqHkEQbMv48/pP81n0EEM0O4j2DkIf0Q4zKio2V29y5k2A0RNDK8zF54e1/xQnIfI0+T6Xp/3E/c9vA43T2Z1z7zU3L5+Vb+d0xT3i1oB4x/3/KjDq/Y1yE4j1NfIq1o001zWwN+P1Qx24GjQk4/z8/Ua01mB2I+rTq0L3H6n4wT//5c/R9oP4vM8gT9yD6vE8F+H/T8fQpM+0T6o+0O0/0N+0D7f0z4o+sT1/0T/D/T/O8/3/6/v/0E/5M/k/+z/2//Q/7//g/4//R/2/+f/1/8D/l/wT8/30/1P/d/xT1z/yT/1/X/wP81v/F9Z/wX43/y/4b/Tf4x/6/3T4/+/x+Z+H6y/sP9r/9f/h/8D+w/1P/C/4r/Jv63/e/1b4d3L+kE6qJ9oR+A69M8H+T1z4qE/aH/k/0/1r3xP8j/+P9v/w//P/hP9//3f7z9Z/xPxL9n/P//f9z81n3d/3P/g/6L+H/5v9J/r/9n/wP9b/yf8V9w/t34f+rT3T/R/0X/f/3P4z+QTxz9aP2A+v//X+7/wT9mP43+A/hT5k9T/uX9O/oP7P9sP0D/3P7j+tP03/bPyP+A/+z8U/Qf8Z/9/137X7/X91/qL67/s/3/5N/f/5H7hP6P8b/kE+n1w+yL70P65/f/sP9x+4z6//f/8X9D/wL/o/5//oP65/6P51/aP9J/Q/2P4z99f6U9H/d/6j+uPxf7r/Y/3f+p/+f8X/r/438Z/0/y1/1T/x/2X+Fv7l+K/4h/Vn+4//H91P8L+K/2n/r/6v/aP9J/RfjH3m/3H9mP9A/wD/Gf51/eP4v/P/7b9D/sT9F/k/93+A/2v+O/7X8m/pf7v/S/1v+g/xL9tPxf/2f2D/A/97/T/5//Fv8D+Q/xv9e/Q/7N/tP9l/uX9b+GfxD+e/o37P/B/3n/x/6r/Ff53/S/7b+rP8P/M/9v/A/71/R/wf/1/2X+2Px3/N/8t/q/5/6E9X/9z95P3d/D/+1/d/yf+Xf6L/mP/F/Q/w/wX+g/0v9T99v//+Tf6d/Afxz9z/iP4//r/9n+Bf5N/f/87+X/5P9f/b34X+e/tf9l/+7/Ff+f+Q/l/8v/s/4X+I/t3+2vwv/1f1T+uP0//r/6X/Z/4//C/0r/p/+3/rP1A+oP8A/wv/kPxj9n/b35f/a/7/2//v//z+pP6//S//hP1k/eD6X9rP+D/z3+j/2//sPw//1n+r/yf/D/rT+x/1v8T/t3+P//r9G/iH/aPwA+9L+P/x/+D/yv/Tf5J/aH91/+L/0P6B+Xv4//0n/Gf6f9jfwP8T/m/93/hH/b//z/Xf6f/z/+c/xv+f/3T/Uf1z/7D9x/+H/sP+b/vP63/uP9f/5n9M/wf8m/z/3H9U/1v/h/w/9X/wD+Q/sP8p/i3+0f8t/U/73+Tf0P+FfwH+j/1T/s/+E/7n+M/6H/xP/S/13+1/65/5H9mH7v+8P5z/jT+vfyf/4/+Xv6V/9P/h/6d/S/+F/+T9c/4z/aP4r/t3+d/5n9E/q/+tfxr+Bv8v+a/4f/R3+H/6f7P+qvwz+m//z+xvwB+e33H+Pvz3/7v6v/Qv4v/T/2r/w/55+s/+B/p3/sP2v+Gf17+3P9D/wz/n/4X/tH+z/0T+1v4j+8/0b+1f6r/s39I/4T/oP9b/jH6r/8v6z+sX9b/7f/P/6x/2j/6v63/Ff1b/5P4V/R/+H/rf9c/mX77/uX/7v2d+L/wf9J/4r/Yf7/+/fxb+R/9b99f6v/x3+t/1P+Tf8n/D/4z/s/5n/w/+k/5f+Tf9A/6L/5n9n/+f79+Nn+t/uP7T/uP9r/7T/C/wD/2/2h/c/+P/qD9L/g/+R/8H+Pvw/6z+nfxn8A/0n/1f/n/zP/A/37/Fv5/9H/h/9r//H7w/oT81/y/+g/2D+M/4T/Bv/B/qD+RfxP+8/wv+c/xX+h//f7T//3+s//z9vfy39D/of8T/xf59/p/6r/mH/1v2l/f/85/rf8H/xf+5/wH+j//b9ePwL+rP5H/vT7v+GfxB+4P3f/Wf65/f//n9Yf+j+jfxA/nL9v/7H+9P5J+H/iP+m/xf+D/6P8N/6X/2f4X/zH/k/8D+dPxH+lP5/+R/tD/fP+g/9P6z/b32V/tH/gP/z/D/3//4v6b/b/3v+Gf67+L/2f/uPx//gP81/uX6N/03/E/+d/j3/l/0b/gH9D/g/+k/s7+u/yD8B/+r9aPy/9zP91/wf/xP2v/K/yT8N/kf9T/9P8/Pxn+X/7z+sH7r/s37xP0B+P/yP+R/+D8v/sH67+X/5r+p/8V+R/9b92/1f9K/v/59/j/8s/gH8L/pf97/1/+d/3H80/1H8D//b85/vv65+m/x7/d/xP/S39w/7P+s/0j/c/7T/K/1r/p/+9/5/+f/t/47/A/1H/hPx7/E/+j+Q/xP/A/wf+hP/Z/f/2X/o/65/U36B/g/9gP8//Gf1B+M/x/+ZPxB+2T7L/hL9n/0H+U/8J/Tf+A/w3+6PzT9mP//9F/sf6n/Vn83f6f9lP/D9237B/Pfx/99fy/+xP2j/Xn/n/Z3/h/zj+I/+j+qPwv+m/wf+c/7L9R/4P/D/xP9vfj78rPz79j/rT+s//n9fP6F/tP+S/7n+hPxv/U/4b/Jfy/+kPy//s37r/t/9w/vH79P1R/2r9q/vv8Qf//8G/fH5r+/X1E/2v7GfvN8gP//91//5+L/1j+J//l+Ff6R+vP+v+Yv/v91v8D/f/6T/0j+qfwx/Y392v+v+Z/x7/fH89/lH8A/45/p38hP+/9137H/+r8//9r8n/+X5r/l362PyF+0n5Xf2p/l3+Bf9H+23/1/yv/m38mP///2P/Gf+C/g== + + + + +AR+A7/jH//X4ZqHkEQbMv48/pP81n0EEM0O4j2DkIf0Q4zKio2V29y5k2A0RNDK8zF54e1/xQnIfI0+T6Xp/3E/c9vA43T2Z1z7zU3L5+Vb+d0xT3i1oB4x/3/KjDq/Y1yE4j1NfIq1o001zWwN+P1Qx24GjQk4/z8/Ua01mB2I+rTq0L3H6n4wT//5c/R9oP4vM8gT9yD6vE8F+H/T8fQpM+0T6o+0O0/0N+0D7f0z4o+sT1/0T/D/T/O8/3/6/v/0E/5M/k/+z/2//Q/7//g/4//R/2/+f/1/8D/l/wT8/30/1P/d/xT1z/yT/1/X/wP81v/F9Z/wX43/y/4b/Tf4x/6/3T4/+/x+Z+H6y/sP9r/9f/h/8D+w/1P/C/4r/Jv63/e/1b4d3L+kE6qJ9oR+A69M8H+T1z4qE/aH/k/0/1r3xP8j/+P9v/w//P/hP9//3f7z9Z/xPxL9n/P//f9z81n3d/3P/g/6L+H/5v9J/r/9n/wP9b/yf8V9w/t34f+rT3T/R/0X/f/3P4z+QTxz9aP2A+v//X+7/wT9mP43+A/hT5k9T/uX9O/oP7P9sP0D/3n7j+tP03/bPyP+A/+z8U/Qf8Z/9/137X7/X91/qL67/s/3/5N/f/5H7hP6P8b/kE+n1w+yL70P65/f/sP9x+4z6//f/8X9D/wL/o/5//oP65/6P51/aP9J/Q/2P4z99f6U9H/d/6j+uPxf7r/Y/3f+p/+f8X/r/438Z/0/y1/1T/x/2X+Fv7l+K/4h/Vn+4//H91P8L+K/2n/r/6v/aP9J/RfjH3m/3H9mP9A/wD/Gf51/eP4v/P/7b9D/sT9F/k/93+A/2v+O/7X8m/pf7v/S/1v+g/xL9tPxf/2f2D/A/97/T/5//Fv8D+Q/xv9e/Q/7N/tP9l/uX9b+GfxD+e/o37P/B/3n/x/6r/Ff53/S/7b+rP8P/M/9v/A/71/R/wf/1/2X+2Px3/N/8t/q/5/6E9X/9z95P3d/D/+1/d/yf+Xf6L/mP/F/Q/w/wX+g/0v9T99v//+Tf6d/Afxz9z/iP4//r/9n+Bf5N/f/87+X/5P9f/b34X+e/tf9l/+7/Ff+f+Q/l/8v/s/4X+I/t3+2vwv/1f1T+uP0//r/6X/Z/4//C/0r/p/+3/rP1A+oP8A/wv/kPxj9n/b35f/a/7/2//v//z+pP6//S//hP1k/eD6X9rP+D/z3+j/2//sPw//1n+r/yf/D/rT+x/1v8T/t3+P//r9G/iH/aPwA+9L+P/x/+D/yv/Tf5J/aH91/+L/0P6B+Xv4//0n/Gf6f9jfwP8T/m/93/hH/b//z/Xf6f/z/+c/xv+f/3T/Uf1z/7D9x/+H/sP+b/vP63/uP9f/5n9M/wf8m/z/3H9U/1v/h/w/9X/wD+Q/sP8p/i3+0f8t/U/73+Tf0P+FfwH+j/1T/s/+E/7n+M/6H/xP/S/13+1/65/5H9mH7v+8P5z/jT+vfyf/4/+Xv6V/9P/h/6d/S/+F/+T9c/4z/aP4r/t3+d/5n9E/q/+tfxr+Bv8v+a/4f/R3+H/6f7P+qvwz+m//z+xvwB+e33H+Pvz3/7v6v/Qv4v/T/2r/w/55+s/+B/p3/sP2v+Gf17+3P9D/wz/n/4X/tH+z/0T+1v4j+8/0b+1f6r/s39I/4T/oP9b/jH6r/8v6z+sX9b/7f/P/6x/2j/6v63/Ff1b/5P4V/R/+H/rf9c/mX77/uX/7v2d+L/wf9J/4r/Yf7/+/fxb+R/9b99f6v/x3+t/1P+Tf8n/D/4z/s/5n/w/+k/5f+Tf9A/6L/5n9n/+f79+Nn+t/uP7T/uP9r/7T/C/wD/2/2h/c/+P/qD9L/g/+R/8H+Pvw/6z+nfxn8A/0n/1f/n/zP/A/37/Fv5/9H/h/9r//H7w/oT81/y/+g/2D+M/4T/Bv/B/qD+RfxP+8/wv+c/xX+h//f7T//3+s//z9vfy39D/of8T/xf59/p/6r/mH/1v2l/f/85/rf8H/xf+5/wH+j//b9ePwL+rP5H/vT7v+GfxB+4P3f/Wf65/f//n9Yf+j+jfxA/nL9v/7H+9P5J+H/iP+m/xf+D/6P8N/6X/2f4X/zH/k/8D+dPxH+lP5/+R/tD/fP+g/9P6z/b32V/tH/gP/z/D/3//4v6b/b/3v+Gf67+L/2f/uPx//gP81/uX6N/03/E/+d/j3/l/0b/gH9D/g/+k/s7+u/yD8B/+r9aPy/9zP91/wf/xP2v/K/yT8N/kf9T/9P8/Pxn+X/7z+sH7r/s37xP0B+P/yP+R/+D8v/sH67+X/5r+p/8V+R/9b92/1f9K/v/59/j/8s/gH8L/pf97/1/+d/3H80/1H8D//b85/vv65+m/x7/d/xP/S39w/7P+s/0j/c/7T/K/1r/p/+9/5/+f/t/47/A/1H/hPx7/E/+j+Q/xP/A/wf+hP/Z/f/2X/o/65/U36B/g/9gP8//Gf1B+M/x/+ZPxB+2T7L/hL9n/0H+U/8J/Tf+A/w3+6PzT9mP//9F/sf6n/Vn83f6f9lP/D9237B/Pfx/99fy/+xP2j/Xn/n/Z3/h/zj+I/+j+qPwv+m/wf+c/7L9R/4P/D/xP9vfj78rPz79j/rT+s//n9fP6F/tP+S/7n+hPxv/U/4b/Jfy/+kPy//s37r/t/9w/vH79P1R/2r9q/vv8Qf//8G/fH5r+/X1E/2v7GfvN8gP//91//5+L/1j+J//l+Ff6R+vP+v+Yv/v91v8D/f/6T/0j+qfwx/Y392v+v+Z/x7/fH89/lH8A/45/p38hP+/9137H/+r8//9r8n/+X5r/l362PyF+0n5Xf2p/l3+Bf9H+23/1/yv/m38mP///2P/Gf+C/g== + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ceb6765..8bbb1cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -850,6 +850,9 @@ Font Size Line Height Paragraph Gap + Image Size + Horizontal Margin + None Orig diff --git a/app/src/oss/java/com/aryan/reader/ml/SpeechBubbleDetector.kt b/app/src/oss/java/com/aryan/reader/ml/SpeechBubbleDetector.kt new file mode 100644 index 0000000..c920bb3 --- /dev/null +++ b/app/src/oss/java/com/aryan/reader/ml/SpeechBubbleDetector.kt @@ -0,0 +1,20 @@ +package com.aryan.reader.ml + +import android.graphics.Bitmap +import timber.log.Timber +import java.io.File + +class SpeechBubbleDetector(modelFile: File) : ISpeechBubbleDetector { + + init { + Timber.i("OSS flavor: SpeechBubbleDetector stub initialized. ONNX features are disabled.") + } + + override fun detectBubbles(bitmap: Bitmap, confidenceThreshold: Float): List { + return emptyList() + } + + override fun close() { + // No-op + } +} \ No newline at end of file diff --git a/app/src/pro/java/com/aryan/reader/ml/SpeechBubbleDetector.kt b/app/src/pro/java/com/aryan/reader/ml/SpeechBubbleDetector.kt new file mode 100644 index 0000000..de2156a --- /dev/null +++ b/app/src/pro/java/com/aryan/reader/ml/SpeechBubbleDetector.kt @@ -0,0 +1,259 @@ +package com.aryan.reader.ml + +import ai.onnxruntime.OnnxTensor +import ai.onnxruntime.OrtEnvironment +import ai.onnxruntime.OrtSession +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.RectF +import androidx.core.graphics.scale +import timber.log.Timber +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.Collections +import kotlin.math.min + +class SpeechBubbleDetector(modelFile: File) : ISpeechBubbleDetector { + private var env: OrtEnvironment? = null + private var session: OrtSession? = null + private val inputSize = 504 + + private val byteBuffer = ByteBuffer.allocateDirect(3 * inputSize * inputSize * 4).order( + ByteOrder.nativeOrder()) + private val floatBuffer = byteBuffer.asFloatBuffer() + private val pixels = IntArray(inputSize * inputSize) + + init { + try { + env = OrtEnvironment.getEnvironment() + val options = OrtSession.SessionOptions().apply { + // Dynamically use available cores (cap at 4 to prevent thermal throttling) + val threadCount = Runtime.getRuntime().availableProcessors().coerceAtMost(4) + setIntraOpNumThreads(threadCount) + setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT) + + // 1. Thread spinning keeps CPU threads active between operations (reduces latency) + try { + addConfigEntry("session.intra_op.allow_spinning", "1") + } catch (_: Throwable) { + Timber.w("Could not set intra_op.allow_spinning config") + } + + // 2. Enable XNNPACK (Highly optimized ARM CPU execution provider) + try { + // Safest cross-version way to request XNNPACK in Android ORT + addConfigEntry("session.disable_cpu_ep_fallback", "0") + addConfigEntry("optimization.enable_xnnpack", "1") + Timber.i("ONNX XNNPACK requested via config entry for optimized CPU inference.") + } catch (t: Throwable) { + Timber.w(t, "Could not set XNNPACK config entries") + } + } + session = env?.createSession(modelFile.absolutePath, options) + } catch (t: Throwable) { + Timber.e(t, "Fatal error initializing ONNX model") + } + } + + // 3. Synchronized to safely share pre-allocated buffers across calls + @Synchronized + override fun detectBubbles(bitmap: Bitmap, confidenceThreshold: Float): List { + Timber.tag("BubbleZoom").d("Detector: detectBubbles started. Bitmap: ${bitmap.width}x${bitmap.height}, threshold: $confidenceThreshold") + val currentEnv = env ?: run { + Timber.tag("BubbleZoom").w("Detector: OrtEnvironment is null") + return emptyList() + } + val currentSession = session ?: run { + Timber.tag("BubbleZoom").w("Detector: OrtSession is null") + return emptyList() + } + + try { + // 4. Skip unnecessary scaling if already 504x504 + val resized = if (bitmap.width == inputSize && bitmap.height == inputSize) { + bitmap + } else { + bitmap.scale(inputSize, inputSize, false) + } + + floatBuffer.clear() // Reset buffer positions for reuse + resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize) + + val imageArea = inputSize * inputSize + for (i in 0 until imageArea) { + val pixel = pixels[i] + val r = ((pixel shr 16) and 0xFF) / 255.0f + val g = ((pixel shr 8) and 0xFF) / 255.0f + val b = (pixel and 0xFF) / 255.0f + + floatBuffer.put(i, r) + floatBuffer.put(i + imageArea, g) + floatBuffer.put(i + 2 * imageArea, b) + } + floatBuffer.rewind() // Ready for tensor creation + + val inputTensor = OnnxTensor.createTensor(currentEnv, floatBuffer, longArrayOf(1, 3, inputSize.toLong(), inputSize.toLong())) + val inputName = currentSession.inputNames.iterator().next() + + Timber.tag("BubbleZoom").d("Detector: Running ONNX inference...") + val results = currentSession.run(Collections.singletonMap(inputName, inputTensor)) + Timber.tag("BubbleZoom").d("Detector: ONNX inference finished.") + + val parsedResults = mutableListOf() + + var detsOutput: FloatArray? = null + var labelsOutput: FloatArray? = null + var masksOutput: FloatArray? = null + + var numBoxes = 0 + var numClasses = 0 + var maskH = 0 + var maskW = 0 + + Timber.tag("ONNX_SEG").d("--- ONNX OUTPUT TENSORS ---") + results.forEach { entry -> + val value = entry.value as OnnxTensor + val shape = value.info.shape + Timber.tag("ONNX_SEG").d("Name: ${entry.key}, Shape: ${shape.contentToString()}") + + if (entry.key.contains("dets") || entry.key.contains("boxes") || (shape.size == 3 && shape[2] == 4L)) { + numBoxes = shape[1].toInt() + val flatOutput = FloatArray(shape.reduce { acc, l -> acc * l }.toInt()) + value.floatBuffer.get(flatOutput) + detsOutput = flatOutput + } else if (entry.key.contains("labels") || entry.key.contains("scores") || (shape.size == 3 && shape[2] != 4L && shape[1] == numBoxes.toLong())) { + numClasses = shape[2].toInt() + val flatOutput = FloatArray(shape.reduce { acc, l -> acc * l }.toInt()) + value.floatBuffer.get(flatOutput) + labelsOutput = flatOutput + } else if (entry.key.contains("masks") || shape.size == 4) { + maskH = shape[2].toInt() + maskW = shape[3].toInt() + val flatOutput = FloatArray(shape.reduce { acc, l -> acc * l }.toInt()) + value.floatBuffer.get(flatOutput) + masksOutput = flatOutput + } + } + + Timber.tag("BubbleZoom").d("Detector: Outputs mapped. Boxes: $numBoxes, Classes: $numClasses, Masks: ${maskW}x${maskH}") + + if (detsOutput != null && labelsOutput != null) { + var maxCoord = 0f + for (i in 0 until min(100, detsOutput.size)) { + if (detsOutput[i] > maxCoord) maxCoord = detsOutput[i] + } + val isNormalized = maxCoord <= 1.5f + val scaleX = if (isNormalized) bitmap.width.toFloat() else bitmap.width.toFloat() / inputSize + val scaleY = if (isNormalized) bitmap.height.toFloat() else bitmap.height.toFloat() / inputSize + + for (i in 0 until numBoxes) { + var maxConf = 0f + for (c in 0 until numClasses) { + val conf = labelsOutput[i * numClasses + c] + if (conf > maxConf) maxConf = conf + } + + if (maxConf > confidenceThreshold) { + val val0 = detsOutput[i * 4 + 0] + val val1 = detsOutput[i * 4 + 1] + val val2 = detsOutput[i * 4 + 2] + val val3 = detsOutput[i * 4 + 3] + + val w = val2 * 1.08f + val h = val3 * 1.08f + + val rawLeft = val0 - w / 2 + val rawTop = val1 - h / 2 + val rawRight = val0 + w / 2 + val rawBottom = val1 + h / 2 + + val left = rawLeft * scaleX + val top = rawTop * scaleY + val right = rawRight * scaleX + val bottom = rawBottom * scaleY + + var maskBitmap: Bitmap? = null + if (masksOutput != null && maskH > 0 && maskW > 0) { + try { + val maskScaleX = if (isNormalized) maskW.toFloat() else maskW.toFloat() / inputSize + val maskScaleY = if (isNormalized) maskH.toFloat() else maskH.toFloat() / inputSize + + val mLeft = (rawLeft * maskScaleX).toInt().coerceIn(0, maskW - 1) + val mTop = (rawTop * maskScaleY).toInt().coerceIn(0, maskH - 1) + val mRight = (rawRight * maskScaleX).toInt().coerceIn(0, maskW - 1) + val mBottom = (rawBottom * maskScaleY).toInt().coerceIn(0, maskH - 1) + + val cropW = mRight - mLeft + val cropH = mBottom - mTop + + if (cropW > 0 && cropH > 0) { + val maskBmp = Bitmap.createBitmap(cropW, cropH, Bitmap.Config.ALPHA_8) + val maskPixels = IntArray(cropW * cropH) + val offset = i * maskW * maskH + + // Dilate radius: expands the mask slightly to include outline + val dilationRadius = 1 + + for (y in 0 until cropH) { + for (x in 0 until cropW) { + val maskX = mLeft + x + val maskY = mTop + y + + var isWhite = false + + // Morphological Dilation: Check neighbors to expand & smooth the mask + for (dy in -dilationRadius..dilationRadius) { + for (dx in -dilationRadius..dilationRadius) { + val nx = (maskX + dx).coerceIn(0, maskW - 1) + val ny = (maskY + dy).coerceIn(0, maskH - 1) + val p = ny * maskW + nx + // > -0.5f captures slightly softer edge bounds + if (offset + p < masksOutput.size && masksOutput[offset + p] > -0.5f) { + isWhite = true + break + } + } + if (isWhite) break + } + + maskPixels[y * cropW + x] = if (isWhite) Color.WHITE else Color.TRANSPARENT + } + } + maskBmp.setPixels(maskPixels, 0, cropW, 0, 0, cropW, cropH) + maskBitmap = maskBmp + } + } catch (e: Exception) { + Timber.tag("ONNX_SEG").e(e, "Failed to parse mask for bubble $i") + } + } + + // Prevent adding invalid boxes + if (right > left && bottom > top) { + parsedResults.add(SpeechBubble(RectF(left, top, right, bottom), maskBitmap)) + } + } + } + } + + Timber.tag("BubbleZoom").d("Detector: Parsed ${parsedResults.size} valid bubbles above threshold.") + + inputTensor.close() + results.close() + if (resized != bitmap) resized.recycle() + + return parsedResults + + } catch (t: Throwable) { + Timber.tag("BubbleZoom").e(t, "Detector: ONNX Inference failed completely") + } + return emptyList() + } + + override fun close() { + session?.close() + session = null + env?.close() + env = null + } +} diff --git a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt new file mode 100644 index 0000000..c0f5da9 --- /dev/null +++ b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt @@ -0,0 +1,169 @@ +package com.aryan.reader + +import android.app.Application +import android.content.SharedPreferences +import android.content.res.Resources +import android.util.Log +import androidx.work.WorkManager +import com.aryan.reader.data.* +import com.tom_roush.pdfbox.android.PDFBoxResourceLoader +import io.mockk.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var viewModel: MainViewModel + private lateinit var mockApplication: Application + private lateinit var mockPrefs: SharedPreferences + private lateinit var mockEditor: SharedPreferences.Editor + + private val billingStateFlow = MutableStateFlow(ProUpgradeState()) + private val customFontsFlow = MutableStateFlow>(emptyList()) + + @Before + fun setup() { + mockkStatic(Log::class) + every { Log.isLoggable(any(), any()) } returns false + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + + Dispatchers.setMain(testDispatcher) + + mockApplication = mockk() + mockPrefs = mockk(relaxed = true) + mockEditor = mockk(relaxed = true) + val mockResources = mockk(relaxed = true) + + every { mockApplication.applicationContext } returns mockApplication + every { mockApplication.getSharedPreferences(any(), any()) } returns mockPrefs + every { mockApplication.resources } returns mockResources + every { mockPrefs.edit() } returns mockEditor + + every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? } + every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean } + every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int } + every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float } + + mockkStatic(AppDatabase::class) + val mockDb = mockk(relaxed = true) + every { AppDatabase.getDatabase(any()) } returns mockDb + + mockkStatic(WorkManager::class) + every { WorkManager.getInstance(any()) } returns mockk(relaxed = true) + mockkStatic(PDFBoxResourceLoader::class) + every { PDFBoxResourceLoader.init(any()) } just Runs + + mockkConstructor(AuthRepository::class) + mockkConstructor(RecentFilesRepository::class) + mockkConstructor(BillingClientWrapper::class) + mockkConstructor(RemoteConfigRepository::class) + mockkConstructor(FirestoreRepository::class) + mockkConstructor(FeedbackRepository::class) + mockkConstructor(FontsRepository::class) + + every { anyConstructed().proUpgradeState } returns billingStateFlow + every { anyConstructed().getSignedInUser() } returns null + every { anyConstructed().observeAuthState() } returns flowOf(null) + every { anyConstructed().getRecentFilesFlow() } returns flowOf(emptyList()) + every { anyConstructed().activeShelvesFlow } returns flowOf(emptyList()) + every { anyConstructed().shelfCrossRefsFlow } returns flowOf(emptyList()) + every { anyConstructed().tagsFlow } returns flowOf(emptyList()) + every { anyConstructed().tagCrossRefsFlow } returns flowOf(emptyList()) + + coEvery { anyConstructed().migrateLegacyShelvesToRoom() } just Runs + coEvery { anyConstructed().seedTagsIfEmpty(any()) } just Runs + + every { anyConstructed().getAllFonts() } returns customFontsFlow + + viewModel = MainViewModel(mockApplication) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `search query updates uiState when search is active`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.setSearchActive(true) + viewModel.onSearchQueryChange("Moby Dick") + + assertEquals("Moby Dick", viewModel.uiState.value.searchQuery) + assertTrue(viewModel.uiState.value.isSearchActive) + } + + @Test + fun `setSearchActive false clears the search query`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.setSearchActive(true) + viewModel.onSearchQueryChange("Android") + viewModel.setSearchActive(false) + + assertEquals("", viewModel.uiState.value.searchQuery) + assertFalse(viewModel.uiState.value.isSearchActive) + } + + @Test + fun `switching theme updates internal state and preferences`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.setAppThemeMode(AppThemeMode.DARK) + + assertEquals(AppThemeMode.DARK, viewModel.uiState.value.appThemeMode) + verify { mockEditor.putString("app_theme_mode", AppThemeMode.DARK.name) } + } + + @Test + fun `setTabsEnabled persists to shared preferences`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.setTabsEnabled(true) + + assertTrue(viewModel.uiState.value.isTabsEnabled) + verify { mockEditor.putBoolean("tabs_enabled", true) } + } + + @Test + fun `banner message logic works correctly`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.showBanner("Test Message", isError = true) + + val currentBanner = viewModel.uiState.value.bannerMessage + assertEquals("Test Message", currentBanner?.message) + assertTrue(currentBanner?.isError == true) + + viewModel.bannerMessageShown() + assertEquals(null, viewModel.uiState.value.bannerMessage) + } +} \ No newline at end of file