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..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,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.saveHQPagePreview +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 @@ -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,14 +411,14 @@ class PageDataManager @Inject constructor( } scope.launch(Dispatchers.IO) { - persistBitmapFull( + saveHQPagePreview( context, bitmap, pageId, currentScroll, currentZoomLevel ) - persistBitmapThumbnail(context, bitmap, pageId) + savePageThumbnail(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 c0ee53ad..59ea86bf 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 @@ -29,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.loadHQPagePreview import com.ethran.notable.editor.utils.minus import com.ethran.notable.editor.utils.plus import com.ethran.notable.editor.utils.strokeBounds @@ -428,7 +432,7 @@ class PageView( // load background, fast, if it is accurate enough. private fun loadInitialBitmap(): Boolean { - val bitmapFromDisc = loadPersistBitmap( + val bitmapFromDisc = loadHQPagePreview( context = context, pageID = currentPageId, scroll = scroll, @@ -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/canvas/CanvasObserverRegistry.kt b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt index 3e3deb3f..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 @@ -6,10 +6,9 @@ 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.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() } @@ -289,13 +288,12 @@ class CanvasObserverRegistry( val pageUpdatedAtMs = pageDataManager.getPageUpdatedAt(pageId) val previewBitmap = withContext(Dispatchers.IO) { - loadPreview( + loadPagePreviewOrFallback( context = drawCanvas.context, pageIdToLoad = pageId, expectedWidth = page.viewWidth, expectedHeight = page.viewHeight, pageNumber = pageNumber, - requireExactMatch = false, pageUpdatedAtMs = pageUpdatedAtMs ) } 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/editor/utils/PreviewBitmapStore.kt b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt new file mode 100644 index 00000000..5720fef8 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/editor/utils/PreviewBitmapStore.kt @@ -0,0 +1,380 @@ +package com.ethran.notable.editor.utils + +import android.content.Context +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 +import androidx.compose.ui.geometry.Offset +import androidx.core.content.FileProvider +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 +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.roundToInt + +private val log = ShipBook.getLogger("bitmapUtils") + +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 = 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") + +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("savePagePreview: skipping persist (zoom is $zoom, scroll is $scroll)") + return false + } + if (!isEqApprox(zoom, 1f)) { + log.d("savePagePreview: skipping persist (zoom=$zoom not ~1.0)") + return false + } + if (!isEqApprox(scroll.x, 0f)) { + log.d("savePagePreview: skipping persist (scroll.x: ${scroll.x} != 0)") + return false + } + return true +} + +private fun isCacheFresh(file: File, pageUpdatedAtMs: Long?): Boolean { + return pageUpdatedAtMs == null || pageUpdatedAtMs <= 0 || file.lastModified() >= pageUpdatedAtMs +} + +/** + * Build the filename (without directories) for a persisted preview bitmap. + * We encode the vertical scroll (rounded to Int) into the name so different vertical positions + * can have separate cached previews. + * + * Format: {pageID}-sy{scrollY}.webp + */ +private fun buildPreviewFileName(pageID: String, scrollY: Int): String = "${pageID}-sy$scrollY.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) + */ +private fun removeOldBitmaps(dir: File, latestPreview: String, pageID: String) { + dir.listFiles()?.forEach { f -> + if (f.name != latestPreview && f.name.startsWith(pageID)) { + try { + if (f.delete()) { + log.d("saveHQPagePreview: removed old preview ${f.name}") + } + } catch (_: Throwable) { + log.e("saveHQPagePreview: failed to delete old preview ${f.name}") + } + } + } +} + +fun saveHQPagePreview( + context: Context, bitmap: Bitmap, pageID: String, scroll: Offset?, zoom: Float?, mode: PreviewSaveMode = PreviewSaveMode.REGULAR +) { + ensureNotMainThread("saveHQPagePreview") + if (!checkZoomAndScroll(scroll, zoom)) return + + val scrollYInt = scroll!!.y.roundToInt() + val fileName = buildPreviewFileName(pageID, scrollYInt) + val dir = ensurePreviewsFullFolder(context) + val file = File(dir, fileName) + + val optimized = optimizeBitmapForStorage(bitmap, mode, isThumbnail = false) + + try { + file.outputStream().buffered().use { os -> + val success = optimized.bitmap.compress(optimized.format, optimized.quality, os) + if (!success) { + log.e("saveHQPagePreview: Failed to compress bitmap") + return@use + } + log.d("saveHQPagePreview: cached preview saved as $fileName (scrollY=$scrollYInt)") + } + removeOldBitmaps(dir, fileName, pageID) + } catch (e: Exception) { + log.e("saveHQPagePreview: Exception while saving preview: ${e.message}") + logCallStack("saveHQPagePreview") + } finally { + if (optimized.bitmap != bitmap) { + optimized.bitmap.recycle() + } + } +} + +fun loadHQPagePreview( + context: Context, + pageID: String, + scroll: Offset?, + zoom: Float?, + pageUpdatedAtMs: Long?, + requireExactMatch: Boolean, +): Bitmap? { + val dir = ensurePreviewsFullFolder(context) + + if (requireExactMatch) { + if (!checkZoomAndScroll(scroll, zoom)) return null + val scrollYInt = scroll!!.y.roundToInt() + val expectedFileName = buildPreviewFileName(pageID, scrollYInt) + val targetFile = File(dir, expectedFileName) + + if (!targetFile.exists()) { + log.i("loadHQPagePreview: no exact-match cache (expected $expectedFileName)") + return null + } + if (!isCacheFresh(targetFile, pageUpdatedAtMs)) { + log.i("loadHQPagePreview: cache is stale for ${targetFile.name}") + return null + } + return decodeBitmapFromFile(targetFile) + } + + // Try finding the freshest file starting with pageID + val candidates = + dir.listFiles { f -> f.isFile && f.name.startsWith(pageID) && f.name.endsWith(".webp") } + ?.toList()?.filter { isCacheFresh(it, pageUpdatedAtMs) }.orEmpty() + + if (candidates.isEmpty()) { + log.i("loadHQPagePreview: no native cache file for pageID=$pageID") + return null + } + + val newest = candidates.maxByOrNull { it.lastModified() } ?: candidates.first() + return decodeBitmapFromFile(newest) +} + +suspend fun loadPagePreviewOrFallback( + context: Context, + pageIdToLoad: String, + expectedWidth: Int, + expectedHeight: Int, + pageNumber: Int?, + pageUpdatedAtMs: Long? +): Bitmap = withContext(Dispatchers.IO) { + // Load from disk (full quality folder) ignoring requireExactMatch initially to find any full image + var bitmapFromDisk: Bitmap? = try { + loadHQPagePreview( + context, + pageIdToLoad, + null, + null, + pageUpdatedAtMs = pageUpdatedAtMs, + requireExactMatch = false + ) + } catch (t: Throwable) { + log.e("Failed to load persisted bitmap: ${t.message}") + null + } + + if (bitmapFromDisk == null) { + val thumbFile = getThumbnailFile(context, pageIdToLoad) + if (thumbFile.exists()) { + bitmapFromDisk = decodeBitmapFromFile(thumbFile) + } + } + + when { + bitmapFromDisk == null -> { + log.d("No persisted preview for $pageIdToLoad. Creating placeholder.") + createPlaceholderPreview(expectedWidth, expectedHeight, pageNumber) + } + + bitmapFromDisk.width == expectedWidth && bitmapFromDisk.height == expectedHeight -> { + log.d("Loaded preview for page $pageIdToLoad (fits view).") + bitmapFromDisk + } + + else -> { + log.i("Preview size mismatch -> scaling to ${expectedWidth}x${expectedHeight}") + val scaled = createBitmap( + expectedWidth, expectedHeight, bitmapFromDisk.config ?: Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(scaled) + val paint = Paint().apply { + isAntiAlias = true + 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() + scaled + } + } +} + + +private fun createPlaceholderPreview( + width: Int, height: Int, pageNumber: Int? +): Bitmap { + val bmp = createBitmap(width.coerceAtLeast(1), height.coerceAtLeast(1)) + val canvas = Canvas(bmp) + canvas.drawColor(Color.WHITE) + + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.DKGRAY + textAlign = Paint.Align.CENTER + textSize = (min(width, height) * 0.05f).coerceAtLeast(16f) + } + val msg = pageNumber?.let { "Page $it — No Preview" } ?: "No Preview" + + val fm = paint.fontMetrics + val x = width / 2f + val y = height / 2f - (fm.ascent + fm.descent) / 2f + canvas.drawText(msg, x, y, paint) + + return bmp +} + +private fun decodeBitmapFromFile(file: File): Bitmap? { + return try { + val imgBitmap = BitmapFactory.decodeFile(file.absolutePath) + if (imgBitmap != null) { + log.d("decodeBitmapFromFile: loaded cached preview '${file.name}'") + imgBitmap + } else { + log.w("decodeBitmapFromFile: failed to decode bitmap from ${file.name}") + log.d( + $$""" + exists=$${file.exists()} + size=$${file.length()} + name=${file.name} + """.trimIndent() + ) + null + } + } catch (e: Exception) { + log.e("decodeBitmapFromFile: Exception while loading bitmap: ${e.message}") + null + } +} + +/** + * Persist a thumbnail for a page. + */ +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 -> + 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..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 @@ -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 true + return try { + DeviceInfoUtil.isColorDevice() + } catch (e: Exception) { + log.e("Failed to check if device is color: ${e.message}") + false + } + } + suspend fun delayBeforeResumingDrawing() { + if (!isOnyxDevice) return + // 500ms for Kaleido Color e-ink, 300ms for monochrome + val delayMs = if (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/editor/utils/persistBitmap.kt b/app/src/main/java/com/ethran/notable/editor/utils/persistBitmap.kt deleted file mode 100644 index 8f202c6d..00000000 --- a/app/src/main/java/com/ethran/notable/editor/utils/persistBitmap.kt +++ /dev/null @@ -1,357 +0,0 @@ -package com.ethran.notable.editor.utils - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.Rect -import androidx.compose.ui.geometry.Offset -import androidx.core.content.FileProvider -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.logCallStack -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 -private 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") - -private fun isEqqApprox(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)") - return false - } - if (!isEqqApprox(zoom, 1f)) { - log.d("persistBitmapFull: skipping persist (zoom=$zoom not ~1.0)") - return false - } - if (!isEqqApprox(scroll.x, 0f)) { - log.d("persistBitmapFull: skipping persist (scroll.x: ${scroll.x} != 0)") - return false - } - return true -} - -private fun isCacheFresh(file: File, pageUpdatedAtMs: Long?): Boolean { - return pageUpdatedAtMs == null || pageUpdatedAtMs <= 0 || file.lastModified() >= pageUpdatedAtMs -} - -/** - * Build the filename (without directories) for a persisted preview bitmap. - * 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 - */ -private fun buildPreviewFileName(pageID: String, scrollY: Int): String = "${pageID}-sy$scrollY.png" - - -/** - * 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"))) - ) { - 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()) - } - } catch (t: Throwable) { - log.e("persistBitmapFull: failed to delete old preview ${f.name}: ${t::class.simpleName} ${t.message}") - } - } - } -} - -/** - * 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( - context: Context, bitmap: Bitmap, pageID: String, scroll: Offset?, zoom: Float? -) { - if (!checkZoomAndScroll(scroll, zoom)) return - val scrollYInt = scroll!!.y.roundToInt() - val fileName = buildPreviewFileName(pageID, scrollYInt) - val dir = ensurePreviewsFullFolder(context) - val file = File(dir, fileName) - - try { - file.outputStream().buffered().use { os -> - val success = bitmap.compress(Bitmap.CompressFormat.PNG, PREVIEW_QUALITY, os) - if (!success) { - log.e("persistBitmapFull: Failed to compress bitmap") - logCallStack("persistBitmapFull") - return - } else { - log.d("persistBitmapFull: 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") - } -} - -/** - * 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( - context: Context, - pageID: String, - scroll: Offset?, - zoom: Float?, - pageUpdatedAtMs: Long?, - requireExactMatch: Boolean, -): 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 targetFile = candidateFiles.firstOrNull { it.exists() } - if (targetFile == null) { - log.i("loadPersistBitmap: no exact-match cache (expected ${encodedFile.name})") - return null - } - if (!isCacheFresh(targetFile, pageUpdatedAtMs)) { - log.i("loadPersistBitmap: cache is stale for ${targetFile.name} (pageUpdatedAtMs=$pageUpdatedAtMs)") - return null - } - return decodePreview(targetFile, encodedFile.name) - } - - // 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 - val candidates = - (listOfNotNull(encodedFromProvidedScroll) + legacyExtras + allMatches).distinctBy { it.name } - .filter { it.exists() && isCacheFresh(it, pageUpdatedAtMs) } - - if (candidates.isEmpty()) { - log.i("loadPersistBitmap: no cache file for pageID=$pageID (non-exact)") - 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) -} - -// Load preview fast, without touching any windowed canvas. -suspend fun loadPreview( - context: Context, - pageIdToLoad: String, - expectedWidth: Int, - expectedHeight: Int, - pageNumber: Int?, - pageUpdatedAtMs: Long?, - requireExactMatch: Boolean = true, -): 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( - context, - pageIdToLoad, - null, - null, - pageUpdatedAtMs = pageUpdatedAtMs, - requireExactMatch = false - ) - } catch (t: Throwable) { - log.e("Failed to load persisted bitmap: ${t.message}") - 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") - } - } - - val prepared = when { - bitmapFromDisk == null -> { - log.d("No persisted preview for $pageIdToLoad. Creating placeholder.") - createPlaceholderPreview(expectedWidth, expectedHeight, pageNumber) - } - - bitmapFromDisk.width == expectedWidth && bitmapFromDisk.height == expectedHeight -> { - log.d("Loaded preview for page $pageIdToLoad (fits view).") - bitmapFromDisk - } - - else -> { - log.i( - "Preview size mismatch (${bitmapFromDisk.width}x${bitmapFromDisk.height}) -> " + "scaling to ${expectedWidth}x${expectedHeight}" - ) - - val scaled = createBitmap( - expectedWidth, expectedHeight, bitmapFromDisk.config ?: Bitmap.Config.ARGB_8888 - ) - val canvas = Canvas(scaled) - val paint = Paint().apply { - isAntiAlias = true - 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() - } - scaled - } - } - - prepared -} - - -private fun createPlaceholderPreview( - width: Int, height: Int, pageNumber: Int? -): Bitmap { - val bmp = createBitmap(width.coerceAtLeast(1), height.coerceAtLeast(1)) - val canvas = Canvas(bmp) - canvas.drawColor(Color.WHITE) - - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.DKGRAY - textAlign = Paint.Align.CENTER - textSize = (min(width, height) * 0.05f).coerceAtLeast(16f) - } - val msg = pageNumber?.let { "Page $it — No Preview" } ?: "No Preview" - - val fm = paint.fontMetrics - val x = width / 2f - val y = height / 2f - (fm.ascent + fm.descent) / 2f - canvas.drawText(msg, x, y, paint) - - return bmp -} - -private fun decodePreview(file: File, expectedNameForLog: String): 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}'") - } - imgBitmap - } else { - log.w("loadPersistBitmap: failed to decode bitmap from ${file.name}") - log.d( - """ - exists=${file.exists()} - size=${file.length()} - name=${'$'}{file.name} - """.trimIndent() - ) - null - } - } catch (e: Exception) { - log.e("loadPersistBitmap: Exception while loading bitmap: ${e.message}") - logCallStack("loadPersistBitmap") - null - } -} - -/** - * Persist a thumbnail for a page. - */ -fun persistBitmapThumbnail(context: Context, bitmap: Bitmap, pageID: String) { - 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) - } - } catch (e: Exception) { - log.e("persistBitmapThumbnail: Exception while saving thumbnail: ${e.message}") - logCallStack("persistBitmapThumbnail") - } - - if (scaledBitmap != bitmap) { - scaledBitmap.recycle() - } -} 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/ImportEngine.kt b/app/src/main/java/com/ethran/notable/io/ImportEngine.kt index 53031f39..c9c7aeb8 100644 --- a/app/src/main/java/com/ethran/notable/io/ImportEngine.kt +++ b/app/src/main/java/com/ethran/notable/io/ImportEngine.kt @@ -4,33 +4,22 @@ 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 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 -/** - * 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 +72,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 @@ -98,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") @@ -109,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( @@ -137,7 +125,8 @@ class ImportEngine @Inject constructor( ) bookRepo.createEmpty(book) - + val importedPageIds = mutableListOf() + var persistentError: DomainError? = null xoppFile.importBook(uri) { pageData -> try { @@ -146,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() @@ -175,6 +164,8 @@ class ImportEngine @Inject constructor( ) bookRepo.createEmpty(book) + val importedPageIds = mutableListOf() + var persistentError: DomainError? = null importPdf(fileToSave, options) { pageData -> try { @@ -184,20 +175,20 @@ 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) } - 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..701b685f 100644 --- a/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt +++ b/app/src/main/java/com/ethran/notable/io/PageContentRenderer.kt @@ -4,15 +4,17 @@ 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 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 @@ -21,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 @@ -29,7 +32,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 @@ -38,11 +41,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") @@ -52,13 +50,13 @@ class PageContentRenderer @Inject constructor( val (contentWidth, contentHeight) = computeContentDimensions(data) val size = resolveRenderSize(contentWidth, contentHeight, target) + Log.d("PageContentRenderer", "size: ${size.width}, ${size.height}, ${size.scale}") createBitmap(size.width, size.height).also { bitmap -> drawPage( canvas = Canvas(bitmap), data = data, scroll = Offset.Zero, - scaleFactor = size.scale, - backgroundType = data.page.getBackgroundType() + scaleFactor = size.scale ) } } @@ -70,12 +68,11 @@ 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,29 +83,54 @@ class PageContentRenderer @Inject constructor( suspend fun drawPage( canvas: Canvas, - data: PageContent, + 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, + backgroundType = resolvedBackgroundType, background = data.page.background, - scroll = scaledScroll, - scale = scaleFactor + scroll = scroll, + resourceBitmap = bgImage, + scale = scaleFactor, + repeat = resolvedBackgroundType is BackgroundType.ImageRepeating ) - data.images.forEach { drawImage(context, canvas, it, -scaledScroll) } - data.strokes.forEach { drawStroke(canvas, it, -scaledScroll) } + + data.images.forEach { drawImage(context, canvas, it, -scroll) } + data.strokes.forEach { drawStroke(canvas, it, -scroll) } } } // 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 +141,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) @@ -130,28 +152,48 @@ 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() + ) ) - ) - - val width = (contentWidth * scale).toInt().coerceAtLeast(1) - val height = (contentHeight * scale).toInt().coerceAtLeast(1) - RenderSize(width, height, scale) + 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() + } + + 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 a22dcd2b..a51031e2 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 @@ -29,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() @@ -41,9 +42,10 @@ class ThumbnailBackfillQueue @Inject constructor( private var lastUpdateMs = 0L init { + // listen for thumbnail generation requests applicationScope.launch(ioDispatcher) { - for (pageId in queue) { - processOne(pageId) + for ((pageId, mode) in queue) { + processOne(pageId, mode) } } } @@ -51,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) { @@ -77,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) @@ -88,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 { @@ -112,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 a62ab099..214e9f4d 100644 --- a/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt +++ b/app/src/main/java/com/ethran/notable/io/ThumbnailGenerator.kt @@ -4,9 +4,10 @@ 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.getThumbnailTargetWidthPx -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 @@ -15,14 +16,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 +29,9 @@ enum class ThumbnailEnsureResult { PAGE_NOT_FOUND } + +const val thumbnailGeneratorStaleMs = 60000 // 1 min + @EntryPoint @InstallIn(SingletonComponent::class) interface ThumbnailGeneratorEntryPoint { @@ -63,15 +63,12 @@ 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. * 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 @@ -86,12 +83,10 @@ 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) { @@ -100,10 +95,8 @@ class ThumbnailGenerator @Inject constructor( } try { - val result = generateIfNeeded(page) + val result = generate(page, mode) if (result == ThumbnailEnsureResult.GENERATED) { - val now = System.currentTimeMillis() - _thumbnailSignatures.update { it + (pageId to now) } _thumbnailUpdated.tryEmit(pageId) } marker.complete(result) @@ -116,33 +109,21 @@ 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() + private suspend fun generate( + page: Page, mode: PreviewSaveMode + ): ThumbnailEnsureResult { val bitmap = pageContentRenderer.renderPageBitmap( pageId = page.id, target = RenderTarget.Thumbnail( - maxWidthPx = targetWidth, - maxHeightPx = Int.MAX_VALUE + maxWidthPx = THUMBNAIL_WIDTH, + maxHeightPx = null ) ) - bitmap.useAndRecycle { rendered -> - persistBitmapThumbnail(context, rendered, page.id) + savePageThumbnail(context, rendered, page.id, mode) } - writeThumbnailMeta(page) - - log.d("Thumbnail ensured for pageId=${page.id}") + log.d("Thumbnail generated for pageId=${page.id}") return ThumbnailEnsureResult.GENERATED } @@ -150,38 +131,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 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) { 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 { 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" } - - - - -