Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ android {
minSdk 29
targetSdk 35

versionCode 35
versionName '0.2.1'
versionCode 36
versionName '0.2.2'
if (project.hasProperty('IS_NEXT') && project.property('IS_NEXT').toBoolean()) {
def timestamp = new Date().format('dd.MM.YYYY-HH:mm')
versionName = "${versionName}-next-${timestamp}"
Expand Down
13 changes: 11 additions & 2 deletions app/src/main/java/com/ethran/notable/data/dbUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,19 @@ fun getDbDir(): File {
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
val dbDir = File(documentsDir, "notabledb")
if (!dbDir.exists()) {
dbDir.mkdirs()
val created = dbDir.mkdirs()
if (!created && !dbDir.exists()) {
throw IllegalStateException(
"Database directory could not be created at ${dbDir.absolutePath}. " +
"Grant 'All files access' to Notable in system settings and restart the app."
)
}
}
if (!dbDir.canWrite()) {
throw IllegalStateException("Database directory is not writable")
throw IllegalStateException(
"Database directory is not writable: ${dbDir.absolutePath}. " +
"Grant 'All files access' to Notable in system settings and restart the app."
)
}
return dbDir
}
Expand Down
62 changes: 41 additions & 21 deletions app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import io.shipbook.shipbooksdk.ShipBook
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
Expand All @@ -33,11 +36,18 @@ class EditorControlTower(
private val clipboardStore: ClipboardStore,
) {
private var scrollInProgress = Mutex()
private var scrollJob: Job? = null
private val logEditorControlTower = ShipBook.getLogger("EditorControlTower")
private var changePageObserverJob: Job? = null

// Accumulated, not-yet-rendered scroll delta in screen coordinates. Input events add
// into this; a single consumer coroutine drains and renders it. StateFlow conflation
// means a burst of input collapses to one render pass per frame the renderer can keep
// up with.
private val pendingScroll = MutableStateFlow(Offset.Zero)
private var scrollConsumerJob: Job? = null

fun registerObservers() {
startScrollConsumer()
if (changePageObserverJob?.isActive == true) return

changePageObserverJob = scope.launch {
Expand All @@ -61,34 +71,44 @@ class EditorControlTower(
fun unregisterObservers() {
changePageObserverJob?.cancel()
changePageObserverJob = null
scrollConsumerJob?.cancel()
scrollConsumerJob = null
}

// returns delta if could not scroll, to be added to next request,
// this ensures that smooth scroll works reliably even if rendering takes to long
fun processScroll(delta: Offset): Offset {
if (delta == Offset.Zero) return Offset.Zero
if (!page.isTransformationAllowed) return Offset.Zero
if (scrollInProgress.isLocked) {
logEditorControlTower.w("Scroll in progress -- skipping")
return delta
} // Return unhandled part

scrollJob = scope.launch(Dispatchers.Main.immediate) {
scrollInProgress.withLock {
val scaledDelta = (delta / page.zoomLevel.value)
if (viewModel.toolbarState.value.mode == Mode.Select) {
if (viewModel.selectionState.firstPageCut != null) {
onOpenPageCut(scaledDelta)
/**
* Submit a scroll/drag delta (screen coordinates) for rendering. Non-blocking: the
* delta is accumulated and consumed by [startScrollConsumer]; bursts coalesce automatically.
*/
fun requestScroll(delta: Offset) {
if (delta == Offset.Zero) return
if (!page.isTransformationAllowed) return
pendingScroll.update { it + delta }
}

/**
* Single consumer that drains [pendingScroll] and performs the actual bitmap shift.
* Draining atomically resets the accumulator to zero, so any input that arrives while
* a render is in flight piles onto a fresh zero and is picked up on the next pass —
* coalescing a flood of touch samples into one shift per render.
*/
private fun startScrollConsumer() {
if (scrollConsumerJob?.isActive == true) return
scrollConsumerJob = scope.launch(Dispatchers.Main.immediate) {
pendingScroll.collect {
val delta = pendingScroll.getAndUpdate { Offset.Zero }
if (delta == Offset.Zero) return@collect
scrollInProgress.withLock {
if (viewModel.toolbarState.value.mode == Mode.Select &&
viewModel.selectionState.firstPageCut != null
) {
onOpenPageCut(delta / page.zoomLevel.value)
} else {
onPageScroll(-delta)
}
} else {
onPageScroll(-delta)
}
CanvasEventBus.refreshUiImmediately.emit(Unit)
}
CanvasEventBus.refreshUiImmediately.emit(Unit)
}
return Offset.Zero // All handled
}


Expand Down
15 changes: 12 additions & 3 deletions app/src/main/java/com/ethran/notable/editor/PageView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ class PageView(
var windowedCanvas = Canvas(windowedBitmap)
private set

// Spare screen-sized buffer reused by updateScroll to avoid allocating a new
// bitmap on every scroll event. Ping-ponged with windowedBitmap; recreated only
// when the canvas size/config changes (zoom, dimension change, page switch).
private var scrollBackBuffer: Bitmap? = null

// var strokes = listOf<Stroke>()
var strokes: List<Stroke>
get() = pageDataManager.getStrokes(currentPageId)
Expand Down Expand Up @@ -573,13 +578,17 @@ class PageView(

val width = windowedBitmap.width
val height = windowedBitmap.height
// Shift the existing bitmap content
val shiftedBitmap = createBitmap(width, height, windowedBitmap.config!!)
// Shift the existing bitmap content into the spare buffer, reusing it across
// scroll events. Recreate the spare only if it doesn't match current geometry.
val shiftedBitmap = scrollBackBuffer?.takeIf {
it.width == width && it.height == height && it.config == windowedBitmap.config
} ?: createBitmap(width, height, windowedBitmap.config!!)
val shiftedCanvas = Canvas(shiftedBitmap)
shiftedCanvas.drawColor(Color.RED) //for debugging.
shiftedCanvas.drawBitmap(windowedBitmap, -movement.x, -movement.y, null)

// Swap in the shifted bitmap
// Swap in the shifted bitmap; the old live buffer becomes the next spare.
scrollBackBuffer = windowedBitmap
windowedBitmap = shiftedBitmap
windowedCanvas.setBitmap(windowedBitmap)
windowedCanvas.scale(zoomLevel.value, zoomLevel.value)
Expand Down
Loading
Loading