From fa86295a5e63b6ef957055d01274e111cbc01cf1 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sat, 11 Apr 2026 21:09:59 +0200 Subject: [PATCH 01/11] clean up code --- .../notable/editor/utils/persistBitmap.kt | 3 +- .../com/ethran/notable/io/ImportEngine.kt | 21 ++---- .../ethran/notable/io/PageContentRenderer.kt | 23 ++----- .../notable/io/ThumbnailBackfillQueue.kt | 1 + .../ethran/notable/io/ThumbnailGenerator.kt | 68 +++---------------- .../java/com/ethran/notable/io/XoppFile.kt | 5 +- .../java/com/ethran/notable/io/importPdf.kt | 5 +- 7 files changed, 28 insertions(+), 98 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/editor/utils/persistBitmap.kt b/app/src/main/java/com/ethran/notable/editor/utils/persistBitmap.kt index 8f202c6d..76bd2d57 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/persistBitmap.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/persistBitmap.kt @@ -29,11 +29,10 @@ private val log = ShipBook.getLogger("bitmapUtils") class Provider : FileProvider(R.xml.file_paths) private const val EQUALITY_THRESHOLD = 0.01f -private const val THUMBNAIL_WIDTH = 500 +const val THUMBNAIL_WIDTH = 500 private const val THUMBNAIL_QUALITY = 60 private const val PREVIEW_QUALITY = 90 -fun getThumbnailTargetWidthPx(): Int = THUMBNAIL_WIDTH fun getThumbnailFile(context: Context, pageID: String): File = File(context.filesDir, "pages/previews/thumbs/$pageID") diff --git a/app/src/main/java/com/ethran/notable/io/ImportEngine.kt b/app/src/main/java/com/ethran/notable/io/ImportEngine.kt index 53031f39..35bdf307 100644 --- a/app/src/main/java/com/ethran/notable/io/ImportEngine.kt +++ b/app/src/main/java/com/ethran/notable/io/ImportEngine.kt @@ -4,12 +4,10 @@ import android.content.Context import android.net.Uri import androidx.annotation.WorkerThread import com.ethran.notable.data.db.BookRepository -import com.ethran.notable.data.db.Image import com.ethran.notable.data.db.ImageRepository import com.ethran.notable.data.db.Notebook -import com.ethran.notable.data.db.Page import com.ethran.notable.data.db.PageRepository -import com.ethran.notable.data.db.Stroke +import com.ethran.notable.data.db.PageWithData import com.ethran.notable.data.db.StrokeRepository import com.ethran.notable.data.events.AppEvent import com.ethran.notable.data.events.AppEventBus @@ -19,18 +17,6 @@ import io.shipbook.shipbooksdk.ShipBook import javax.inject.Inject -/** - * A standardized data structure representing a page and its content, - * as parsed from an import file. This is used to pass data from a - * file parser to the ImportEngine. - */ -data class PageContent( - val page: Page, - val strokes: List, - val images: List -) - - /** * Defines the strategy to resolve conflicts when importing a book that already exists in the database. */ @@ -83,8 +69,9 @@ class ImportEngine @Inject constructor( private val appEventBus: AppEventBus ) { private val log = ShipBook.getLogger("ImportEngine") + @Inject - lateinit var xoppFile : XoppFile + lateinit var xoppFile: XoppFile /** * Imports a notebook from the given URI. It recognizes the file type and @@ -197,7 +184,7 @@ class ImportEngine @Inject constructor( } - private fun merge(fileData: PageContent, options: ImportOptions) { + private fun merge(fileData: PageWithData, options: ImportOptions) { require(options.saveToBookId != null) { "saveToBookId cannot be null when merging" } require(options.conflictStrategy != null) { "conflictStrategy cannot be null when merging" } log.d("Conflict detected. Strategy: ${options.conflictStrategy}") diff --git a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt index 0db1c172..19a4299d 100644 --- a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt +++ b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt @@ -9,10 +9,8 @@ import com.ethran.notable.SCREEN_HEIGHT import com.ethran.notable.SCREEN_WIDTH import com.ethran.notable.data.AppRepository import com.ethran.notable.data.datastore.GlobalAppSettings -import com.ethran.notable.data.db.Image -import com.ethran.notable.data.db.Page import com.ethran.notable.data.db.PageRepository -import com.ethran.notable.data.db.Stroke +import com.ethran.notable.data.db.PageWithData import com.ethran.notable.data.db.getBackgroundType import com.ethran.notable.data.model.BackgroundType import com.ethran.notable.data.model.BackgroundType.Native @@ -38,11 +36,6 @@ class PageContentRenderer @Inject constructor( private val pageRepo: PageRepository, private val appRepository: AppRepository ) { - data class PageContent( - val page: Page, - val strokes: List, - val images: List - ) suspend fun renderPageBitmap(pageId: String, target: RenderTarget): Bitmap { ensureNotMainThread("PageContentRenderer") @@ -70,12 +63,10 @@ class PageContentRenderer @Inject constructor( val scale: Float ) - suspend fun loadPageContent(pageId: String): PageContent = withContext(Dispatchers.IO) { - val pageWithData = pageRepo.getWithDataById(pageId) - PageContent(pageWithData.page, pageWithData.strokes, pageWithData.images) + suspend fun loadPageContent(pageId: String): PageWithData = withContext(Dispatchers.IO) { + pageRepo.getWithDataById(pageId) } - - suspend fun resolveExportBackgroundType(data: PageContent): BackgroundType { + suspend fun resolveExportBackgroundType(data: PageWithData): BackgroundType { return data.page.notebookId?.let { bookId -> val pageNumber = withContext(Dispatchers.IO) { appRepository.getPageNumber(bookId, data.page.id) @@ -86,7 +77,7 @@ class PageContentRenderer @Inject constructor( suspend fun drawPage( canvas: Canvas, - data: PageContent, + data: PageWithData, scroll: Offset, scaleFactor: Float, backgroundType: BackgroundType @@ -108,7 +99,7 @@ class PageContentRenderer @Inject constructor( } // Returns (width, height) - fun computeContentDimensions(data: PageContent): Pair { + fun computeContentDimensions(data: PageWithData): Pair { if (data.strokes.isEmpty() && data.images.isEmpty()) { return SCREEN_WIDTH to SCREEN_HEIGHT } @@ -119,7 +110,7 @@ class PageContentRenderer @Inject constructor( val imageRight = data.images.maxOfOrNull { it.x + it.width } ?: 0 val rawHeight = maxOf(strokeBottom, imageBottom) + - if (GlobalAppSettings.current.visualizePdfPagination) 0 else 50 + if (GlobalAppSettings.current.visualizePdfPagination) 0 else 50 val rawWidth = maxOf(strokeRight, imageRight) + 50 val height = rawHeight.coerceAtLeast(SCREEN_HEIGHT) diff --git a/app/src/main/java/com/ethran/notable/io/ThumbnailBackfillQueue.kt b/app/src/main/java/com/ethran/notable/io/ThumbnailBackfillQueue.kt index a22dcd2b..4383c4d0 100644 --- a/app/src/main/java/com/ethran/notable/io/ThumbnailBackfillQueue.kt +++ b/app/src/main/java/com/ethran/notable/io/ThumbnailBackfillQueue.kt @@ -41,6 +41,7 @@ class ThumbnailBackfillQueue @Inject constructor( private var lastUpdateMs = 0L init { + // listen for thumbnail generation requests applicationScope.launch(ioDispatcher) { for (pageId in queue) { processOne(pageId) diff --git a/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt b/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt index a62ab099..139cb1e0 100644 --- a/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt +++ b/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt @@ -4,8 +4,8 @@ import android.content.Context import com.ethran.notable.data.db.Page import com.ethran.notable.data.db.PageRepository import com.ethran.notable.di.IoDispatcher +import com.ethran.notable.editor.utils.THUMBNAIL_WIDTH import com.ethran.notable.editor.utils.getThumbnailFile -import com.ethran.notable.editor.utils.getThumbnailTargetWidthPx import com.ethran.notable.editor.utils.persistBitmapThumbnail import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -15,14 +15,10 @@ import io.shipbook.shipbooksdk.ShipBook import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import java.io.File import javax.inject.Inject import javax.inject.Singleton @@ -32,6 +28,9 @@ enum class ThumbnailEnsureResult { PAGE_NOT_FOUND } + +const val thumbnailGeneratorStaleMs = 3600000 // 1h + @EntryPoint @InstallIn(SingletonComponent::class) interface ThumbnailGeneratorEntryPoint { @@ -63,9 +62,6 @@ class ThumbnailGenerator @Inject constructor( */ val thumbnailUpdated = _thumbnailUpdated.asSharedFlow() - // Map of pageId to its last known generation timestamp (signature) - private val _thumbnailSignatures = MutableStateFlow>(emptyMap()) - val thumbnailSignatures = _thumbnailSignatures.asStateFlow() /** * Checks if a thumbnail is up to date and generates it if necessary. @@ -86,12 +82,7 @@ class ThumbnailGenerator @Inject constructor( val marker = CompletableDeferred() val acquired = inFlightLock.withLock { - if (inFlight.containsKey(pageId)) { - false - } else { - inFlight[pageId] = marker - true - } + inFlight.putIfAbsent(pageId, marker) == null // it will return null if there wasn't any value } if (!acquired) { @@ -102,8 +93,6 @@ class ThumbnailGenerator @Inject constructor( try { val result = generateIfNeeded(page) if (result == ThumbnailEnsureResult.GENERATED) { - val now = System.currentTimeMillis() - _thumbnailSignatures.update { it + (pageId to now) } _thumbnailUpdated.tryEmit(pageId) } marker.complete(result) @@ -116,23 +105,14 @@ class ThumbnailGenerator @Inject constructor( } } - /** - * Returns the persistent signature (last modified time) for a thumbnail. - * Performs IO. - */ - suspend fun getThumbnailSignature(pageId: String): Long = withContext(ioDispatcher) { - val file = getThumbnailFile(context, pageId) - if (file.exists()) file.lastModified() else 0L - } private suspend fun generateIfNeeded(page: Page): ThumbnailEnsureResult { if (!isThumbnailStale(page)) return ThumbnailEnsureResult.UP_TO_DATE - val targetWidth = getThumbnailTargetWidthPx() val bitmap = pageContentRenderer.renderPageBitmap( pageId = page.id, target = RenderTarget.Thumbnail( - maxWidthPx = targetWidth, + maxWidthPx = THUMBNAIL_WIDTH, maxHeightPx = Int.MAX_VALUE ) ) @@ -140,9 +120,7 @@ class ThumbnailGenerator @Inject constructor( bitmap.useAndRecycle { rendered -> persistBitmapThumbnail(context, rendered, page.id) } - writeThumbnailMeta(page) - - log.d("Thumbnail ensured for pageId=${page.id}") + log.d("Thumbnail generated for pageId=${page.id}") return ThumbnailEnsureResult.GENERATED } @@ -150,38 +128,10 @@ class ThumbnailGenerator @Inject constructor( val thumbFile = getThumbnailFile(context, page.id) if (!thumbFile.exists()) return@withContext true - if (page.updatedAt.time > thumbFile.lastModified()) return@withContext true - - val meta = readThumbnailMeta(page.id) ?: return@withContext true - meta.updatedAtMs < page.updatedAt.time || meta.scroll != page.scroll - } - - private suspend fun writeThumbnailMeta(page: Page) = withContext(ioDispatcher) { - val metaFile = thumbnailMetaFile(page.id) - metaFile.parentFile?.mkdirs() - metaFile.writeText("${page.updatedAt.time}|${page.scroll}") - } - - private fun readThumbnailMeta(pageId: String): ThumbnailMeta? { - val metaFile = thumbnailMetaFile(pageId) - if (!metaFile.exists()) return null - - val parts = metaFile.readText().split("|") - if (parts.size != 2) return null - val updatedAtMs = parts[0].toLongOrNull() ?: return null - val scroll = parts[1].toIntOrNull() ?: return null - return ThumbnailMeta(updatedAtMs = updatedAtMs, scroll = scroll) - } - - private fun thumbnailMetaFile(pageId: String): File { - val thumbFile = getThumbnailFile(context, pageId) - return File(thumbFile.parentFile, "$pageId.meta") + if (page.updatedAt.time + thumbnailGeneratorStaleMs > thumbFile.lastModified()) return@withContext true + else return@withContext false } - private data class ThumbnailMeta( - val updatedAtMs: Long, - val scroll: Int - ) private inline fun android.graphics.Bitmap.useAndRecycle(block: (android.graphics.Bitmap) -> Unit) { try { diff --git a/app/src/main/java/com/ethran/notable/io/XoppFile.kt b/app/src/main/java/com/ethran/notable/io/XoppFile.kt index 9d3aec7b..66a0ba6b 100644 --- a/app/src/main/java/com/ethran/notable/io/XoppFile.kt +++ b/app/src/main/java/com/ethran/notable/io/XoppFile.kt @@ -18,6 +18,7 @@ import com.ethran.notable.data.db.BookRepository import com.ethran.notable.data.db.Image import com.ethran.notable.data.db.Page import com.ethran.notable.data.db.PageRepository +import com.ethran.notable.data.db.PageWithData import com.ethran.notable.data.db.Stroke import com.ethran.notable.data.db.StrokePoint import com.ethran.notable.data.events.AppEvent @@ -291,7 +292,7 @@ class XoppFile @Inject constructor( * @param context The application context. * @param uri The URI of the `.xopp` file to import. */ - suspend fun importBook(uri: Uri, savePageToDatabase: suspend (PageContent) -> Unit) { + suspend fun importBook(uri: Uri, savePageToDatabase: suspend (PageWithData) -> Unit) { log.v("Importing book from $uri") ensureNotMainThread("xoppImportBook") val inputStream = context.contentResolver.openInputStream(uri) ?: return @@ -306,7 +307,7 @@ class XoppFile @Inject constructor( val page = Page() val strokes = parseStrokes(pageElement, page) val images = parseImages(pageElement, page) - savePageToDatabase(PageContent(page, strokes, images)) + savePageToDatabase(PageWithData(page, strokes, images)) } log.i("Successfully imported book with ${pages.length} pages.") } diff --git a/app/src/main/java/com/ethran/notable/io/importPdf.kt b/app/src/main/java/com/ethran/notable/io/importPdf.kt index 404b37dd..69128b18 100644 --- a/app/src/main/java/com/ethran/notable/io/importPdf.kt +++ b/app/src/main/java/com/ethran/notable/io/importPdf.kt @@ -6,6 +6,7 @@ import android.net.Uri import androidx.annotation.WorkerThread import com.ethran.notable.data.copyBackgroundToDatabase import com.ethran.notable.data.db.Page +import com.ethran.notable.data.db.PageWithData import com.ethran.notable.data.model.BackgroundType import com.ethran.notable.utils.ensureNotMainThread import io.shipbook.shipbooksdk.ShipBook @@ -46,7 +47,7 @@ fun handleFileSaving( suspend fun importPdf( fileToSave: File, options: ImportOptions, - savePageToDatabase: suspend (PageContent) -> Unit + savePageToDatabase: suspend (PageWithData) -> Unit ): String { log.v("Importing PDF from") @@ -59,7 +60,7 @@ suspend fun importPdf( backgroundType = if (options.linkToExternalFile) BackgroundType.AutoPdf.key else BackgroundType.Pdf(i).key ) - savePageToDatabase(PageContent(page, emptyList(), emptyList())) + savePageToDatabase(PageWithData(page, emptyList(), emptyList())) } return "Imported ${fileToSave.name}" } \ No newline at end of file From bc718f7407f15f5a19465a3a744f25eefa47a8f2 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sat, 11 Apr 2026 22:09:13 +0200 Subject: [PATCH 02/11] Refactor background rendering logic to improve modularity and consolidate drawing operations. ### Editor Logic - **PageView**: - Introduced `drawBgToCanvas` to centralize background resource resolution (bitmaps, PDFs, and internal resources like `iris`). - Updated `onScrollChanged` and `redrawFullCanvas` to use the new unified drawing method. - **Background Rendering**: - Simplified `drawBg` in `backgrounds.kt` by removing direct dependency on `Context` and `PageView`, now accepting a pre-resolved `resourceBitmap`. - Removed redundant `drawBackgroundImages` and `drawPdfPage` helper functions, moving their logic into the calling context or the simplified `drawBg`. - Refactored `BackgroundType` handling to separate native drawing (colors/dots/lines) from bitmap-based drawing. ### Clean up - Improved logging in `renderFromFile.kt` for image decoding failures. - Temporarily disabled background drawing in `PageContentRenderer` and updated `pageDrawing.kt` to use the new `PageView` drawing flow. --- .../com/ethran/notable/editor/PageView.kt | 55 +++++--- .../notable/editor/drawing/backgrounds.kt | 126 +++--------------- .../notable/editor/drawing/pageDrawing.kt | 3 +- .../ethran/notable/io/PageContentRenderer.kt | 16 +-- .../com/ethran/notable/io/renderFromFile.kt | 8 +- 5 files changed, 74 insertions(+), 134 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/editor/PageView.kt b/app/src/main/java/com/ethran/notable/editor/PageView.kt index c0ee53ad..8eba8d65 100644 --- a/app/src/main/java/com/ethran/notable/editor/PageView.kt +++ b/app/src/main/java/com/ethran/notable/editor/PageView.kt @@ -10,9 +10,13 @@ import android.graphics.Paint import android.graphics.Rect import android.graphics.RectF import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.res.imageResource import androidx.compose.ui.unit.IntOffset import androidx.core.graphics.createBitmap import androidx.core.graphics.toRect +import com.ethran.notable.R import com.ethran.notable.SCREEN_HEIGHT import com.ethran.notable.SCREEN_WIDTH import com.ethran.notable.data.CachedBackground @@ -450,10 +454,7 @@ class PageView( // draw just background. val backgroundType = pageDataManager.getBackgroundType() if (backgroundType == BackgroundType.Native) { - val bg = pageDataManager.getBackgroundName() - drawBg( - context, windowedCanvas, backgroundType, bg, scroll, 1f, this - ) + drawBgToCanvas(null) } else windowedCanvas.drawColor(Color.WHITE) return false @@ -666,18 +667,7 @@ class PageView( log.d("Redrawing full logical rect: $redrawRect") windowedCanvas.drawColor(Color.BLACK) - val backgroundType = pageDataManager.getBackgroundType() ?: BackgroundType.Native - val bg = pageDataManager.getBackgroundName() - drawBg( - context, - windowedCanvas, - backgroundType, - bg, - scroll, - zoomLevel.value, - this, - redrawRect - ) + drawBgToCanvas(redrawRect) pageDataManager.cacheBitmap(currentPageId, windowedBitmap) drawAreaScreenCoordinates(redrawRect) @@ -823,6 +813,39 @@ class PageView( } + fun drawBgToCanvas(clipRect: Rect?) { + val backgroundType = pageDataManager.getBackgroundType() ?: BackgroundType.Native + val bg = pageDataManager.getBackgroundName() + val pageNumber = currentPageNumber + val scale = zoomLevel.value + val bgImage: Bitmap? = + when (backgroundType) { + BackgroundType.Image, BackgroundType.CoverImage, BackgroundType.AutoPdf, + is BackgroundType.Pdf, BackgroundType.ImageRepeating -> { + if (backgroundType is BackgroundType.Image && bg == "iris") { + val resId = R.drawable.iris + ImageBitmap.imageResource(context.resources, resId).asAndroidBitmap() + } else { + getOrLoadBackground(bg, pageNumber, scale) + } + } + BackgroundType.Native -> { + null + } + } + drawBg( + canvas = windowedCanvas, + backgroundType = backgroundType, + background = bg, + scroll = scroll, + resourceBitmap = bgImage, + scale = scale, + repeat = false, + clipRect = clipRect + ) + } + + fun updateDimensions(newWidth: Int, newHeight: Int) { if (newWidth != viewWidth || newHeight != viewHeight) { log.d("Updating dimensions: $newWidth x $newHeight") diff --git a/app/src/main/java/com/ethran/notable/editor/drawing/backgrounds.kt b/app/src/main/java/com/ethran/notable/editor/drawing/backgrounds.kt index c98af775..b3c8fd7d 100644 --- a/app/src/main/java/com/ethran/notable/editor/drawing/backgrounds.kt +++ b/app/src/main/java/com/ethran/notable/editor/drawing/backgrounds.kt @@ -1,6 +1,5 @@ package com.ethran.notable.editor.drawing -import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color @@ -10,22 +9,13 @@ import android.graphics.Path import android.graphics.Rect import android.graphics.RectF import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asAndroidBitmap -import androidx.compose.ui.res.imageResource import androidx.compose.ui.unit.IntOffset -import com.ethran.notable.R import com.ethran.notable.SCREEN_HEIGHT import com.ethran.notable.SCREEN_WIDTH import com.ethran.notable.data.datastore.GlobalAppSettings import com.ethran.notable.data.model.BackgroundType -import com.ethran.notable.editor.PageView import com.ethran.notable.editor.utils.scaleRect -import com.ethran.notable.io.getPdfPageCount -import com.ethran.notable.io.loadBackgroundBitmap -import com.ethran.notable.utils.logCallStack import com.onyx.android.sdk.extension.copy -import com.onyx.android.sdk.extension.isNotNull import io.shipbook.shipbooksdk.ShipBook import kotlin.math.cos import kotlin.math.floor @@ -185,40 +175,6 @@ fun drawHexagon(canvas: Canvas, centerX: Float, centerY: Float, r: Float) { canvas.drawPath(path, defaultPaintStroke) } -fun drawBackgroundImages( - context: Context, - canvas: Canvas, - backgroundImage: String, - scroll: Offset, - page: PageView? = null, - scale: Float = 1.0F, - repeat: Boolean = false, -) { - try { - val imageBitmap = when (backgroundImage) { - "iris" -> { - val resId = R.drawable.iris - ImageBitmap.imageResource(context.resources, resId).asAndroidBitmap() - } - - else -> { - if (page != null) { - page.getOrLoadBackground(backgroundImage, -1, scale) - } else { - loadBackgroundBitmap(backgroundImage, -1, scale) - } - } - } - - if (imageBitmap != null) { - drawBitmapToCanvas(canvas, imageBitmap, scroll, scale, repeat) - } else { - log.e("Failed to load image from $backgroundImage") - } - } catch (e: Exception) { - log.e("Error loading background image: ${e.message}", e) - } -} fun drawTitleBox(canvas: Canvas) { @@ -257,37 +213,6 @@ fun drawTitleBox(canvas: Canvas) { } -fun drawPdfPage( - canvas: Canvas, - pdfUriString: String, - pageNumber: Int, - scroll: Offset, - page: PageView? = null, - scale: Float = 1.0f -) { - if (pageNumber < 0) { - log.e("Page number should not be ${pageNumber}, uri: $pdfUriString") - logCallStack("DrawPdfPage") - return - } - try { - val imageBitmap = if (page != null) { - page.getOrLoadBackground(pdfUriString, pageNumber, scale) - } else { - // here, if we don't have page, we assume are doing export, - // so background have to be in better quality - // (it is scaled down, but still takes whole screen, not like when we render it) - loadBackgroundBitmap(pdfUriString, pageNumber, 1f) - } - if (imageBitmap.isNotNull()) { - drawBitmapToCanvas(canvas, imageBitmap, scroll, scale, false) - } - - } catch (e: Exception) { - log.e("drawPdfPage: Failed to render PDF", e) - } -} - fun drawBitmapToCanvas( canvas: Canvas, imageBitmap: Bitmap, scroll: Offset, scale: Float, repeat: Boolean ) { @@ -342,50 +267,23 @@ fun drawBitmapToCanvas( } fun drawBg( - context: Context, canvas: Canvas, backgroundType: BackgroundType, background: String, scroll: Offset = Offset.Zero, - scale: Float = 1f, // When exporting, we change scale of canvas. therefore canvas.width/height is scaled - page: PageView? = null, - clipRect: Rect? = null // before the scaling + resourceBitmap: Bitmap?, + scale: Float = 1f, // When exporting, we change scale of canvas. therefore canvas.width/height is scaled + repeat: Boolean = false, // for repeating image + clipRect: Rect? = null, // before the scaling ) { + log.v("Loading the background") clipRect?.let { canvas.save() canvas.clipRect(scaleRect(it, scale)) } when (backgroundType) { - is BackgroundType.Image -> { - drawBackgroundImages(context, canvas, background, scroll, page, scale) - } - - is BackgroundType.ImageRepeating -> { - drawBackgroundImages(context, canvas, background, scroll, page, scale, true) - } - - is BackgroundType.CoverImage -> { - drawBackgroundImages(context, canvas, background, Offset.Zero, page, scale) - drawTitleBox(canvas) - } - - is BackgroundType.AutoPdf -> { - if (page == null) return - val pageNumber = page.currentPageNumber - if (0 <= pageNumber && pageNumber < getPdfPageCount(background)) drawPdfPage( - canvas, background, pageNumber, scroll, page, scale - ) - else { - log.w("Page number $pageNumber is out of bounds") - canvas.drawColor(Color.WHITE) - } - } - - is BackgroundType.Pdf -> { - drawPdfPage(canvas, background, backgroundType.page, scroll, page, scale) - } - + // draw native background for it we don't need a resource is BackgroundType.Native -> { when (background) { "blank" -> canvas.drawColor(Color.WHITE) @@ -398,6 +296,18 @@ fun drawBg( } } } + + else -> { + if (resourceBitmap != null) { + drawBitmapToCanvas(canvas, resourceBitmap, scroll, scale, repeat) + if (backgroundType is BackgroundType.CoverImage) { + drawTitleBox(canvas) + } + } else { + log.i("No resource provided to draw, maybe out of pages in pdf?") + canvas.drawColor(Color.WHITE) + } + } } drawMargin(canvas, scroll, scale) diff --git a/app/src/main/java/com/ethran/notable/editor/drawing/pageDrawing.kt b/app/src/main/java/com/ethran/notable/editor/drawing/pageDrawing.kt index 8ce4aa08..8a5ef57c 100644 --- a/app/src/main/java/com/ethran/notable/editor/drawing/pageDrawing.kt +++ b/app/src/main/java/com/ethran/notable/editor/drawing/pageDrawing.kt @@ -157,7 +157,8 @@ fun drawOnCanvasFromPage( // for debugging: drawColor(Color.WHITE) - drawBg(page.context, this, backgroundType, background, page.scroll, zoomLevel, page) +// drawBg(page.context, this, backgroundType, background, page.scroll, zoomLevel, page, page.currentPageNumber) + page.drawBgToCanvas(null) if (GlobalAppSettings.current.debugMode) { drawDebugRectWithLabels(canvas, RectF(canvasClipBounds), Color.BLACK) } diff --git a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt index 19a4299d..0e271c96 100644 --- a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt +++ b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt @@ -85,14 +85,14 @@ class PageContentRenderer @Inject constructor( withContext(Dispatchers.Default) { canvas.scale(scaleFactor, scaleFactor) val scaledScroll = scroll / scaleFactor - drawBg( - context = context, - canvas = canvas, - backgroundType = backgroundType, - background = data.page.background, - scroll = scaledScroll, - scale = scaleFactor - ) +// drawBg( +// context = context, +// canvas = canvas, +// backgroundType = backgroundType, +// background = data.page.background, +// scroll = scaledScroll, +// scale = scaleFactor +// ) data.images.forEach { drawImage(context, canvas, it, -scaledScroll) } data.strokes.forEach { drawStroke(canvas, it, -scaledScroll) } } diff --git a/app/src/main/java/com/ethran/notable/io/renderFromFile.kt b/app/src/main/java/com/ethran/notable/io/renderFromFile.kt index 9b1f6bb1..eeef9dd7 100644 --- a/app/src/main/java/com/ethran/notable/io/renderFromFile.kt +++ b/app/src/main/java/com/ethran/notable/io/renderFromFile.kt @@ -63,7 +63,13 @@ fun loadBackgroundBitmap(filePath: String, pageNumber: Int, scale: Float): Bitma timer.step("decode bitmap image") val result = BitmapFactory.decodeFile(file.absolutePath)?.asImageBitmap() if (result == null) - log.e("loadBackgroundBitmap: result is null, couldn't decode image, file name ends with ${filePath.takeLast(4)}") + log.e( + "loadBackgroundBitmap: result is null, couldn't decode image, file name ends with ${ + filePath.takeLast( + 4 + ) + }" + ) timer.end("loaded background") return result?.asAndroidBitmap() } catch (e: Exception) { From 77dae0af098a2cbf00bc2fa19375000393c6d516 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sat, 11 Apr 2026 22:21:04 +0200 Subject: [PATCH 03/11] Refactor page rendering and thumbnail generation logic - **ThumbnailGenerator**: Temporarily force `isThumbnailStale` to always return `true` to ensure thumbnails are refreshed. - **PageContentRenderer**: - Updated `renderPage` and `renderContent` to resolve the background type internally instead of accepting it as a parameter. - Added logic to load background bitmaps, including specific support for resource-based backgrounds (e.g., "iris"). - Re-enabled background drawing in `renderContent` and integrated it with images and strokes. - **ExportEngine**: Simplified calls to `renderContent` by removing the redundant `backgroundType` parameter. --- .../com/ethran/notable/io/ExportEngine.kt | 3 - .../ethran/notable/io/PageContentRenderer.kt | 61 +++++++++++++------ .../ethran/notable/io/ThumbnailGenerator.kt | 1 + 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/io/ExportEngine.kt b/app/src/main/java/com/ethran/notable/io/ExportEngine.kt index 1926eb93..065c8f6a 100644 --- a/app/src/main/java/com/ethran/notable/io/ExportEngine.kt +++ b/app/src/main/java/com/ethran/notable/io/ExportEngine.kt @@ -395,7 +395,6 @@ class ExportEngine @Inject constructor( ensureNotMainThread("ExportPdf") val data = pageContentRenderer.loadPageContent(pageId) val (_, contentHeightPx) = pageContentRenderer.computeContentDimensions(data) - val backgroundType = pageContentRenderer.resolveExportBackgroundType(data) val scaleFactor = A4_WIDTH.toFloat() / SCREEN_WIDTH.toFloat() val scaledHeight = (contentHeightPx * scaleFactor).toInt() @@ -412,7 +411,6 @@ class ExportEngine @Inject constructor( data = data, scroll = Offset(0f, currentTop.toFloat()), scaleFactor = scaleFactor, - backgroundType = backgroundType ) doc.finishPage(page) currentTop += A4_HEIGHT @@ -426,7 +424,6 @@ class ExportEngine @Inject constructor( data = data, scroll = Offset.Zero, scaleFactor = scaleFactor, - backgroundType = backgroundType ) doc.finishPage(page) } diff --git a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt index 0e271c96..909bf3a8 100644 --- a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt +++ b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt @@ -4,7 +4,11 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.res.imageResource import androidx.core.graphics.createBitmap +import com.ethran.notable.R import com.ethran.notable.SCREEN_HEIGHT import com.ethran.notable.SCREEN_WIDTH import com.ethran.notable.data.AppRepository @@ -50,8 +54,7 @@ class PageContentRenderer @Inject constructor( canvas = Canvas(bitmap), data = data, scroll = Offset.Zero, - scaleFactor = size.scale, - backgroundType = data.page.getBackgroundType() + scaleFactor = size.scale ) } } @@ -66,6 +69,7 @@ class PageContentRenderer @Inject constructor( suspend fun loadPageContent(pageId: String): PageWithData = withContext(Dispatchers.IO) { pageRepo.getWithDataById(pageId) } + suspend fun resolveExportBackgroundType(data: PageWithData): BackgroundType { return data.page.notebookId?.let { bookId -> val pageNumber = withContext(Dispatchers.IO) { @@ -79,22 +83,47 @@ class PageContentRenderer @Inject constructor( canvas: Canvas, data: PageWithData, scroll: Offset, - scaleFactor: Float, - backgroundType: BackgroundType + scaleFactor: Float ) { + val resolvedBackgroundType = resolveExportBackgroundType(data) + + val pageNumber = if (resolvedBackgroundType is BackgroundType.Pdf) { + resolvedBackgroundType.page + } else { + -1 + } + + val bgImage: Bitmap? = withContext(Dispatchers.IO) { + when (resolvedBackgroundType) { + BackgroundType.Image, BackgroundType.CoverImage, BackgroundType.AutoPdf, + is BackgroundType.Pdf, BackgroundType.ImageRepeating -> { + if (resolvedBackgroundType is BackgroundType.Image && data.page.background == "iris") { + val resId = R.drawable.iris + ImageBitmap.imageResource(context.resources, resId).asAndroidBitmap() + } else { + loadBackgroundBitmap(data.page.background, pageNumber, scaleFactor) + } + } + + Native -> null + } + } + withContext(Dispatchers.Default) { canvas.scale(scaleFactor, scaleFactor) - val scaledScroll = scroll / scaleFactor -// drawBg( -// context = context, -// canvas = canvas, -// backgroundType = backgroundType, -// background = data.page.background, -// scroll = scaledScroll, -// scale = scaleFactor -// ) - data.images.forEach { drawImage(context, canvas, it, -scaledScroll) } - data.strokes.forEach { drawStroke(canvas, it, -scaledScroll) } + + drawBg( + canvas = canvas, + backgroundType = resolvedBackgroundType, + background = data.page.background, + scroll = scroll, + resourceBitmap = bgImage, + scale = scaleFactor, + repeat = resolvedBackgroundType is BackgroundType.ImageRepeating + ) + + data.images.forEach { drawImage(context, canvas, it, -scroll) } + data.strokes.forEach { drawStroke(canvas, it, -scroll) } } } @@ -144,5 +173,3 @@ class PageContentRenderer @Inject constructor( } } } - - diff --git a/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt b/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt index 139cb1e0..e1efd346 100644 --- a/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt +++ b/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt @@ -125,6 +125,7 @@ class ThumbnailGenerator @Inject constructor( } private suspend fun isThumbnailStale(page: Page): Boolean = withContext(ioDispatcher) { + return@withContext true val thumbFile = getThumbnailFile(context, page.id) if (!thumbFile.exists()) return@withContext true From 1eec939cf3209bdb233dd40b8b961c350e0deeb3 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sun, 12 Apr 2026 22:12:15 +0200 Subject: [PATCH 04/11] remove waitForEpdRefresh --- .../com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt index 3e3deb3f..a4fdde91 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt @@ -9,7 +9,6 @@ import com.ethran.notable.editor.utils.cleanAllStrokes import com.ethran.notable.editor.utils.loadPreview import com.ethran.notable.editor.utils.partialRefreshRegionOnce import com.ethran.notable.editor.utils.selectRectangle -import com.ethran.notable.editor.utils.waitForEpdRefresh import com.onyx.android.sdk.extension.isNull import io.shipbook.shipbooksdk.ShipBook import kotlinx.coroutines.CoroutineScope @@ -217,7 +216,7 @@ class CanvasObserverRegistry( // We need to close all menus if (it) { CanvasEventBus.closeMenusSignal.emit(Unit) - waitForEpdRefresh() +// waitForEpdRefresh() } inputHandler.updateIsDrawing() } From caff1338a155f7332ac5371b9aaeb7d4935fdf03 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sun, 12 Apr 2026 23:05:26 +0200 Subject: [PATCH 05/11] rename to PreviewBitmapStore.kt --- .../editor/utils/{persistBitmap.kt => PreviewBitmapStore.kt} | 3 +++ 1 file changed, 3 insertions(+) rename app/src/main/java/com/ethran/notable/editor/utils/{persistBitmap.kt => PreviewBitmapStore.kt} (98%) diff --git a/app/src/main/java/com/ethran/notable/editor/utils/persistBitmap.kt b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt similarity index 98% rename from app/src/main/java/com/ethran/notable/editor/utils/persistBitmap.kt rename to app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt index 76bd2d57..3b27a1c0 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/persistBitmap.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt @@ -13,6 +13,7 @@ import androidx.core.graphics.createBitmap import androidx.core.graphics.scale import com.ethran.notable.R import com.ethran.notable.data.ensurePreviewsFullFolder +import com.ethran.notable.utils.ensureNotMainThread import com.ethran.notable.utils.logCallStack import io.shipbook.shipbooksdk.ShipBook import kotlinx.coroutines.Dispatchers @@ -106,6 +107,7 @@ private fun removeOldBitmaps(dir: File, latestPreview: String, pageID: String) { fun persistBitmapFull( context: Context, bitmap: Bitmap, pageID: String, scroll: Offset?, zoom: Float? ) { + ensureNotMainThread("persistBitmapFull") if (!checkZoomAndScroll(scroll, zoom)) return val scrollYInt = scroll!!.y.roundToInt() val fileName = buildPreviewFileName(pageID, scrollYInt) @@ -336,6 +338,7 @@ private fun decodePreview(file: File, expectedNameForLog: String): Bitmap? { * Persist a thumbnail for a page. */ fun persistBitmapThumbnail(context: Context, bitmap: Bitmap, pageID: String) { + ensureNotMainThread("persistBitmapFull") val file = getThumbnailFile(context, pageID) file.parentFile?.mkdirs() val ratio = bitmap.height.toFloat() / bitmap.width.toFloat() From 2e500f543dc48366c67a75c1f889a65082ce5c50 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sun, 12 Apr 2026 23:30:13 +0200 Subject: [PATCH 06/11] Refactor page preview and thumbnail persistence logic to use the WEBP format and improve naming consistency. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Core Changes - **Format Update**: Switched from PNG/JPEG to WEBP for full-page previews and thumbnails, including version-specific support for `WEBP_LOSSY`. - **API Renaming**: Standardized persistence functions for better clarity: - `persistBitmapFull` → `savePageFull` - `loadPersistBitmap` → `loadPageFull` - `loadPreview` → `loadPagePreviewOrFallback` - `persistBitmapThumbnail` → `savePageThumbnail` ### Preview & Thumbnail Management - **Preview Storage**: Updated file naming to include `.webp` extensions and simplified the removal of legacy preview files. - **Loading Logic**: Refactored `loadPageFull` to focus on WEBP candidates and improved the fallback mechanism in `loadPagePreviewOrFallback` to better handle missing or mismatched previews. - **Thumbnail Generation**: Simplified `ThumbnailGenerator` by removing the redundant `isThumbnailStale` check and renaming `generateIfNeeded` to `generate`. ### Miscellaneous - **Logging**: Improved log messages across the rendering and persistence pipeline for better traceability. - **Bug Fix**: Fixed a typo in `isEqApprox` utility function. --- .../ethran/notable/data/PageDataManager.kt | 8 +- .../com/ethran/notable/editor/PageView.kt | 4 +- .../editor/canvas/CanvasObserverRegistry.kt | 4 +- .../editor/utils/PreviewBitmapStore.kt | 175 ++++++------------ .../ethran/notable/io/PageContentRenderer.kt | 2 + .../ethran/notable/io/ThumbnailGenerator.kt | 16 +- 6 files changed, 73 insertions(+), 136 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/data/PageDataManager.kt b/app/src/main/java/com/ethran/notable/data/PageDataManager.kt index 793bacfe..7034e48f 100644 --- a/app/src/main/java/com/ethran/notable/data/PageDataManager.kt +++ b/app/src/main/java/com/ethran/notable/data/PageDataManager.kt @@ -22,8 +22,8 @@ import com.ethran.notable.data.model.BackgroundType.AutoPdf.getPage import com.ethran.notable.data.model.BackgroundType.CoverImage import com.ethran.notable.data.model.BackgroundType.ImageRepeating import com.ethran.notable.editor.canvas.CanvasEventBus -import com.ethran.notable.editor.utils.persistBitmapFull -import com.ethran.notable.editor.utils.persistBitmapThumbnail +import com.ethran.notable.editor.utils.savePageFull +import com.ethran.notable.editor.utils.savePageThumbnail import com.ethran.notable.io.IN_IGNORED import com.ethran.notable.io.fileObserverEventNames import com.ethran.notable.io.loadBackgroundBitmap @@ -406,14 +406,14 @@ class PageDataManager @Inject constructor( } scope.launch(Dispatchers.IO) { - persistBitmapFull( + savePageFull( context, bitmap, pageId, currentScroll, currentZoomLevel ) - persistBitmapThumbnail(context, bitmap, pageId) + savePageThumbnail(context, bitmap, pageId) } } } diff --git a/app/src/main/java/com/ethran/notable/editor/PageView.kt b/app/src/main/java/com/ethran/notable/editor/PageView.kt index 8eba8d65..557a2883 100644 --- a/app/src/main/java/com/ethran/notable/editor/PageView.kt +++ b/app/src/main/java/com/ethran/notable/editor/PageView.kt @@ -33,7 +33,7 @@ import com.ethran.notable.editor.drawing.drawBg import com.ethran.notable.editor.drawing.drawOnCanvasFromPage import com.ethran.notable.editor.utils.div import com.ethran.notable.editor.utils.divideStrokesFromCut -import com.ethran.notable.editor.utils.loadPersistBitmap +import com.ethran.notable.editor.utils.loadPageFull import com.ethran.notable.editor.utils.minus import com.ethran.notable.editor.utils.plus import com.ethran.notable.editor.utils.strokeBounds @@ -432,7 +432,7 @@ class PageView( // load background, fast, if it is accurate enough. private fun loadInitialBitmap(): Boolean { - val bitmapFromDisc = loadPersistBitmap( + val bitmapFromDisc = loadPageFull( context = context, pageID = currentPageId, scroll = scroll, diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt index a4fdde91..c3b88189 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt @@ -6,7 +6,7 @@ import com.ethran.notable.editor.PageView import com.ethran.notable.editor.state.History import com.ethran.notable.editor.utils.ImageHandler import com.ethran.notable.editor.utils.cleanAllStrokes -import com.ethran.notable.editor.utils.loadPreview +import com.ethran.notable.editor.utils.loadPagePreviewOrFallback import com.ethran.notable.editor.utils.partialRefreshRegionOnce import com.ethran.notable.editor.utils.selectRectangle import com.onyx.android.sdk.extension.isNull @@ -288,7 +288,7 @@ class CanvasObserverRegistry( val pageUpdatedAtMs = pageDataManager.getPageUpdatedAt(pageId) val previewBitmap = withContext(Dispatchers.IO) { - loadPreview( + loadPagePreviewOrFallback( context = drawCanvas.context, pageIdToLoad = pageId, expectedWidth = page.viewWidth, diff --git a/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt index 3b27a1c0..9d628a6e 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt @@ -7,6 +7,7 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Rect +import android.os.Build import androidx.compose.ui.geometry.Offset import androidx.core.content.FileProvider import androidx.core.graphics.createBitmap @@ -19,14 +20,12 @@ import io.shipbook.shipbooksdk.ShipBook import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File -import java.nio.file.Files.delete import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt private val log = ShipBook.getLogger("bitmapUtils") -// Why it is needed? I try to removed it, and sharing bimap seems to work. class Provider : FileProvider(R.xml.file_paths) private const val EQUALITY_THRESHOLD = 0.01f @@ -34,23 +33,22 @@ const val THUMBNAIL_WIDTH = 500 private const val THUMBNAIL_QUALITY = 60 private const val PREVIEW_QUALITY = 90 - fun getThumbnailFile(context: Context, pageID: String): File = - File(context.filesDir, "pages/previews/thumbs/$pageID") + File(context.filesDir, "pages/previews/thumbs/$pageID.webp") -private fun isEqqApprox(a: Float, b: Float): Boolean = abs(a - b) <= EQUALITY_THRESHOLD +private fun isEqApprox(a: Float, b: Float): Boolean = abs(a - b) <= EQUALITY_THRESHOLD private fun checkZoomAndScroll(scroll: Offset?, zoom: Float?): Boolean { if (zoom == null || scroll == null) { - log.d("persistBitmapFull: skipping persist (zoom is $zoom, scroll is $scroll)") + log.d("savePagePreview: skipping persist (zoom is $zoom, scroll is $scroll)") return false } - if (!isEqqApprox(zoom, 1f)) { - log.d("persistBitmapFull: skipping persist (zoom=$zoom not ~1.0)") + if (!isEqApprox(zoom, 1f)) { + log.d("savePagePreview: skipping persist (zoom=$zoom not ~1.0)") return false } - if (!isEqqApprox(scroll.x, 0f)) { - log.d("persistBitmapFull: skipping persist (scroll.x: ${scroll.x} != 0)") + if (!isEqApprox(scroll.x, 0f)) { + log.d("savePagePreview: skipping persist (scroll.x: ${scroll.x} != 0)") return false } return true @@ -67,48 +65,38 @@ private fun isCacheFresh(file: File, pageUpdatedAtMs: Long?): Boolean { * * Format: {pageID}-sy{scrollY}.png */ -private fun buildPreviewFileName(pageID: String, scrollY: Int): String = "${pageID}-sy$scrollY.png" +private fun buildPreviewFileName(pageID: String, scrollY: Int): String = "${pageID}-sy$scrollY.webp" +val webpCompressFormat: Bitmap.CompressFormat + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + Bitmap.CompressFormat.WEBP + } /** * Remove other variants for this page (legacy + other scrollY encodings) */ private fun removeOldBitmaps(dir: File, latestPreview: String, pageID: String) { dir.listFiles()?.forEach { f -> - if (f.name != latestPreview && (f.name == pageID || f.name == "$pageID.png" || (f.name.startsWith( - "$pageID-sy" - ) && f.name.endsWith(".png"))) - ) { + if (f.name != latestPreview && f.name.startsWith(pageID)) { try { if (f.delete()) { - log.d("persistBitmapFull: removed old preview ${f.name}") - } else { - log.w("persistBitmapFull: failed to delete old preview ${f.name}") - delete(f.toPath()) + log.d("savePagePreview: removed old preview ${f.name}") } } catch (t: Throwable) { - log.e("persistBitmapFull: failed to delete old preview ${f.name}: ${t::class.simpleName} ${t.message}") + log.e("savePagePreview: failed to delete old preview ${f.name}") } } } } -/** - * Persist a full bitmap preview for a page. - * - * Rules implemented from inline spec: - * - If zoom or scroll is null -> skip (log) - * - If zoom is not ~1.0 (with epsilon) -> skip - * - If scroll.x != 0f -> skip - * - Encode scroll.y (rounded) in the file name. - * - Remove previously persisted previews for the same page (keep only one). - * TODO: If scroll differs by a small factor, update scroll to match the saved value. - */ -fun persistBitmapFull( +fun savePageFull( context: Context, bitmap: Bitmap, pageID: String, scroll: Offset?, zoom: Float? ) { - ensureNotMainThread("persistBitmapFull") + ensureNotMainThread("savePagePreview") if (!checkZoomAndScroll(scroll, zoom)) return + val scrollYInt = scroll!!.y.roundToInt() val fileName = buildPreviewFileName(pageID, scrollYInt) val dir = ensurePreviewsFullFolder(context) @@ -116,33 +104,21 @@ fun persistBitmapFull( try { file.outputStream().buffered().use { os -> - val success = bitmap.compress(Bitmap.CompressFormat.PNG, PREVIEW_QUALITY, os) + val success = bitmap.compress(webpCompressFormat, PREVIEW_QUALITY, os) if (!success) { - log.e("persistBitmapFull: Failed to compress bitmap") - logCallStack("persistBitmapFull") + log.e("savePagePreview: Failed to compress bitmap") return - } else { - log.d("persistBitmapFull: cached preview saved as $fileName (scrollY=$scrollYInt)") } + log.d("savePagePreview: cached preview saved as $fileName (scrollY=$scrollYInt)") } removeOldBitmaps(dir, fileName, pageID) - } catch (e: Exception) { - log.e("persistBitmapFull: Exception while saving preview: ${e.message}") - logCallStack("persistBitmapFull") + log.e("savePagePreview: Exception while saving preview: ${e.message}") + logCallStack("savePagePreview") } } -/** - * Load a persisted bitmap preview. - * - * Rules: - * - Only load if zoom is ~1.0 (else return null) - * - Require non-null scroll (so we can derive file name); if null -> return null - * - File name must match encoded scroll.y used during persist - * - Backward compatibility: if encoded file not found and scrollY != 0, attempt legacy filename (without suffix) - */ -fun loadPersistBitmap( +fun loadPageFull( context: Context, pageID: String, scroll: Offset?, @@ -152,63 +128,38 @@ fun loadPersistBitmap( ): Bitmap? { val dir = ensurePreviewsFullFolder(context) - // Exact match path: enforce zoom/scroll checks and precise encoded filename if (requireExactMatch) { if (!checkZoomAndScroll(scroll, zoom)) return null val scrollYInt = scroll!!.y.roundToInt() - val encodedFile = File(dir, buildPreviewFileName(pageID, scrollYInt)) - val candidateFiles = listOf( - encodedFile, File(dir, pageID), // legacy (no suffix) - File(dir, "$pageID.png") // legacy .png - ) + val expectedFileName = buildPreviewFileName(pageID, scrollYInt) + val targetFile = File(dir, expectedFileName) - val targetFile = candidateFiles.firstOrNull { it.exists() } - if (targetFile == null) { - log.i("loadPersistBitmap: no exact-match cache (expected ${encodedFile.name})") + if (!targetFile.exists()) { + log.i("loadPagePreview: no exact-match cache (expected $expectedFileName)") return null } if (!isCacheFresh(targetFile, pageUpdatedAtMs)) { - log.i("loadPersistBitmap: cache is stale for ${targetFile.name} (pageUpdatedAtMs=$pageUpdatedAtMs)") + log.i("loadPagePreview: cache is stale for ${targetFile.name}") return null } - return decodePreview(targetFile, encodedFile.name) + return readImageFile(targetFile) } - // Non-exact path: accept any zoom/scroll and pick the best matching cached file for this page - // Prefer the newest file among all files starting with pageID (including legacy and encoded variants) - val allMatches: List = - dir.listFiles { f -> f.isFile && f.name.startsWith(pageID) }?.toList().orEmpty() - - // Also include legacy fallbacks explicitly (in case listFiles filtering changes) - val legacyExtras = listOf( - File(dir, pageID), File(dir, "$pageID.png") - ) - - // If we do have a scroll, include its encoded name as a candidate too (may help if it's present) - val encodedFromProvidedScroll = scroll?.let { - File(dir, buildPreviewFileName(pageID, it.y.roundToInt())) - } - - // Merge and deduplicate by name + // Try finding the freshest file starting with pageID val candidates = - (listOfNotNull(encodedFromProvidedScroll) + legacyExtras + allMatches).distinctBy { it.name } - .filter { it.exists() && isCacheFresh(it, pageUpdatedAtMs) } + dir.listFiles { f -> f.isFile && f.name.startsWith(pageID) && f.name.endsWith(".webp") } + ?.toList()?.filter { isCacheFresh(it, pageUpdatedAtMs) }.orEmpty() if (candidates.isEmpty()) { - log.i("loadPersistBitmap: no cache file for pageID=$pageID (non-exact)") + log.i("loadPagePreview: no native cache file for pageID=$pageID") return null } - // Pick newest by lastModified val newest = candidates.maxByOrNull { it.lastModified() } ?: candidates.first() - - // For logging, try to compute the "exact" encoded filename if we had a scroll; otherwise pass the actual name - val expectedName = encodedFromProvidedScroll?.name ?: newest.name - return decodePreview(newest, expectedName) + return readImageFile(newest) } -// Load preview fast, without touching any windowed canvas. -suspend fun loadPreview( +suspend fun loadPagePreviewOrFallback( context: Context, pageIdToLoad: String, expectedWidth: Int, @@ -219,9 +170,7 @@ suspend fun loadPreview( ): Bitmap = withContext(Dispatchers.IO) { // Load from disk (full quality folder) var bitmapFromDisk: Bitmap? = try { - // We use requireExactMatch=false here for the full folder search because loadPreview usually - // doesn't have current scroll/zoom info, so we want the best available full preview. - loadPersistBitmap( + loadPageFull( context, pageIdToLoad, null, @@ -234,15 +183,14 @@ suspend fun loadPreview( null } - // Fallback to low-quality thumbnail if full preview is missing and we allow non-exact matches if (bitmapFromDisk == null && !requireExactMatch) { val thumbFile = getThumbnailFile(context, pageIdToLoad) if (thumbFile.exists()) { - bitmapFromDisk = decodePreview(thumbFile, "thumbnail-fallback") + bitmapFromDisk = readImageFile(thumbFile) } } - val prepared = when { + when { bitmapFromDisk == null -> { log.d("No persisted preview for $pageIdToLoad. Creating placeholder.") createPlaceholderPreview(expectedWidth, expectedHeight, pageNumber) @@ -254,10 +202,7 @@ suspend fun loadPreview( } else -> { - log.i( - "Preview size mismatch (${bitmapFromDisk.width}x${bitmapFromDisk.height}) -> " + "scaling to ${expectedWidth}x${expectedHeight}" - ) - + log.i("Preview size mismatch -> scaling to ${expectedWidth}x${expectedHeight}") val scaled = createBitmap( expectedWidth, expectedHeight, bitmapFromDisk.config ?: Bitmap.Config.ARGB_8888 ) @@ -267,20 +212,14 @@ suspend fun loadPreview( isFilterBitmap = true isDither = true } - val srcRect = Rect(0, 0, bitmapFromDisk.width, bitmapFromDisk.height) val destRect = Rect(0, 0, expectedWidth, expectedHeight) - canvas.drawBitmap(bitmapFromDisk, srcRect, destRect, paint) - if (scaled != bitmapFromDisk) { - bitmapFromDisk.recycle() - } + if (scaled != bitmapFromDisk) bitmapFromDisk.recycle() scaled } } - - prepared } @@ -306,18 +245,14 @@ private fun createPlaceholderPreview( return bmp } -private fun decodePreview(file: File, expectedNameForLog: String): Bitmap? { +private fun readImageFile(file: File): Bitmap? { return try { val imgBitmap = BitmapFactory.decodeFile(file.absolutePath) if (imgBitmap != null) { - if (file.name != expectedNameForLog) { - log.d("loadPersistBitmap: loaded cached preview (non-exact or legacy) '${file.name}'") - } else { - log.d("loadPersistBitmap: loaded cached preview '${file.name}'") - } + log.d("loadPagePreview: loaded cached preview '${file.name}'") imgBitmap } else { - log.w("loadPersistBitmap: failed to decode bitmap from ${file.name}") + log.w("loadPagePreview: failed to decode bitmap from ${file.name}") log.d( """ exists=${file.exists()} @@ -328,8 +263,7 @@ private fun decodePreview(file: File, expectedNameForLog: String): Bitmap? { null } } catch (e: Exception) { - log.e("loadPersistBitmap: Exception while loading bitmap: ${e.message}") - logCallStack("loadPersistBitmap") + log.e("loadPagePreview: Exception while loading bitmap: ${e.message}") null } } @@ -337,23 +271,24 @@ private fun decodePreview(file: File, expectedNameForLog: String): Bitmap? { /** * Persist a thumbnail for a page. */ -fun persistBitmapThumbnail(context: Context, bitmap: Bitmap, pageID: String) { - ensureNotMainThread("persistBitmapFull") +fun savePageThumbnail(context: Context, bitmap: Bitmap, pageID: String) { + ensureNotMainThread("savePageThumbnail") val file = getThumbnailFile(context, pageID) file.parentFile?.mkdirs() + val ratio = bitmap.height.toFloat() / bitmap.width.toFloat() val scaledBitmap = bitmap.scale(THUMBNAIL_WIDTH, (THUMBNAIL_WIDTH * ratio).toInt(), false) try { file.outputStream().buffered().use { os -> - scaledBitmap.compress(Bitmap.CompressFormat.JPEG, THUMBNAIL_QUALITY, os) + scaledBitmap.compress(webpCompressFormat, THUMBNAIL_QUALITY, os) } } catch (e: Exception) { - log.e("persistBitmapThumbnail: Exception while saving thumbnail: ${e.message}") - logCallStack("persistBitmapThumbnail") + log.e("savePageThumbnail: Exception while saving thumbnail: ${e.message}") + logCallStack("savePageThumbnail") } if (scaledBitmap != bitmap) { scaledBitmap.recycle() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt index 909bf3a8..a0aef793 100644 --- a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt +++ b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt @@ -23,6 +23,7 @@ import com.ethran.notable.editor.drawing.drawImage import com.ethran.notable.editor.drawing.drawStroke import com.ethran.notable.utils.ensureNotMainThread import dagger.hilt.android.qualifiers.ApplicationContext +import io.shipbook.shipbooksdk.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject @@ -49,6 +50,7 @@ class PageContentRenderer @Inject constructor( val (contentWidth, contentHeight) = computeContentDimensions(data) val size = resolveRenderSize(contentWidth, contentHeight, target) + Log.e("PageContentRenderer", "size: ${size.width}, ${size.height}, ${size.scale}") createBitmap(size.width, size.height).also { bitmap -> drawPage( canvas = Canvas(bitmap), diff --git a/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt b/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt index e1efd346..488edf13 100644 --- a/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt +++ b/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt @@ -6,7 +6,7 @@ import com.ethran.notable.data.db.PageRepository import com.ethran.notable.di.IoDispatcher import com.ethran.notable.editor.utils.THUMBNAIL_WIDTH import com.ethran.notable.editor.utils.getThumbnailFile -import com.ethran.notable.editor.utils.persistBitmapThumbnail +import com.ethran.notable.editor.utils.savePageThumbnail import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext @@ -82,7 +82,10 @@ class ThumbnailGenerator @Inject constructor( val marker = CompletableDeferred() val acquired = inFlightLock.withLock { - inFlight.putIfAbsent(pageId, marker) == null // it will return null if there wasn't any value + inFlight.putIfAbsent( + pageId, + marker + ) == null // it will return null if there wasn't any value } if (!acquired) { @@ -91,7 +94,7 @@ class ThumbnailGenerator @Inject constructor( } try { - val result = generateIfNeeded(page) + val result = generate(page) if (result == ThumbnailEnsureResult.GENERATED) { _thumbnailUpdated.tryEmit(pageId) } @@ -106,9 +109,7 @@ class ThumbnailGenerator @Inject constructor( } - private suspend fun generateIfNeeded(page: Page): ThumbnailEnsureResult { - if (!isThumbnailStale(page)) return ThumbnailEnsureResult.UP_TO_DATE - + private suspend fun generate(page: Page): ThumbnailEnsureResult { val bitmap = pageContentRenderer.renderPageBitmap( pageId = page.id, target = RenderTarget.Thumbnail( @@ -116,9 +117,8 @@ class ThumbnailGenerator @Inject constructor( maxHeightPx = Int.MAX_VALUE ) ) - bitmap.useAndRecycle { rendered -> - persistBitmapThumbnail(context, rendered, page.id) + savePageThumbnail(context, rendered, page.id) } log.d("Thumbnail generated for pageId=${page.id}") return ThumbnailEnsureResult.GENERATED From 76a081411f77135f1b27d113e00079d786df1c25 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sun, 12 Apr 2026 23:44:46 +0200 Subject: [PATCH 07/11] Adjust preview quality and clean up logging in PreviewBitmapStore - **PreviewBitmapStore.kt**: - Reduced `PREVIEW_QUALITY` from 90 to 85. - Suppressed the unused variable in the `savePagePreview` catch block. - Fixed string template formatting in the `loadPagePreview` failure log. --- .../notable/editor/utils/PreviewBitmapStore.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt index 9d628a6e..c8d16543 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt @@ -31,7 +31,7 @@ class Provider : FileProvider(R.xml.file_paths) private const val EQUALITY_THRESHOLD = 0.01f const val THUMBNAIL_WIDTH = 500 private const val THUMBNAIL_QUALITY = 60 -private const val PREVIEW_QUALITY = 90 +private const val PREVIEW_QUALITY = 85 fun getThumbnailFile(context: Context, pageID: String): File = File(context.filesDir, "pages/previews/thumbs/$pageID.webp") @@ -84,7 +84,7 @@ private fun removeOldBitmaps(dir: File, latestPreview: String, pageID: String) { if (f.delete()) { log.d("savePagePreview: removed old preview ${f.name}") } - } catch (t: Throwable) { + } catch (_: Throwable) { log.e("savePagePreview: failed to delete old preview ${f.name}") } } @@ -254,10 +254,10 @@ private fun readImageFile(file: File): Bitmap? { } else { log.w("loadPagePreview: failed to decode bitmap from ${file.name}") log.d( - """ - exists=${file.exists()} - size=${file.length()} - name=${'$'}{file.name} + $$""" + exists=$${file.exists()} + size=$${file.length()} + name=${file.name} """.trimIndent() ) null From 1462c4d79c407521ba42b6331bc40236340f97b8 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sat, 18 Apr 2026 13:17:27 +0200 Subject: [PATCH 08/11] rename image decoding utility. --- .../notable/editor/utils/PreviewBitmapStore.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt index c8d16543..171ccb0e 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt @@ -142,7 +142,7 @@ fun loadPageFull( log.i("loadPagePreview: cache is stale for ${targetFile.name}") return null } - return readImageFile(targetFile) + return decodeBitmapFromFile(targetFile) } // Try finding the freshest file starting with pageID @@ -156,7 +156,7 @@ fun loadPageFull( } val newest = candidates.maxByOrNull { it.lastModified() } ?: candidates.first() - return readImageFile(newest) + return decodeBitmapFromFile(newest) } suspend fun loadPagePreviewOrFallback( @@ -186,7 +186,7 @@ suspend fun loadPagePreviewOrFallback( if (bitmapFromDisk == null && !requireExactMatch) { val thumbFile = getThumbnailFile(context, pageIdToLoad) if (thumbFile.exists()) { - bitmapFromDisk = readImageFile(thumbFile) + bitmapFromDisk = decodeBitmapFromFile(thumbFile) } } @@ -245,14 +245,14 @@ private fun createPlaceholderPreview( return bmp } -private fun readImageFile(file: File): Bitmap? { +private fun decodeBitmapFromFile(file: File): Bitmap? { return try { val imgBitmap = BitmapFactory.decodeFile(file.absolutePath) if (imgBitmap != null) { - log.d("loadPagePreview: loaded cached preview '${file.name}'") + log.d("decodeBitmapFromFile: loaded cached preview '${file.name}'") imgBitmap } else { - log.w("loadPagePreview: failed to decode bitmap from ${file.name}") + log.w("decodeBitmapFromFile: failed to decode bitmap from ${file.name}") log.d( $$""" exists=$${file.exists()} @@ -263,7 +263,7 @@ private fun readImageFile(file: File): Bitmap? { null } } catch (e: Exception) { - log.e("loadPagePreview: Exception while loading bitmap: ${e.message}") + log.e("decodeBitmapFromFile: Exception while loading bitmap: ${e.message}") null } } From 1c113daf2730e34988e5b6cd4f54d0ac1b2fddbd Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sat, 18 Apr 2026 14:22:32 +0200 Subject: [PATCH 09/11] Refactor device information gathering and optimize preview/thumbnail storage for E-ink devices. ### System Information - **SystemInformationViewModel**: Added comprehensive device metadata fields (kernel, CPU serial, resolution, etc.) using `DeviceInfoUtil` and `DeviceCompat`. - **SystemInformation View**: Integrated new device information fields into the UI display and updated the mock snapshot for previews. ### Storage & Rendering Optimization - **PreviewBitmapStore**: - Introduced `optimizeBitmapForStorage` to reduce memory and disk footprint on monochrome and E-ink devices (e.g., using `RGB_565` for thumbnails or stripping saturation). - Renamed `savePageFull` to `saveHQPagePreview` and `loadPageFull` to `loadHQPagePreview` for clarity. - Updated storage logic to recycle temporary optimized bitmaps. - **PageContentRenderer**: Updated `RenderTarget.Thumbnail` to support optional dimensions, defaulting to a screen-ratio-based height if not provided. - **ThumbnailGenerator**: Updated to use the new flexible `maxHeightPx` in `RenderTarget`. ### Device Compatibility - **DeviceCompat**: Added `isColorDevice()` helper and a `delayBeforeResumingDrawing()` method to handle refresh rate differences between color and monochrome E-ink screens. - **CanvasObserverRegistry**: Removed the `requireExactMatch` parameter when loading bitmap previews to allow more flexible fallback to thumbnails. ### General Clean up - Improved code formatting for `AppEvent.ActionHint` calls across `PageDataManager`. - Simplified bitmap loading logic in `PageView` to use the renamed high-quality preview methods. --- .../ethran/notable/data/PageDataManager.kt | 25 ++- .../com/ethran/notable/editor/PageView.kt | 4 +- .../editor/canvas/CanvasObserverRegistry.kt | 1 - .../editor/utils/PreviewBitmapStore.kt | 142 ++++++++++++++---- .../ethran/notable/editor/utils/einkHelper.kt | 19 +++ .../ethran/notable/io/PageContentRenderer.kt | 51 +++++-- .../notable/io/ThumbnailBackfillQueue.kt | 18 ++- .../ethran/notable/io/ThumbnailGenerator.kt | 13 +- .../viewmodels/SystemInformationViewModel.kt | 32 ++++ .../notable/ui/views/SystemInformation.kt | 27 +++- 10 files changed, 266 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/data/PageDataManager.kt b/app/src/main/java/com/ethran/notable/data/PageDataManager.kt index 7034e48f..d16eecb9 100644 --- a/app/src/main/java/com/ethran/notable/data/PageDataManager.kt +++ b/app/src/main/java/com/ethran/notable/data/PageDataManager.kt @@ -22,7 +22,7 @@ import com.ethran.notable.data.model.BackgroundType.AutoPdf.getPage import com.ethran.notable.data.model.BackgroundType.CoverImage import com.ethran.notable.data.model.BackgroundType.ImageRepeating import com.ethran.notable.editor.canvas.CanvasEventBus -import com.ethran.notable.editor.utils.savePageFull +import com.ethran.notable.editor.utils.saveHQPagePreview import com.ethran.notable.editor.utils.savePageThumbnail import com.ethran.notable.io.IN_IGNORED import com.ethran.notable.io.fileObserverEventNames @@ -245,7 +245,12 @@ class PageDataManager @Inject constructor( } catch (e: Exception) { // All other unexpected exceptions log.e("Error caching neighbor pages", e) - appEventBus.tryEmit(AppEvent.ActionHint("Error encountered while caching neighbors", 5000)) + appEventBus.tryEmit( + AppEvent.ActionHint( + "Error encountered while caching neighbors", + 5000 + ) + ) } @@ -406,7 +411,7 @@ class PageDataManager @Inject constructor( } scope.launch(Dispatchers.IO) { - savePageFull( + saveHQPagePreview( context, bitmap, pageId, @@ -746,7 +751,12 @@ class PageDataManager @Inject constructor( } if (!waitForFileAvailable(filePath)) { log.w("File changed, but does not exist: $filePath") - appEventBus.tryEmit(AppEvent.ActionHint("Background does not exist", 3000)) + appEventBus.tryEmit( + AppEvent.ActionHint( + "Background does not exist", + 3000 + ) + ) return@launch } else observeBackgroundFile(pageId, filePath) @@ -780,7 +790,12 @@ class PageDataManager @Inject constructor( invalidateBackground(pid) if (pid == currentPage) { CanvasEventBus.forceUpdate.emit(null) - appEventBus.tryEmit(AppEvent.ActionHint("Background file changed", 4000)) + appEventBus.tryEmit( + AppEvent.ActionHint( + "Background file changed", + 4000 + ) + ) } } } diff --git a/app/src/main/java/com/ethran/notable/editor/PageView.kt b/app/src/main/java/com/ethran/notable/editor/PageView.kt index 557a2883..59ea86bf 100644 --- a/app/src/main/java/com/ethran/notable/editor/PageView.kt +++ b/app/src/main/java/com/ethran/notable/editor/PageView.kt @@ -33,7 +33,7 @@ import com.ethran.notable.editor.drawing.drawBg import com.ethran.notable.editor.drawing.drawOnCanvasFromPage import com.ethran.notable.editor.utils.div import com.ethran.notable.editor.utils.divideStrokesFromCut -import com.ethran.notable.editor.utils.loadPageFull +import com.ethran.notable.editor.utils.loadHQPagePreview import com.ethran.notable.editor.utils.minus import com.ethran.notable.editor.utils.plus import com.ethran.notable.editor.utils.strokeBounds @@ -432,7 +432,7 @@ class PageView( // load background, fast, if it is accurate enough. private fun loadInitialBitmap(): Boolean { - val bitmapFromDisc = loadPageFull( + val bitmapFromDisc = loadHQPagePreview( context = context, pageID = currentPageId, scroll = scroll, diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt index c3b88189..84154c87 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt @@ -294,7 +294,6 @@ class CanvasObserverRegistry( expectedWidth = page.viewWidth, expectedHeight = page.viewHeight, pageNumber = pageNumber, - requireExactMatch = false, pageUpdatedAtMs = pageUpdatedAtMs ) } diff --git a/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt index 171ccb0e..98f786e9 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt @@ -5,6 +5,8 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.Color +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter import android.graphics.Paint import android.graphics.Rect import android.os.Build @@ -33,6 +35,55 @@ const val THUMBNAIL_WIDTH = 500 private const val THUMBNAIL_QUALITY = 60 private const val PREVIEW_QUALITY = 85 +enum class PreviewSaveMode { + STRICT_BW, // Threshold to black & white, lossless max compression (WebP Lossless effort 100) + REGULAR // Grayscale or Color depending on device +} + +private data class StorageOptimization( + val bitmap: Bitmap, + val format: Bitmap.CompressFormat, + val quality: Int +) + +private fun optimizeBitmapForStorage( + bitmap: Bitmap, + mode: PreviewSaveMode, + isThumbnail: Boolean +): StorageOptimization { + if (mode == PreviewSaveMode.STRICT_BW) { + // Apply threshold for absolute B&W. WebP Lossless scale factor (effort) is passed via quality: 100 is max effort. + return StorageOptimization(bitmap.toThresholded(), webpLosslessFormat, 100) + } + + // REGULAR mode + val isColor = DeviceCompat.isColorDevice() + val isOnyx = DeviceCompat.isOnyxDevice + + if (!isColor || isOnyx) { + val config = Bitmap.Config.RGB_565 + val optimized = createBitmap(bitmap.width, bitmap.height, config) + val canvas = Canvas(optimized) + val paint = Paint().apply { + if (!isColor) { + colorFilter = ColorMatrixColorFilter(ColorMatrix().apply { setSaturation(0f) }) + } + } + canvas.drawBitmap(bitmap, 0f, 0f, paint) + + // WebP Lossless generates excellent small files for UI/grayscale handwriting. + // When choosing lossless WEBP, the compression effort uses exactly `100` to yield smallest file. + val format = if (!isThumbnail) webpLosslessFormat else webpLossyFormat + val quality = if (!isThumbnail) 100 else THUMBNAIL_QUALITY + + return StorageOptimization(optimized, format, quality) + } + + // Standard color saves + val quality = if (isThumbnail) THUMBNAIL_QUALITY else PREVIEW_QUALITY + return StorageOptimization(bitmap, webpLossyFormat, quality) +} + fun getThumbnailFile(context: Context, pageID: String): File = File(context.filesDir, "pages/previews/thumbs/$pageID.webp") @@ -67,12 +118,11 @@ private fun isCacheFresh(file: File, pageUpdatedAtMs: Long?): Boolean { */ private fun buildPreviewFileName(pageID: String, scrollY: Int): String = "${pageID}-sy$scrollY.webp" -val webpCompressFormat: Bitmap.CompressFormat - get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Bitmap.CompressFormat.WEBP_LOSSY - } else { - Bitmap.CompressFormat.WEBP - } +val webpLossyFormat get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + Bitmap.CompressFormat.WEBP_LOSSY else Bitmap.CompressFormat.WEBP + +val webpLosslessFormat get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP /** * Remove other variants for this page (legacy + other scrollY encodings) @@ -82,19 +132,19 @@ private fun removeOldBitmaps(dir: File, latestPreview: String, pageID: String) { if (f.name != latestPreview && f.name.startsWith(pageID)) { try { if (f.delete()) { - log.d("savePagePreview: removed old preview ${f.name}") + log.d("saveHQPagePreview: removed old preview ${f.name}") } } catch (_: Throwable) { - log.e("savePagePreview: failed to delete old preview ${f.name}") + log.e("saveHQPagePreview: failed to delete old preview ${f.name}") } } } } -fun savePageFull( - context: Context, bitmap: Bitmap, pageID: String, scroll: Offset?, zoom: Float? +fun saveHQPagePreview( + context: Context, bitmap: Bitmap, pageID: String, scroll: Offset?, zoom: Float?, mode: PreviewSaveMode = PreviewSaveMode.REGULAR ) { - ensureNotMainThread("savePagePreview") + ensureNotMainThread("saveHQPagePreview") if (!checkZoomAndScroll(scroll, zoom)) return val scrollYInt = scroll!!.y.roundToInt() @@ -102,23 +152,29 @@ fun savePageFull( val dir = ensurePreviewsFullFolder(context) val file = File(dir, fileName) + val optimized = optimizeBitmapForStorage(bitmap, mode, isThumbnail = false) + try { file.outputStream().buffered().use { os -> - val success = bitmap.compress(webpCompressFormat, PREVIEW_QUALITY, os) + val success = optimized.bitmap.compress(optimized.format, optimized.quality, os) if (!success) { - log.e("savePagePreview: Failed to compress bitmap") - return + log.e("saveHQPagePreview: Failed to compress bitmap") + return@use } - log.d("savePagePreview: cached preview saved as $fileName (scrollY=$scrollYInt)") + log.d("saveHQPagePreview: cached preview saved as $fileName (scrollY=$scrollYInt)") } removeOldBitmaps(dir, fileName, pageID) } catch (e: Exception) { - log.e("savePagePreview: Exception while saving preview: ${e.message}") - logCallStack("savePagePreview") + log.e("saveHQPagePreview: Exception while saving preview: ${e.message}") + logCallStack("saveHQPagePreview") + } finally { + if (optimized.bitmap != bitmap) { + optimized.bitmap.recycle() + } } } -fun loadPageFull( +fun loadHQPagePreview( context: Context, pageID: String, scroll: Offset?, @@ -135,11 +191,11 @@ fun loadPageFull( val targetFile = File(dir, expectedFileName) if (!targetFile.exists()) { - log.i("loadPagePreview: no exact-match cache (expected $expectedFileName)") + log.i("loadHQPagePreview: no exact-match cache (expected $expectedFileName)") return null } if (!isCacheFresh(targetFile, pageUpdatedAtMs)) { - log.i("loadPagePreview: cache is stale for ${targetFile.name}") + log.i("loadHQPagePreview: cache is stale for ${targetFile.name}") return null } return decodeBitmapFromFile(targetFile) @@ -151,7 +207,7 @@ fun loadPageFull( ?.toList()?.filter { isCacheFresh(it, pageUpdatedAtMs) }.orEmpty() if (candidates.isEmpty()) { - log.i("loadPagePreview: no native cache file for pageID=$pageID") + log.i("loadHQPagePreview: no native cache file for pageID=$pageID") return null } @@ -165,12 +221,11 @@ suspend fun loadPagePreviewOrFallback( expectedWidth: Int, expectedHeight: Int, pageNumber: Int?, - pageUpdatedAtMs: Long?, - requireExactMatch: Boolean = true, + pageUpdatedAtMs: Long? ): Bitmap = withContext(Dispatchers.IO) { - // Load from disk (full quality folder) + // Load from disk (full quality folder) ignoring requireExactMatch initially to find any full image var bitmapFromDisk: Bitmap? = try { - loadPageFull( + loadHQPagePreview( context, pageIdToLoad, null, @@ -183,7 +238,7 @@ suspend fun loadPagePreviewOrFallback( null } - if (bitmapFromDisk == null && !requireExactMatch) { + if (bitmapFromDisk == null) { val thumbFile = getThumbnailFile(context, pageIdToLoad) if (thumbFile.exists()) { bitmapFromDisk = decodeBitmapFromFile(thumbFile) @@ -271,24 +326,55 @@ private fun decodeBitmapFromFile(file: File): Bitmap? { /** * Persist a thumbnail for a page. */ -fun savePageThumbnail(context: Context, bitmap: Bitmap, pageID: String) { +fun savePageThumbnail( + context: Context, bitmap: Bitmap, pageID: String, mode: PreviewSaveMode = PreviewSaveMode.REGULAR +) { ensureNotMainThread("savePageThumbnail") val file = getThumbnailFile(context, pageID) file.parentFile?.mkdirs() val ratio = bitmap.height.toFloat() / bitmap.width.toFloat() val scaledBitmap = bitmap.scale(THUMBNAIL_WIDTH, (THUMBNAIL_WIDTH * ratio).toInt(), false) + val optimized = optimizeBitmapForStorage(scaledBitmap, mode, isThumbnail = true) try { file.outputStream().buffered().use { os -> - scaledBitmap.compress(webpCompressFormat, THUMBNAIL_QUALITY, os) + optimized.bitmap.compress(optimized.format, optimized.quality, os) } } catch (e: Exception) { log.e("savePageThumbnail: Exception while saving thumbnail: ${e.message}") logCallStack("savePageThumbnail") } + if (optimized.bitmap != scaledBitmap) { + optimized.bitmap.recycle() + } if (scaledBitmap != bitmap) { scaledBitmap.recycle() } +} + + +fun Bitmap.toThresholded(threshold: Int = 180): Bitmap { + val result = createBitmap(width, height, Bitmap.Config.RGB_565) + val canvas = Canvas(result) + val paint = Paint().apply { + colorFilter = ColorMatrixColorFilter(ColorMatrix(floatArrayOf( + // R output = 0.299R + 0.587G + 0.114B (luminance) + 0.299f, 0.587f, 0.114f, 0f, 0f, + 0.299f, 0.587f, 0.114f, 0f, 0f, + 0.299f, 0.587f, 0.114f, 0f, 0f, + 0f, 0f, 0f, 1f, 0f + ))) + } + canvas.drawBitmap(this, 0f, 0f, paint) + // now threshold: push every pixel to pure black or white + val pixels = IntArray(width * height) + result.getPixels(pixels, 0, width, 0, 0, width, height) + for (i in pixels.indices) { + val lum = Color.red(pixels[i]) // R=G=B after desaturate + pixels[i] = if (lum < threshold) Color.BLACK else Color.WHITE + } + result.setPixels(pixels, 0, width, 0, 0, width, height) + return result } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt index c5cb66ad..1a12dc98 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt @@ -1,5 +1,6 @@ package com.ethran.notable.editor.utils +import android.content.Context import android.graphics.Rect import android.os.Build import android.view.View @@ -26,6 +27,7 @@ import com.onyx.android.sdk.api.device.epd.UpdateMode import com.onyx.android.sdk.api.device.epd.UpdateOption import com.onyx.android.sdk.device.Device import com.onyx.android.sdk.pen.TouchHelper +import com.onyx.android.sdk.utils.DeviceInfoUtil import io.shipbook.shipbooksdk.ShipBook import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -438,4 +440,21 @@ object DeviceCompat { false } } + + fun isColorDevice(): Boolean { + if (!isOnyxDevice) return false + return try { + // Uses the method found in your actual SDK jar + DeviceInfoUtil.isColorDevice() + } catch (e: Exception) { + log.e("Failed to check if device is color: ${e.message}") + false + } + } + suspend fun delayBeforeResumingDrawing() { + // 500ms for Kaleido Color e-ink, 300ms for monochrome + val delayMs = if (DeviceCompat.isColorDevice()) 500L else 300L + log.d("Delaying raw drawing resume for ${delayMs}ms to allow Android UI to settle") + delay(delayMs) + } } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt index a0aef793..0bea701d 100644 --- a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt +++ b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt @@ -21,6 +21,7 @@ import com.ethran.notable.data.model.BackgroundType.Native import com.ethran.notable.editor.drawing.drawBg import com.ethran.notable.editor.drawing.drawImage import com.ethran.notable.editor.drawing.drawStroke +import com.ethran.notable.editor.utils.PreviewSaveMode import com.ethran.notable.utils.ensureNotMainThread import dagger.hilt.android.qualifiers.ApplicationContext import io.shipbook.shipbooksdk.Log @@ -32,7 +33,7 @@ import kotlin.math.min sealed class RenderTarget { object Full : RenderTarget() - data class Thumbnail(val maxWidthPx: Int, val maxHeightPx: Int) : RenderTarget() + data class Thumbnail(val maxWidthPx: Int? = null, val maxHeightPx: Int? = null) : RenderTarget() } @Singleton @@ -152,25 +153,47 @@ class PageContentRenderer @Inject constructor( private fun resolveRenderSize( contentWidth: Int, contentHeight: Int, - target: RenderTarget + target: RenderTarget, ): RenderSize { return when (target) { RenderTarget.Full -> RenderSize(contentWidth, contentHeight, 1f) is RenderTarget.Thumbnail -> { - val boundedWidth = target.maxWidthPx.coerceAtLeast(1) - val boundedHeight = target.maxHeightPx.coerceAtLeast(1) - - val scale = min( - 1f, - min( - boundedWidth.toFloat() / contentWidth.toFloat(), - boundedHeight.toFloat() / contentHeight.toFloat() + val screenRatio = SCREEN_HEIGHT.toFloat() / SCREEN_WIDTH.toFloat() + + val width: Int + val height: Int + val scale: Float + + if (target.maxWidthPx != null && target.maxHeightPx == null) { + val w = target.maxWidthPx.coerceAtLeast(1) + width = w + height = (w * screenRatio).toInt() + scale = w.toFloat() / contentWidth.toFloat() + } else if (target.maxHeightPx != null && target.maxWidthPx == null) { + val h = target.maxHeightPx.coerceAtLeast(1) + height = h + width = (h / screenRatio).toInt() + scale = h.toFloat() / contentHeight.toFloat() + } else if (target.maxWidthPx != null && target.maxHeightPx != null) { + val boundedWidth = target.maxWidthPx.coerceAtLeast(1) + val boundedHeight = target.maxHeightPx.coerceAtLeast(1) + scale = min( + 1f, + min( + boundedWidth.toFloat() / contentWidth.toFloat(), + boundedHeight.toFloat() / contentHeight.toFloat() + ) ) - ) + width = (contentWidth * scale).toInt() + height = (contentHeight * scale).toInt() + } else { + val w = com.ethran.notable.editor.utils.THUMBNAIL_WIDTH.coerceAtLeast(1) + width = w + height = (w * screenRatio).toInt() + scale = w.toFloat() / contentWidth.toFloat() + } - val width = (contentWidth * scale).toInt().coerceAtLeast(1) - val height = (contentHeight * scale).toInt().coerceAtLeast(1) - RenderSize(width, height, scale) + RenderSize(width.coerceAtLeast(1), height.coerceAtLeast(1), scale) } } } diff --git a/app/src/main/java/com/ethran/notable/io/ThumbnailBackfillQueue.kt b/app/src/main/java/com/ethran/notable/io/ThumbnailBackfillQueue.kt index 4383c4d0..32c300b9 100644 --- a/app/src/main/java/com/ethran/notable/io/ThumbnailBackfillQueue.kt +++ b/app/src/main/java/com/ethran/notable/io/ThumbnailBackfillQueue.kt @@ -1,9 +1,10 @@ package com.ethran.notable.io -import com.ethran.notable.di.ApplicationScope -import com.ethran.notable.di.IoDispatcher import com.ethran.notable.data.events.AppEvent import com.ethran.notable.data.events.AppEventBus +import com.ethran.notable.di.ApplicationScope +import com.ethran.notable.di.IoDispatcher +import com.ethran.notable.editor.utils.PreviewSaveMode import io.shipbook.shipbooksdk.ShipBook import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -44,7 +45,7 @@ class ThumbnailBackfillQueue @Inject constructor( // listen for thumbnail generation requests applicationScope.launch(ioDispatcher) { for (pageId in queue) { - processOne(pageId) + processOne(pageId, PreviewSaveMode.REGULAR) } } } @@ -89,9 +90,9 @@ class ThumbnailBackfillQueue @Inject constructor( } } - private suspend fun processOne(pageId: String) { + private suspend fun processOne(pageId: String, mode: PreviewSaveMode) { try { - thumbnailGenerator.ensureThumbnail(pageId) + thumbnailGenerator.ensureThumbnail(pageId, mode) } catch (t: Throwable) { log.e("Thumbnail generation failed for pageId=$pageId: ${t.message}") } finally { @@ -113,7 +114,12 @@ class ThumbnailBackfillQueue @Inject constructor( if (throttled && now - lastUpdateMs < 300) return lastUpdateMs = now - appEventBus.tryEmit(AppEvent.PreviewBackfillProgress(current = cycleDone, total = cycleTotal)) + appEventBus.tryEmit( + AppEvent.PreviewBackfillProgress( + current = cycleDone, + total = cycleTotal + ) + ) } private fun finalizeCycleLocked() { diff --git a/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt b/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt index 488edf13..b29e5590 100644 --- a/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt +++ b/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt @@ -4,6 +4,7 @@ import android.content.Context import com.ethran.notable.data.db.Page import com.ethran.notable.data.db.PageRepository import com.ethran.notable.di.IoDispatcher +import com.ethran.notable.editor.utils.PreviewSaveMode import com.ethran.notable.editor.utils.THUMBNAIL_WIDTH import com.ethran.notable.editor.utils.getThumbnailFile import com.ethran.notable.editor.utils.savePageThumbnail @@ -67,7 +68,7 @@ class ThumbnailGenerator @Inject constructor( * Checks if a thumbnail is up to date and generates it if necessary. * Returns immediately if a generation for the same [pageId] is already in progress. */ - suspend fun ensureThumbnail(pageId: String): ThumbnailEnsureResult { + suspend fun ensureThumbnail(pageId: String, mode: PreviewSaveMode): ThumbnailEnsureResult { val page = withContext(ioDispatcher) { pageRepository.getById(pageId) } ?: return ThumbnailEnsureResult.PAGE_NOT_FOUND @@ -94,7 +95,7 @@ class ThumbnailGenerator @Inject constructor( } try { - val result = generate(page) + val result = generate(page, mode) if (result == ThumbnailEnsureResult.GENERATED) { _thumbnailUpdated.tryEmit(pageId) } @@ -109,16 +110,18 @@ class ThumbnailGenerator @Inject constructor( } - private suspend fun generate(page: Page): ThumbnailEnsureResult { + private suspend fun generate( + page: Page, mode: PreviewSaveMode + ): ThumbnailEnsureResult { val bitmap = pageContentRenderer.renderPageBitmap( pageId = page.id, target = RenderTarget.Thumbnail( maxWidthPx = THUMBNAIL_WIDTH, - maxHeightPx = Int.MAX_VALUE + maxHeightPx = null ) ) bitmap.useAndRecycle { rendered -> - savePageThumbnail(context, rendered, page.id) + savePageThumbnail(context, rendered, page.id, mode) } log.d("Thumbnail generated for pageId=${page.id}") return ThumbnailEnsureResult.GENERATED diff --git a/app/src/main/java/com/ethran/notable/ui/viewmodels/SystemInformationViewModel.kt b/app/src/main/java/com/ethran/notable/ui/viewmodels/SystemInformationViewModel.kt index 18a53b06..c3c5f48d 100644 --- a/app/src/main/java/com/ethran/notable/ui/viewmodels/SystemInformationViewModel.kt +++ b/app/src/main/java/com/ethran/notable/ui/viewmodels/SystemInformationViewModel.kt @@ -10,6 +10,8 @@ import com.onyx.android.sdk.api.device.epd.UpdateOption import com.onyx.android.sdk.device.BaseDevice import com.onyx.android.sdk.device.Device import com.onyx.android.sdk.pen.style.StrokeStyle +import com.onyx.android.sdk.utils.DeviceInfoUtil +import com.ethran.notable.editor.utils.DeviceCompat import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -25,6 +27,17 @@ import javax.inject.Inject * All fields are nullable-safe for display; failures are recorded in [errors]. */ data class DeviceSnapshot( + // DeviceInfoUtil fields + val deviceInfoStr: String? = null, + val kernelInfo: String? = null, + val emtpInfo: String? = null, + val vcomInfo: String? = null, + val cpuSerial: String? = null, + val resolutionX: Int? = null, + val resolutionY: Int? = null, + val isOnyxDevice: Boolean? = null, + val isColorDevice: Boolean? = null, + //classes val actualDeviceClass: String? = null, val deviceClassHierarchy: String? = null, @@ -293,7 +306,26 @@ class SystemInformationViewModel @Inject constructor() : ViewModel() { val fontHotReload = safeNullable("supportFontHotReload") { base.supportFontHotReload() } val fontMap = safeNullable("loadSystemFamilyPathMap") { base.loadSystemFamilyPathMap() } + // Device Info Util + val deviceInfoStr = safeNullable("deviceInfo") { DeviceInfoUtil.deviceInfo() } + val kernelInfo = safeNullable("getDeviceKernelInfo") { DeviceInfoUtil.getDeviceKernelInfo() } + val emtpInfo = safeNullable("getEMTPInfo") { DeviceInfoUtil.getEMTPInfo() } + val vcomInfo = safeNullable("getVComInfo") { DeviceInfoUtil.getVComInfo(context) } + val cpuSerial = safeNullable("loadCPUSerial") { DeviceInfoUtil.loadCPUSerial() } + val resolution = safeNullable("getScreenResolution") { DeviceInfoUtil.getScreenResolution(context) } + val isOnyx = safeNullable("DeviceCompat.isOnyxDevice") { DeviceCompat.isOnyxDevice } + val isColor = safeNullable("DeviceCompat.isColorDevice") { DeviceCompat.isColorDevice() } + return DeviceSnapshot( + deviceInfoStr = deviceInfoStr, + kernelInfo = kernelInfo, + emtpInfo = emtpInfo, + vcomInfo = vcomInfo, + cpuSerial = cpuSerial, + resolutionX = resolution?.x, + resolutionY = resolution?.y, + isOnyxDevice = isOnyx, + isColorDevice = isColor, actualDeviceClass = actualDeviceClass, deviceClassHierarchy = deviceClassHierarchy, epdMode = epdMode, diff --git a/app/src/main/java/com/ethran/notable/ui/views/SystemInformation.kt b/app/src/main/java/com/ethran/notable/ui/views/SystemInformation.kt index 6abba939..a1ecfe66 100644 --- a/app/src/main/java/com/ethran/notable/ui/views/SystemInformation.kt +++ b/app/src/main/java/com/ethran/notable/ui/views/SystemInformation.kt @@ -112,6 +112,19 @@ fun SystemInformationContent( val strokeInfo = uiState.strokeInfo Column(Modifier.verticalScroll(rememberScrollState())) { + // 0) DeviceInfoUtil + SectionTitle("Device Info Util") + InfoRow("Device Info", info.deviceInfoStr, maxLines = 10) + InfoRow("Kernel Info", info.kernelInfo, maxLines = 10) + InfoRow("EMTP Info", info.emtpInfo) + InfoRow("VCom Info", info.vcomInfo) + InfoRow("Is Onyx Device", info.isOnyxDevice.toYesNo()) + InfoRow("Is Color Device", info.isColorDevice.toYesNo()) + InfoRow("CPU Serial", info.cpuSerial) + InfoRow("Resolution", "${info.resolutionX ?: "?"} x ${info.resolutionY ?: "?"}") + + DividerMono() + // 1) Basic system info SectionTitle("Basic System Info") InfoRow("Manufacturer", Build.MANUFACTURER) @@ -366,6 +379,15 @@ private fun InfoRow(label: String, value: String?, maxLines: Int = 3) { @Composable fun SystemInformationPreview() { val mockSnapshot = DeviceSnapshot( + deviceInfoStr = "Mock OS Version", + kernelInfo = "Mock Kernel", + emtpInfo = "Mock EMTP", + vcomInfo = "1.5 V", + cpuSerial = "123456", + resolutionX = 1404, + resolutionY = 1872, + isOnyxDevice = true, + isColorDevice = false, // Screen epdMode = null, @@ -474,8 +496,3 @@ private fun Boolean?.toYesNo(): String? = when (this) { true -> "Yes" false -> "No" } - - - - - From 0508ff22a46af6f0e9f1da48a2860fefbb9fe644 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sat, 18 Apr 2026 15:12:14 +0200 Subject: [PATCH 10/11] Refactor `ImportEngine` to return result objects and integrate automatic thumbnail generation. ### Core Logic - **ImportEngine**: - Updated `import`, `handleImportXopp`, and `handleImportPDF` to return `AppResult, DomainError>` instead of raw strings or throwing exceptions. - Improved error handling by accumulating persistent errors during the import process. - Now returns a list of successfully imported page IDs. - **ThumbnailBackfillQueue**: - Updated the processing queue to accept a `Pair`, allowing specific rendering modes for backfilled thumbnails. - Modified `enqueue` to support an optional `PreviewSaveMode` parameter (defaults to `REGULAR`). ### UI & Integration - **LibraryViewModel**: - Updated PDF and XOPP import flows to handle `AppResult` via `fold`. - Integrated `ThumbnailBackfillQueue` to automatically trigger high-contrast (STRICT_BW) thumbnail generation for newly imported PDF pages. - Standardized snackbar error messaging using `DomainError.userMessage`. --- .../com/ethran/notable/io/ImportEngine.kt | 50 ++++++++++--------- .../notable/io/ThumbnailBackfillQueue.kt | 10 ++-- .../notable/ui/viewmodels/LibraryViewModel.kt | 28 +++++++++-- 3 files changed, 56 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/io/ImportEngine.kt b/app/src/main/java/com/ethran/notable/io/ImportEngine.kt index 35bdf307..c9c7aeb8 100644 --- a/app/src/main/java/com/ethran/notable/io/ImportEngine.kt +++ b/app/src/main/java/com/ethran/notable/io/ImportEngine.kt @@ -12,6 +12,9 @@ import com.ethran.notable.data.db.StrokeRepository import com.ethran.notable.data.events.AppEvent import com.ethran.notable.data.events.AppEventBus import com.ethran.notable.data.model.BackgroundType +import com.ethran.notable.utils.AppResult +import com.ethran.notable.utils.DomainError +import com.ethran.notable.utils.plus import dagger.hilt.android.qualifiers.ApplicationContext import io.shipbook.shipbooksdk.ShipBook import javax.inject.Inject @@ -85,10 +88,10 @@ class ImportEngine @Inject constructor( suspend fun import( uri: Uri, options: ImportOptions = ImportOptions() - ): String { + ): AppResult, DomainError> { val mimeType = context.contentResolver.getType(uri) if (options.fileType != null && mimeType != options.fileType) - throw IllegalArgumentException("File type mismatch. Expected: ${options.fileType}, Actual: $mimeType") + return AppResult.Error(DomainError.UnexpectedState("File type mismatch. Expected: ${options.fileType}, Actual: $mimeType")) val bookTitle = sanitizeNotebookName(options.bookTitle ?: getFileName(uri)) log.d("Starting import for uri: $uri, mimeType: $mimeType, fileName: $bookTitle") @@ -96,24 +99,22 @@ class ImportEngine @Inject constructor( if (options.saveToBookId != null) TODO("Implement logic to save into an existing book (ID: ${options.saveToBookId})") - val optionsWithTitle = options.copy( bookTitle = bookTitle, ) - return when { XoppFile.isXoppFile(mimeType, bookTitle) -> handleImportXopp(uri, optionsWithTitle) isPdfFile(mimeType, bookTitle) -> handleImportPDF(uri, optionsWithTitle) else -> { val errorMessage = "Unsupported file type: $mimeType" log.w(errorMessage) - errorMessage + AppResult.Error(DomainError.UnexpectedState(errorMessage)) } } } - private suspend fun handleImportXopp(uri: Uri, options: ImportOptions): String { + private suspend fun handleImportXopp(uri: Uri, options: ImportOptions): AppResult, DomainError> { log.d("Importing Xopp file...") require(options.bookTitle != null) { "bookTitle cannot be null when importing Xopp file" } val book = Notebook( @@ -124,7 +125,8 @@ class ImportEngine @Inject constructor( ) bookRepo.createEmpty(book) - + val importedPageIds = mutableListOf() + var persistentError: DomainError? = null xoppFile.importBook(uri) { pageData -> try { @@ -133,24 +135,24 @@ class ImportEngine @Inject constructor( strokeRepo.create(pageData.strokes) imageRepo.create(pageData.images) bookRepo.addPage(book.id, pageData.page.id) + importedPageIds.add(pageData.page.id) } catch (e: Exception) { - appEventBus.emit( - AppEvent.LogMessage( - "importBook", "failed import book ${e.message}" - ) - ) + val errMessage = "failed import book ${e.message}" + appEventBus.emit(AppEvent.LogMessage("importBook", errMessage)) + val error = DomainError.DatabaseError(errMessage) + persistentError = persistentError?.let { it + error } ?: error } - } - return "Imported Xopp file" + + return persistentError?.let { AppResult.Error(it) } ?: AppResult.Success(importedPageIds) } - private suspend fun handleImportPDF(uri: Uri, options: ImportOptions): String { + private suspend fun handleImportPDF(uri: Uri, options: ImportOptions): AppResult, DomainError> { log.d("Importing Pdf file...") require(options.bookTitle != null) { "bookTitle cannot be null when importing Pdf file" } val fileToSave = handleFileSaving(context, uri, options) - ?: return "Couldn't determine file path. Does the app have permission to read external storage?" + ?: return AppResult.Error(DomainError.UnexpectedState("Couldn't determine file path. Does the app have permission to read external storage?")) val filePath = fileToSave.toString() @@ -162,6 +164,8 @@ class ImportEngine @Inject constructor( ) bookRepo.createEmpty(book) + val importedPageIds = mutableListOf() + var persistentError: DomainError? = null importPdf(fileToSave, options) { pageData -> try { @@ -171,16 +175,16 @@ class ImportEngine @Inject constructor( if (pageData.images.isNotEmpty()) imageRepo.create(pageData.images) bookRepo.addPage(book.id, pageData.page.id) + importedPageIds.add(pageData.page.id) } catch (e: Exception) { - appEventBus.emit( - AppEvent.LogMessage( - "importBook", "failed import book ${e.message}" - ) - ) + val errMessage = "failed import book ${e.message}" + appEventBus.emit(AppEvent.LogMessage("importBook", errMessage)) + val error = DomainError.DatabaseError(errMessage) + persistentError = persistentError?.let { it + error } ?: error } - } - return "Imported Pdf file" + + return persistentError?.let { AppResult.Error(it) } ?: AppResult.Success(importedPageIds) } diff --git a/app/src/main/java/com/ethran/notable/io/ThumbnailBackfillQueue.kt b/app/src/main/java/com/ethran/notable/io/ThumbnailBackfillQueue.kt index 32c300b9..a51031e2 100644 --- a/app/src/main/java/com/ethran/notable/io/ThumbnailBackfillQueue.kt +++ b/app/src/main/java/com/ethran/notable/io/ThumbnailBackfillQueue.kt @@ -30,7 +30,7 @@ class ThumbnailBackfillQueue @Inject constructor( private val appEventBus: AppEventBus ) { private val log = ShipBook.getLogger("ThumbnailBackfillQueue") - private val queue = Channel(Channel.UNLIMITED) + private val queue = Channel>(Channel.UNLIMITED) private val mutex = Mutex() private val queuedPageIds = linkedSetOf() @@ -44,8 +44,8 @@ class ThumbnailBackfillQueue @Inject constructor( init { // listen for thumbnail generation requests applicationScope.launch(ioDispatcher) { - for (pageId in queue) { - processOne(pageId, PreviewSaveMode.REGULAR) + for ((pageId, mode) in queue) { + processOne(pageId, mode) } } } @@ -53,7 +53,7 @@ class ThumbnailBackfillQueue @Inject constructor( /** * Enqueues a list of [pageIds] for thumbnail generation. */ - fun enqueue(pageIds: List) { + fun enqueue(pageIds: List, mode: PreviewSaveMode = PreviewSaveMode.REGULAR) { if (pageIds.isEmpty()) return applicationScope.launch(ioDispatcher) { @@ -79,7 +79,7 @@ class ThumbnailBackfillQueue @Inject constructor( } added.forEach { pageId -> - val sent = queue.trySend(pageId) + val sent = queue.trySend(pageId to mode) if (sent.isFailure) { mutex.withLock { queuedPageIds.remove(pageId) diff --git a/app/src/main/java/com/ethran/notable/ui/viewmodels/LibraryViewModel.kt b/app/src/main/java/com/ethran/notable/ui/viewmodels/LibraryViewModel.kt index e27280a5..a0b9674d 100644 --- a/app/src/main/java/com/ethran/notable/ui/viewmodels/LibraryViewModel.kt +++ b/app/src/main/java/com/ethran/notable/ui/viewmodels/LibraryViewModel.kt @@ -16,8 +16,10 @@ import com.ethran.notable.io.ExportEngine import com.ethran.notable.io.ImportEngine import com.ethran.notable.io.ImportOptions import com.ethran.notable.io.ThumbnailBackfillQueue +import com.ethran.notable.editor.utils.PreviewSaveMode import com.ethran.notable.ui.SnackConf import com.ethran.notable.ui.SnackDispatcher +import com.ethran.notable.utils.fold import com.ethran.notable.utils.isLatestVersion import com.ethran.notable.data.events.AppEventBus import dagger.hilt.android.lifecycle.HiltViewModel @@ -183,10 +185,21 @@ class LibraryViewModel @Inject constructor( try { // Ideally, ImportEngine should be injected via Hilt rather than instantiated here - importEngine.import( + val result = importEngine.import( uri, ImportOptions(folderId = _folderId.value, linkToExternalFile = !copy) ) - snackDispatcher.showOrUpdateSnack(SnackConf(text = "PDF Import Successful")) + + result.fold( + onSuccess = { importedPageIds -> + if (importedPageIds.isNotEmpty()) { + thumbnailBackfillQueue.enqueue(importedPageIds, PreviewSaveMode.STRICT_BW) + } + snackDispatcher.showOrUpdateSnack(SnackConf(text = "PDF Import Successful")) + }, + onError = { error -> + snackDispatcher.showOrUpdateSnack(SnackConf(text = "Import failed: ${error.userMessage}")) + } + ) } catch (e: Exception) { snackDispatcher.showOrUpdateSnack(SnackConf(text = "Import failed: ${e.message}")) } finally { @@ -206,8 +219,15 @@ class LibraryViewModel @Inject constructor( ) try { - importEngine.import(uri, ImportOptions(folderId = _folderId.value)) - snackDispatcher.showOrUpdateSnack(SnackConf(text = "XOPP Import Successful", duration = 3000)) + val result = importEngine.import(uri, ImportOptions(folderId = _folderId.value)) + result.fold( + onSuccess = { _ -> + snackDispatcher.showOrUpdateSnack(SnackConf(text = "XOPP Import Successful", duration = 3000)) + }, + onError = { error -> + snackDispatcher.showOrUpdateSnack(SnackConf(text = "Import failed: ${error.userMessage}")) + } + ) } catch (e: Exception) { snackDispatcher.showOrUpdateSnack(SnackConf(text = "Import failed: ${e.message}")) } finally { From a41b6a6cf345cdbe4dd4a39655f440b03270bb2f Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Sat, 18 Apr 2026 15:34:52 +0200 Subject: [PATCH 11/11] apply copilot suggestions --- .../com/ethran/notable/editor/utils/PreviewBitmapStore.kt | 2 +- .../main/java/com/ethran/notable/editor/utils/einkHelper.kt | 6 +++--- .../main/java/com/ethran/notable/io/PageContentRenderer.kt | 3 +-- .../main/java/com/ethran/notable/io/ThumbnailGenerator.kt | 3 +-- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt index 98f786e9..5720fef8 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt @@ -114,7 +114,7 @@ private fun isCacheFresh(file: File, pageUpdatedAtMs: Long?): Boolean { * We encode the vertical scroll (rounded to Int) into the name so different vertical positions * can have separate cached previews. * - * Format: {pageID}-sy{scrollY}.png + * Format: {pageID}-sy{scrollY}.webp */ private fun buildPreviewFileName(pageID: String, scrollY: Int): String = "${pageID}-sy$scrollY.webp" diff --git a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt index 1a12dc98..43158f61 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt @@ -442,9 +442,8 @@ object DeviceCompat { } fun isColorDevice(): Boolean { - if (!isOnyxDevice) return false + if (!isOnyxDevice) return true return try { - // Uses the method found in your actual SDK jar DeviceInfoUtil.isColorDevice() } catch (e: Exception) { log.e("Failed to check if device is color: ${e.message}") @@ -452,8 +451,9 @@ object DeviceCompat { } } suspend fun delayBeforeResumingDrawing() { + if (!isOnyxDevice) return // 500ms for Kaleido Color e-ink, 300ms for monochrome - val delayMs = if (DeviceCompat.isColorDevice()) 500L else 300L + val delayMs = if (isColorDevice()) 500L else 300L log.d("Delaying raw drawing resume for ${delayMs}ms to allow Android UI to settle") delay(delayMs) } diff --git a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt index 0bea701d..701b685f 100644 --- a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt +++ b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt @@ -21,7 +21,6 @@ import com.ethran.notable.data.model.BackgroundType.Native import com.ethran.notable.editor.drawing.drawBg import com.ethran.notable.editor.drawing.drawImage import com.ethran.notable.editor.drawing.drawStroke -import com.ethran.notable.editor.utils.PreviewSaveMode import com.ethran.notable.utils.ensureNotMainThread import dagger.hilt.android.qualifiers.ApplicationContext import io.shipbook.shipbooksdk.Log @@ -51,7 +50,7 @@ class PageContentRenderer @Inject constructor( val (contentWidth, contentHeight) = computeContentDimensions(data) val size = resolveRenderSize(contentWidth, contentHeight, target) - Log.e("PageContentRenderer", "size: ${size.width}, ${size.height}, ${size.scale}") + Log.d("PageContentRenderer", "size: ${size.width}, ${size.height}, ${size.scale}") createBitmap(size.width, size.height).also { bitmap -> drawPage( canvas = Canvas(bitmap), diff --git a/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt b/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt index b29e5590..214e9f4d 100644 --- a/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt +++ b/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt @@ -30,7 +30,7 @@ enum class ThumbnailEnsureResult { } -const val thumbnailGeneratorStaleMs = 3600000 // 1h +const val thumbnailGeneratorStaleMs = 60000 // 1 min @EntryPoint @InstallIn(SingletonComponent::class) @@ -128,7 +128,6 @@ class ThumbnailGenerator @Inject constructor( } private suspend fun isThumbnailStale(page: Page): Boolean = withContext(ioDispatcher) { - return@withContext true val thumbFile = getThumbnailFile(context, page.id) if (!thumbFile.exists()) return@withContext true