From 9d09cc46568e85c8a05f18051eb9b87dcd2c5cc8 Mon Sep 17 00:00:00 2001 From: Nate Eaton Date: Wed, 20 May 2026 11:09:20 -0500 Subject: [PATCH 1/3] feat: deterministic sync progress indicator + related housekeeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bookmark list (initial sync): - Add BookmarkSyncProgress sealed interface (Idle / Running) to BookmarkRepository; emit Running(page, totalPages) after each page in performFullSync(), reset to Idle in finally block. - Fix BookmarkRepositoryImpl binding in AppModule: add @Singleton so the Worker and ViewModel share the same instance and progress state is visible across both. - Expose syncFraction: StateFlow from BookmarkListViewModel, mapped from syncProgress. - Replace pull-to-refresh spinner on initial load with a deterministic LinearProgressIndicator below the TopAppBar; PullToRefreshBox spinner is now only shown for user-initiated refreshes. - Auto-scroll list to top when initial sync completes via snapshotFlow on isInitialLoading → scrollToTopTrigger. Highlights (initial sync / pull-to-refresh): - Add indeterminate LinearProgressIndicator below TopAppBar for auto-syncs (guarded by isRefreshing && !isUserRefreshing && !isInitialLocalLoad). - Add isUserRefreshing tracking to HighlightsViewModel: set on retry(), reset via init observer when sync state leaves Running. Wire into HighlightsUiState and PullToRefreshBox so the circular spinner only shows for user-initiated pulls. - Remove inline CircularProgressIndicator from the empty-state body (replaced by top-bar LinearProgressIndicator). Logging: - Revert HttpLoggingInterceptor from BODY back to BASIC; full response bodies were excessive for large libraries and impacted app load time. Housekeeping: - Update CHANGELOG.md with entries for 0.11.0 through 0.13.0. - Update docs/WORKFLOW.md: add CHANGELOG.md update step to release prep. - Remove _notes/reader-top-bar-scroll-behavior-spec.md from git tracking (_notes/ is local-only per .gitignore). - Add docs/specs/deterministic-sync-progress-indicator-spec.md. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 90 ++++++++++++ _notes/reader-top-bar-scroll-behavior-spec.md | 76 ----------- app/src/main/java/com/mydeck/app/AppModule.kt | 1 + .../mydeck/app/domain/BookmarkRepository.kt | 8 ++ .../app/domain/BookmarkRepositoryImpl.kt | 10 ++ .../com/mydeck/app/io/rest/NetworkModule.kt | 2 +- .../app/ui/highlights/HighlightsScreen.kt | 15 +- .../app/ui/highlights/HighlightsViewModel.kt | 19 ++- .../mydeck/app/ui/list/BookmarkListScreen.kt | 25 +++- .../app/ui/list/BookmarkListViewModel.kt | 8 ++ docs/WORKFLOW.md | 5 +- ...erministic-sync-progress-indicator-spec.md | 128 ++++++++++++++++++ 12 files changed, 294 insertions(+), 93 deletions(-) delete mode 100644 _notes/reader-top-bar-scroll-behavior-spec.md create mode 100644 docs/specs/deterministic-sync-progress-indicator-spec.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5a3245..99ff2b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,102 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.0] - 2026-05-18 + +### Added + +- Highlights: select and save text highlights while reading articles, with support for adding notes to each highlight +- Global highlights list in the navigation drawer: browse, search, filter, and sort all highlights across your bookmarks +- Swipe actions on bookmark cards for quick access to favorite, archive, and delete + +### Changed + +- Reader now uses native WebView scrolling for smoother, more reliable article navigation +- Picture bookmark reading view layout updated to match Readeck's presentation + +### Fixed + +- Foreground service crash on Android 14+ devices +- Reader top bar overlay behavior corrected +- Sync cancellation and resilience improvements + +## [0.12.6] - 2026-05-05 + +### Fixed + +- Resolved a critical OutOfMemoryError that caused crashes during offline content synchronization + +### Changed + +- Cleaned up obsolete code and reduced app footprint + +## [0.12.0] - 2026-04-11 + +### Added + +- In-page anchor link support in reader content: table-of-contents and fragment links now navigate correctly within articles +- Long-press context menu for in-page links to open or copy anchor targets +- Content download status icon on reading-view bookmark cards +- Reading progress icon in Compact list view + +### Changed + +- Sync architecture updated to multipart sync for improved reliability and consistency when refreshing bookmark metadata and content +- Sync Settings revised with automatic content sync for offline reading using volume, item-count, or date-range policies + +### Fixed + +- Reader text reflow regression: resolved cases where article text could disappear after layout/font reflow updates +- Server URL validation now allows http:// endpoints (in addition to https://) for self-hosted/local Readeck setups +- Offline status indicator now tracks network transitions more accurately and avoids incorrect offline icon states + +## [0.11.1] - 2026-03-19 + ### Added +- Special thanks to Stefan (@Alanon202) in About screen and README for app functionality feedback and testing support + ### Changed +- Background sync indicator removed for cleaner UI +- Filter chip behavior: dismissing synthetic chips restores preset defaults, literal chip removal remains unchanged + ### Fixed +- Server error flag propagation: bookmarks with server errors now correctly appear in "With errors" filter after refresh/create operations +- Text autosizing in reader: fixed 8-12% text size increase when switching from Medium to Wide reader width +- Filter UI: synthetic filter chips now appear when preset constraints are broadened (e.g., "Is archived: N/A", "Is favorite: N/A", "Type: Any") +- Sync performance: reduced blocking spinner on app open by showing cached bookmarks immediately during background sync +- Delete operation: fixed race condition with rapid successive delete actions +- Layout stability: eliminated theme switching reflow and fixed layout shift in sync indicator +- Video controls: improved fullscreen discoverability and auto-rotation behavior +- Missing translations: added localized "Copy to clipboard" text for all languages + +## [0.11.0] - 2026-03-14 + +### Added + +- Image gallery lightbox in reading view: tap any article image to open a full-screen gallery with swipe navigation, pinch-to-zoom, double-tap to zoom, and a thumbnail strip +- Long-press context menus for images and links in reader view and bookmark list (copy, download, share, open in browser) +- Highlights and annotations: view, create, and edit Readeck highlights directly in the reading view +- "Keep screen on while reading" toggle in Settings → User Interface +- Reader appearance settings: curated themes, font size, line spacing, content width, and fullscreen mode +- Fullscreen reading mode: hides top bar while reading; swipe up to reveal controls +- Typography and Find in Page now available for Video and Picture bookmark types +- About screen shows app and server info in collapsible cards (version, build, server name, URL) +- 15-minute option added to auto-sync schedule + +### Changed + +- Favorite and Archive actions moved from top bar to overflow menu and inline buttons at end of article content +- Long-press context menus replaced with centered dialog popups showing a preview header +- "View original" renamed to "View web page" throughout +- Menu items and filter labels now use sentence case +- Bookmark deletion: card stays visible but greyed-out until snackbar is dismissed or undo is pressed +- Delta sync re-enabled for Readeck 0.22+; deleted bookmarks now detected immediately on pull-to-refresh +- Navigation drawer and settings screen typography refined for better visual hierarchy +- User guide gains a "Contents" button for one-tap navigation back to the table of contents + ## [0.10.0] - 2026-02-26 ### Added diff --git a/_notes/reader-top-bar-scroll-behavior-spec.md b/_notes/reader-top-bar-scroll-behavior-spec.md deleted file mode 100644 index 790c8b3a..00000000 --- a/_notes/reader-top-bar-scroll-behavior-spec.md +++ /dev/null @@ -1,76 +0,0 @@ -# Reader Top Bar — Intended Scroll Behavior - -Hand-off spec describing how the bookmark detail / reader view top app bar is **supposed** to behave. Captured after a circular attempt at fixing slow-scroll content "ghosting" so the next pass can start from intent rather than from the failed attempts. - -## In-scope screens - -- `BookmarkDetailScreen` (reader for **article**, **video**, **picture** bookmark types) -- Both reader and original/embedded content modes - -Out of scope: highlights screen, list screen, fullscreen reader mode (it has its own state machine). - -## Required behaviors - -Implementation decision for the next pass: article reader content uses a hideable overlay top bar, while video and picture reader content keep the top bar visible. All extracted-content bookmark types still use the same WebView reader path and get an initial HTML clearance element before the title/description so the header is visible at scrollY=0. - -1. **Title and description must be fully visible at scrollY=0 with the bar shown.** When a user opens any bookmark with read progress 0, the article header (`

`, `

`) sits below the top bar — not partially under it, not below an empty 80–130 dp gap. -2. **Bar hides on scroll-down, reveals on scroll-up.** Tracks finger naturally (current rate feels right per Codex's implementation). -3. **No content ghosting at any scroll velocity.** The bar transition must not produce a visible per-frame phase shift between the WebView's content position and the bar's position. -4. **Bar auto-reveals when user reaches near the bottom.** Existing threshold ≈ 95% scroll progress. -5. **Bar snaps to fully visible when scrolled to top.** `scrollY <= 0` ⇒ `heightOffset = 0`. -6. **Hiding the bar must give the user more reading area.** A solution that always reserves the bar's slot (so hiding doesn't reclaim pixels) is not acceptable. -7. **Non-scrollable content (Picture, Video) must still let the user see the title.** Today there is no way to access the title when the bar is showing and the article body is empty / shorter than the viewport — this is a *pre-existing* bug, but any solution must fix it, not paper over it. -8. **Tap-on-bar scrolls to top.** Already wired; must keep working for all content types including non-scrollable ones (currently scrolls a non-scrollable WebView to a no-op position). - -## Why the last two attempts failed - -### Attempt A — `Scaffold` + `enterAlwaysScrollBehavior` (the pre-existing implementation) - -Bar lives in the Scaffold's `topBar` slot. As `heightOffset` shrinks, the slot's measured height shrinks, so the Scaffold remeasures and the WebView container's bounds shrink/grow by the same amount. The WebView resize races against the WebView's own scroll on RenderThread → at sub-pixel-per-frame scroll velocities the user sees content "ghost" / double-image. - -Tried mitigating with: pixel snapping of `heightOffset`, a sub-2-px scroll dead zone. Neither helped because both still resize the WebView container per frame. - -### Attempt B — Overlay bar, WebView fills screen, CSS `padding-top` for clearance - -`Scaffold` removed; the bar is overlaid in a `Box` on top of a full-screen WebView. WebView bounds become constant, so the cross-thread race disappears. - -To prevent the bar from covering the title, the HTML body got a CSS `padding-top` clear-zone (~130 dp on Pixel 9, set dynamically via `evaluateJavascript` from the measured bar height). - -Why this broke: - -1. **Non-scrollable content (Picture/Video)** has no body to scroll past, so the CSS top pad **permanently** consumes the area where the title would be — the user can never see the title even by scrolling. Hiding the bar leaves an equivalent empty band at the top because we now require the WebView to expose its first 130 dp via scroll. -2. The CSS-padding approach is fundamentally a "title is part of the scrollable body" model. Picture/Video views don't fit that model. -3. The WebView didn't reliably re-paint after `body.style.padding-top` was set — title only appeared after a slow scroll triggered a layout pass. Forcing reflow (`offsetHeight`) and `postInvalidate()` helped but did not fully fix it across the cold-load path. -4. With the bar overlaid and the title behind the CSS clear-zone, the "tap bar to scroll to top" was broken: the bar's tap target *is* in the area the user wants to reveal. - -## Design questions for the next pass - -These are the decisions a successor solution has to make. Listed not because they have a known answer, but because every workable design picks among them. - -1. **Where does the title/description live — inside the WebView (HTML) or outside (Compose)?** - - Inside HTML (current): one render surface, but title is bound to the article's scroll context. Bad fit for Picture/Video. - - Outside (a Compose header above the WebView): clean separation, title always reachable, but introduces a second scrollable surface to coordinate with the bar. -2. **Is the bar opaque or scrim/translucent?** - - Opaque (current): hard requirement that content not be hidden under it. - - Translucent with content extending under: title is partially visible through the bar at scrollY=0 even without a clear-zone; standard iOS-style pattern. -3. **For Picture/Video views (no scrollable body), does the bar auto-hide on display?** - - Show bar briefly on entry, then auto-hide; user taps to bring it back. - - Or: bar is always visible but content is overlaid such that the bar doesn't cover key elements. -4. **What is the source of truth for the bar's "should be visible" state?** - - WebView scroll deltas (current — fragile because of WebView ↔ Compose timing). - - User gesture velocity captured in Compose ahead of WebView (cleaner). - - Simple binary toggle on tap (loses the "tracks finger" feel users liked). -5. **Should the bar resize the available content area, or always overlay it?** - - Resize: ghosting risk returns. - - Always overlay: requires solving the title-visibility problem some other way. - -## Suggested starting point for the next session - -Move the header (title + description + site name) **out of the HTML** and into a Compose-rendered header above the WebView. Then: - -- The Compose header is a real Compose node that participates in nested scroll naturally. -- The WebView only carries article body (or is hidden entirely for Picture/Video views). -- The top bar can overlay the Compose header without the WebView-resize race because the Compose header *does* resize (it is a Compose node, all on the UI thread). -- Picture/Video views become trivial: just render the image/embed below the Compose header, no WebView clear-zone trick required. - -This is a larger change (it splits the article's "metadata header" away from the reader template) but it removes the WebView coordinate space from the bar visibility problem entirely. diff --git a/app/src/main/java/com/mydeck/app/AppModule.kt b/app/src/main/java/com/mydeck/app/AppModule.kt index 8aff06f4..ae75e13b 100644 --- a/app/src/main/java/com/mydeck/app/AppModule.kt +++ b/app/src/main/java/com/mydeck/app/AppModule.kt @@ -32,6 +32,7 @@ abstract class AppModule { abstract fun bindHighlightsRepository(highlightsRepositoryImpl: com.mydeck.app.domain.HighlightsRepositoryImpl): com.mydeck.app.domain.HighlightsRepository @Binds + @Singleton abstract fun bindBookmarkRepository(bookmarkRepositoryImpl: BookmarkRepositoryImpl): BookmarkRepository @Binds diff --git a/app/src/main/java/com/mydeck/app/domain/BookmarkRepository.kt b/app/src/main/java/com/mydeck/app/domain/BookmarkRepository.kt index 867fda9d..1a6bf719 100644 --- a/app/src/main/java/com/mydeck/app/domain/BookmarkRepository.kt +++ b/app/src/main/java/com/mydeck/app/domain/BookmarkRepository.kt @@ -6,6 +6,7 @@ import com.mydeck.app.domain.model.BookmarkListItem import com.mydeck.app.domain.model.BookmarkMetadataUpdate import com.mydeck.app.domain.model.ProgressFilter import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow interface BookmarkRepository { fun observeBookmarks( @@ -40,6 +41,8 @@ interface BookmarkRepository { suspend fun updateBookmark(bookmarkId: String, isFavorite: Boolean?, isArchived: Boolean?, isRead: Boolean?): UpdateResult suspend fun updateReadProgress(bookmarkId: String, progress: Int): UpdateResult suspend fun updateLabels(bookmarkId: String, labels: List): UpdateResult + val syncProgress: StateFlow + suspend fun performFullSync(): SyncResult suspend fun performDeltaSync(since: kotlinx.datetime.Instant?): SyncResult suspend fun syncPendingActions(): UpdateResult @@ -83,6 +86,11 @@ interface BookmarkRepository { suspend fun refreshBookmarkMetadata(bookmarkId: String) suspend fun fetchExtractionLog(bookmarkId: String): ExtractionLogResult + sealed interface BookmarkSyncProgress { + data object Idle : BookmarkSyncProgress + data class Running(val page: Int, val totalPages: Int) : BookmarkSyncProgress + } + sealed class ExtractionLogResult { data class Success(val text: String) : ExtractionLogResult() data class HttpError(val code: Int) : ExtractionLogResult() diff --git a/app/src/main/java/com/mydeck/app/domain/BookmarkRepositoryImpl.kt b/app/src/main/java/com/mydeck/app/domain/BookmarkRepositoryImpl.kt index 273e497a..569ab806 100644 --- a/app/src/main/java/com/mydeck/app/domain/BookmarkRepositoryImpl.kt +++ b/app/src/main/java/com/mydeck/app/domain/BookmarkRepositoryImpl.kt @@ -37,6 +37,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.NonCancellable @@ -66,6 +69,10 @@ class BookmarkRepositoryImpl @Inject constructor( @IoDispatcher private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : BookmarkRepository { + + private val _syncProgress = MutableStateFlow(BookmarkRepository.BookmarkSyncProgress.Idle) + override val syncProgress: StateFlow = _syncProgress.asStateFlow() + override fun observeBookmarks( type: Bookmark.Type?, unread: Boolean?, @@ -755,6 +762,8 @@ class BookmarkRepositoryImpl @Inject constructor( totalInserted += bookmarks.size } + _syncProgress.value = BookmarkRepository.BookmarkSyncProgress.Running(currentPage, totalPages) + if (currentPage < totalPages) { offset += pageSize } else { @@ -816,6 +825,7 @@ class BookmarkRepositoryImpl @Inject constructor( Timber.e(e, "Full sync failed") BookmarkRepository.SyncResult.NetworkError(errorMessage = "Network error during full sync", ex = e) } finally { + _syncProgress.value = BookmarkRepository.BookmarkSyncProgress.Idle // NonCancellable ensures cleanup runs even when the coroutine is cancelled. withContext(NonCancellable) { try { diff --git a/app/src/main/java/com/mydeck/app/io/rest/NetworkModule.kt b/app/src/main/java/com/mydeck/app/io/rest/NetworkModule.kt index db20d417..fa08d04e 100644 --- a/app/src/main/java/com/mydeck/app/io/rest/NetworkModule.kt +++ b/app/src/main/java/com/mydeck/app/io/rest/NetworkModule.kt @@ -51,7 +51,7 @@ object NetworkModule { Timber.tag("OkHttp").d(message) } val loggingInterceptor = HttpLoggingInterceptor(timberLogger).apply { - level = HttpLoggingInterceptor.Level.BODY + level = HttpLoggingInterceptor.Level.BASIC redactHeader("Authorization") redactHeader("Cookie") redactHeader("Set-Cookie") diff --git a/app/src/main/java/com/mydeck/app/ui/highlights/HighlightsScreen.kt b/app/src/main/java/com/mydeck/app/ui/highlights/HighlightsScreen.kt index 929bdb5a..656363a2 100644 --- a/app/src/main/java/com/mydeck/app/ui/highlights/HighlightsScreen.kt +++ b/app/src/main/java/com/mydeck/app/ui/highlights/HighlightsScreen.kt @@ -214,6 +214,9 @@ fun HighlightsContent( if (uiState.cachePartial && uiState.groups.isNotEmpty()) { HighlightsPartialCacheBanner() } + if (uiState.isRefreshing && !uiState.isUserRefreshing && !uiState.isInitialLocalLoad) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } } } ) { paddingValues -> @@ -246,16 +249,6 @@ fun HighlightsContent( MaterialTheme.colorScheme.onSurfaceVariant } ) - if (uiState.isRefreshing) { - Spacer(Modifier.height(12.dp)) - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(R.string.highlights_refreshing), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } if (uiState.refreshFailed) { Spacer(Modifier.height(8.dp)) Button(onClick = onRetry) { @@ -274,7 +267,7 @@ fun HighlightsContent( } else { val pullToRefreshState = rememberPullToRefreshState() PullToRefreshBox( - isRefreshing = uiState.isRefreshing, + isRefreshing = uiState.isUserRefreshing, onRefresh = onRetry, state = pullToRefreshState, modifier = Modifier.fillMaxSize() diff --git a/app/src/main/java/com/mydeck/app/ui/highlights/HighlightsViewModel.kt b/app/src/main/java/com/mydeck/app/ui/highlights/HighlightsViewModel.kt index dccaf275..d0252b0c 100644 --- a/app/src/main/java/com/mydeck/app/ui/highlights/HighlightsViewModel.kt +++ b/app/src/main/java/com/mydeck/app/ui/highlights/HighlightsViewModel.kt @@ -30,6 +30,7 @@ class HighlightsViewModel @Inject constructor( ) : ViewModel() { private val searchState = MutableStateFlow(HighlightsSearchState()) + private val _userRequestedRefresh = MutableStateFlow(false) val uiState: StateFlow = combine( highlightsRepository.observeHighlights() @@ -52,8 +53,9 @@ class HighlightsViewModel @Inject constructor( Timber.d("Highlights sync state observed: %s", syncState.toHighlightsLogString()) }, highlightsRepository.observeSyncMetadata(), - searchState - ) { groupsOrNull, syncState, metadata, searchState -> + searchState, + _userRequestedRefresh + ) { groupsOrNull, syncState, metadata, searchState, userRequestedRefresh -> val groups = groupsOrNull.orEmpty() val filteredGroups = filterHighlightGroups(groups, searchState) val state = HighlightsUiState( @@ -67,6 +69,7 @@ class HighlightsViewModel @Inject constructor( isSearchActive = searchState.isSearchActive, isInitialLocalLoad = groupsOrNull == null, isRefreshing = syncState is HighlightsSyncState.Running, + isUserRefreshing = userRequestedRefresh && syncState is HighlightsSyncState.Running, refreshFailed = syncState is HighlightsSyncState.Failed, loadedCount = (syncState as? HighlightsSyncState.Running)?.loadedCount, cachePartial = isCachePartial(metadata), @@ -94,11 +97,22 @@ class HighlightsViewModel @Inject constructor( initialValue = HighlightsUiState() ) + init { + viewModelScope.launch { + highlightsRepository.observeSyncState().collect { state -> + if (state !is HighlightsSyncState.Running) { + _userRequestedRefresh.value = false + } + } + } + } + fun refreshFromScreenOpen() { refresh(HighlightsRefreshReason.SCREEN_OPEN) } fun retry() { + _userRequestedRefresh.value = true refresh(HighlightsRefreshReason.USER_RETRY) } @@ -237,6 +251,7 @@ data class HighlightsUiState( val isSearchActive: Boolean = false, val isInitialLocalLoad: Boolean = true, val isRefreshing: Boolean = false, + val isUserRefreshing: Boolean = false, val refreshFailed: Boolean = false, val loadedCount: Int? = null, val cachePartial: Boolean = false, diff --git a/app/src/main/java/com/mydeck/app/ui/list/BookmarkListScreen.kt b/app/src/main/java/com/mydeck/app/ui/list/BookmarkListScreen.kt index ac8169f0..36a83bac 100644 --- a/app/src/main/java/com/mydeck/app/ui/list/BookmarkListScreen.kt +++ b/app/src/main/java/com/mydeck/app/ui/list/BookmarkListScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.material.icons.outlined.SearchOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.DrawerState import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -70,6 +71,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.collectAsState import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.key @@ -176,6 +178,7 @@ fun BookmarkListScreen( val pullToRefreshState = rememberPullToRefreshState() val isInitialLoading by viewModel.isInitialLoading.collectAsState() val isUserRefreshing by viewModel.isUserRefreshing.collectAsState() + val syncFraction by viewModel.syncFraction.collectAsState() val isLabelMode = activeLabel.value != null val dismissPendingDeleteSnackbar: () -> Unit = { @@ -397,6 +400,17 @@ fun BookmarkListScreen( } } + // Scroll to top when initial sync completes so the list starts at item 0 + LaunchedEffect(Unit) { + var wasLoading = false + snapshotFlow { isInitialLoading }.collect { loading -> + if (wasLoading && !loading) { + scrollToTopTrigger++ + } + wasLoading = loading + } + } + // Constraint feedback snackbar (fires once after app-open sync if content sync is blocked) LaunchedEffect(Unit) { viewModel.constraintSnackbarEvent.collect { messageRes -> @@ -453,6 +467,7 @@ fun BookmarkListScreen( } }, topBar = { + Column { TopAppBar( title = { if (isLabelMode) { @@ -641,6 +656,14 @@ fun BookmarkListScreen( } } ) + val fraction = syncFraction + if (fraction != null) { + LinearProgressIndicator( + progress = { fraction }, + modifier = Modifier.fillMaxWidth() + ) + } + } }, floatingActionButton = { val clipboardManager = LocalClipboardManager.current @@ -688,7 +711,7 @@ fun BookmarkListScreen( } PullToRefreshBox( - isRefreshing = isInitialLoading || isUserRefreshing, + isRefreshing = isUserRefreshing, onRefresh = { viewModel.onPullToRefresh() }, state = pullToRefreshState, modifier = Modifier.fillMaxWidth() diff --git a/app/src/main/java/com/mydeck/app/ui/list/BookmarkListViewModel.kt b/app/src/main/java/com/mydeck/app/ui/list/BookmarkListViewModel.kt index 490fa4ad..9bf884c4 100644 --- a/app/src/main/java/com/mydeck/app/ui/list/BookmarkListViewModel.kt +++ b/app/src/main/java/com/mydeck/app/ui/list/BookmarkListViewModel.kt @@ -168,6 +168,14 @@ class BookmarkListViewModel @Inject constructor( val swipeConfig: StateFlow = settingsDataStore.swipeConfigFlow + val syncFraction: StateFlow = bookmarkRepository.syncProgress + .map { progress -> + if (progress is BookmarkRepository.BookmarkSyncProgress.Running) { + progress.page / progress.totalPages.toFloat() + } else null + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + private val loadBookmarksWorkInfos: StateFlow> = workManager.getWorkInfosForUniqueWorkFlow(LoadBookmarksWorker.UNIQUE_WORK_NAME) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 0c2b9d24..a06816d7 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -58,8 +58,9 @@ Runs when a tag starting with `v` is pushed (e.g., `v0.12.0`). * `versionCode`: `(major × 1,000,000) + (minor × 1,000) + patch` * `versionName`: `"X.Y.Z"` 3. Add changelog: `metadata/en-US/changelogs/.txt`. -4. Commit: `chore(release): bump version to X.Y.Z`. -5. Open PR -> Merge to `main`. +4. Update `CHANGELOG.md`: move items from `[Unreleased]` into a new `## [X.Y.Z] - YYYY-MM-DD` section. +5. Commit: `chore(release): bump version to X.Y.Z`. +6. Open PR -> Merge to `main`. ### Step 2: Tag and Publish 1. 💻 `git checkout main && git pull` diff --git a/docs/specs/deterministic-sync-progress-indicator-spec.md b/docs/specs/deterministic-sync-progress-indicator-spec.md new file mode 100644 index 00000000..a9eb54e0 --- /dev/null +++ b/docs/specs/deterministic-sync-progress-indicator-spec.md @@ -0,0 +1,128 @@ +# Spec: Deterministic Sync Progress Indicator + +## Overview + +Replace the indefinite `CircularProgressIndicator` shown during initial bookmark and highlights sync with a `LinearProgressIndicator` displayed at the bottom edge of the top app bar. For bookmark sync, the indicator is deterministic (actual fraction). For highlights sync, the indicator is indeterminate (continuous sweep) due to a lack of a pre-fetch total count from the API. + +--- + +## Background + +During first launch after authentication, the app runs two sequential syncs: + +1. **Bookmark sync** — fetches all bookmarks in pages of 50 via `performFullSync()` in `BookmarkRepositoryImpl` +2. **Highlights sync** — fetches all annotations in pages of 50 via `reconcileAllAnnotations()` in `HighlightsRepository` + +Currently both show a `CircularProgressIndicator`. This spec replaces both with a `LinearProgressIndicator` placed at the bottom of the top app bar, matching Material 3 convention. + +--- + +## API Header Availability + +### Bookmarks + +`performFullSync()` already reads `Total-Count`, `Total-Pages`, and `Current-Page` from the first page response headers (lines ~722–733 in `BookmarkRepositoryImpl.kt`). The total page count is available after the first round-trip (~150ms). A deterministic fraction (`pagesCompleted / totalPages`) is feasible from page 2 onward. + +### Highlights + +`reconcileAllAnnotations()` uses offset-based pagination and terminates when `page.size < HIGHLIGHTS_PAGE_SIZE`. It does not read any total-count headers. The Readeck server may return `Total-Count` on the `/bookmarks/annotations` endpoint — this has not been verified. Until verified against a live server, the highlights progress indicator must be **indeterminate**. + +> **Verification step (before Phase 2):** Add a temporary log of `response.headers()["Total-Count"]` in `reconcileAllAnnotations()` and confirm against a live Readeck server. If the header is present, Phase 2 can upgrade to a deterministic fraction. + +--- + +## Design + +### Placement + +A `LinearProgressIndicator` spanning full width, immediately below the `TopAppBar`. `TopAppBar` has no built-in progress slot; the standard approach is a `Column` wrapping the bar area: + +```kotlin +Column { + TopAppBar(title = { ... }, ...) + if (showProgress) { + LinearProgressIndicator( + progress = progressFraction, // null → indeterminate + modifier = Modifier.fillMaxWidth() + ) + } +} +``` + +Use `LinearProgressIndicator(modifier = ...)` (no `progress` lambda) for indeterminate mode; use `LinearProgressIndicator(progress = { fraction }, modifier = ...)` for determinate mode. + +The indicator is visible only while a sync is actively running. It disappears on completion or failure. There is no animation to hide/show it — it simply composes in and out. + +--- + +## Implementation Plan + +### Phase 1 — Bookmarks (deterministic) + +**1. Progress model** + +Add to `BookmarkRepositoryImpl` (or a shared sync state holder): + +```kotlin +sealed interface BookmarkSyncProgress { + data object Idle : BookmarkSyncProgress + data class Running(val page: Int, val totalPages: Int) : BookmarkSyncProgress + data object Done : BookmarkSyncProgress +} + +private val _syncProgress = MutableStateFlow(BookmarkSyncProgress.Idle) +val syncProgress: StateFlow = _syncProgress.asStateFlow() +``` + +Emit `Running(currentPage, totalPages)` after each page is stored inside `performFullSync()`. Emit `Idle` when complete or on failure. + +**2. ViewModel** + +Expose `syncProgress` from whichever ViewModel drives the loading indicator (confirm the observer chain — likely `BookmarkListViewModel`). Map `Running(page, total)` to a `Float` fraction: `page / total.toFloat()`. + +**3. UI** + +In the composable hosting the top app bar for the bookmark list screen, replace or supplement the existing `CircularProgressIndicator` with: + +```kotlin +val progress by viewModel.syncProgress.collectAsStateWithLifecycle() +Column { + TopAppBar(...) + when (val p = progress) { + is Running -> LinearProgressIndicator( + progress = { p.page / p.totalPages.toFloat() }, + modifier = Modifier.fillMaxWidth() + ) + else -> Unit + } +} +``` + +### Phase 2 — Highlights (indeterminate → potentially deterministic) + +**Before coding:** verify `Total-Count` header availability (see verification step above). + +**If header is absent (default assumption):** replace the existing `CircularProgressIndicator` with an indeterminate `LinearProgressIndicator` shown while `HighlightsSyncState.Running`. No fraction is needed. + +**If header is present:** mirror the Phase 1 pattern using `loadedCount / totalCount.toFloat()` as the fraction. `HighlightsSyncState.Running` already carries `loadedCount`; add `totalCount: Int` to it. + +--- + +## Out of Scope + +- Progress persistence across process death +- Cancellation UI +- Per-bookmark content download progress (multipart sync) +- Tablet / adaptive layout adjustments + +--- + +## Files Likely Touched + +| File | Change | +|---|---| +| `BookmarkRepositoryImpl.kt` | Emit `BookmarkSyncProgress` state from `performFullSync()` | +| `BookmarkListViewModel.kt` | Expose `syncProgress` flow (or relay from repository) | +| `BookmarkListScreen.kt` | Replace `CircularProgressIndicator` with `LinearProgressIndicator` | +| `HighlightsRepository.kt` | Replace `CircularProgressIndicator` trigger; possibly add total-count header read | +| Highlights screen/ViewModel | Wire indeterminate (or deterministic) linear indicator | From bd064d766423b91ecaef0ec90a21fe42ac127c54 Mon Sep 17 00:00:00 2001 From: Nate Eaton Date: Wed, 20 May 2026 13:50:54 -0500 Subject: [PATCH 2/3] ci: pin JDK to 17.0.14 and capture JVM crash logs Intermittent C2 JIT crashes (Node::uncast SIGSEGV) were occurring on the verify job. Pin the Temurin patch version for deterministic builds and upload hs_err/replay/core files as artifacts on failure to enable diagnosis if the crash recurs. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/checks.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index b8871f1a..66545193 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -37,7 +37,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: temurin - java-version: 17 + java-version: '17.0.14' - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 @@ -56,3 +56,15 @@ jobs: - name: Run Tests run: ./gradlew :app:testDebugUnitTestAll + + - name: Upload JVM crash logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: jvm-crash-logs + path: | + **/hs_err_pid*.log + **/replay_pid*.log + **/core.* + if-no-files-found: ignore + retention-days: 14 From 4c871bdd21f3119bb1b6b278570d4a399cbd3fdf Mon Sep 17 00:00:00 2001 From: Nate Eaton Date: Wed, 20 May 2026 14:13:16 -0500 Subject: [PATCH 3/3] Fix sync progress review issues and pin CI JDK - Show bookmark sync progress in the list UI only during initial loading, keeping background and user-triggered periodic full syncs visually quiet. - Reset highlights user-refresh tracking when a retry is skipped or fails, preventing later automatic refreshes from being mislabeled as user refreshes. - Add regression coverage for skipped highlights retry followed by automatic refresh. - Stub `BookmarkRepository.syncProgress` in `BookmarkListViewModelTest` to fix MockK unit test failures. - Pin the quality checks workflow to Temurin JDK 17.0.13 to avoid the GitHub Actions JVM crash seen with 17.0.14. Verification: - `./gradlew :app:assembleDebugAll` - `./gradlew :app:testDebugUnitTestAll` - `./gradlew :app:lintDebugAll` --- .github/workflows/checks.yml | 2 +- .../app/ui/highlights/HighlightsViewModel.kt | 10 ++++++++++ .../mydeck/app/ui/list/BookmarkListScreen.kt | 2 +- .../ui/highlights/HighlightsViewModelTest.kt | 17 +++++++++++++++++ .../app/ui/list/BookmarkListViewModelTest.kt | 1 + 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 66545193..65da6f74 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -37,7 +37,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: temurin - java-version: '17.0.14' + java-version: '17.0.13' - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 diff --git a/app/src/main/java/com/mydeck/app/ui/highlights/HighlightsViewModel.kt b/app/src/main/java/com/mydeck/app/ui/highlights/HighlightsViewModel.kt index d0252b0c..f468a757 100644 --- a/app/src/main/java/com/mydeck/app/ui/highlights/HighlightsViewModel.kt +++ b/app/src/main/java/com/mydeck/app/ui/highlights/HighlightsViewModel.kt @@ -9,6 +9,7 @@ import com.mydeck.app.domain.model.BookmarkHighlightGroup import com.mydeck.app.domain.model.HighlightSummary import com.mydeck.app.domain.model.HighlightsSyncMetadata import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -189,8 +190,17 @@ class HighlightsViewModel @Inject constructor( highlightsRepository.requestRefresh(reason) .onSuccess { Timber.d("Highlights VM refresh request completed: reason=%s result=success", reason) + if (reason == HighlightsRefreshReason.USER_RETRY) { + delay(100) + if (highlightsRepository.observeSyncState().value !is HighlightsSyncState.Running) { + _userRequestedRefresh.value = false + } + } } .onFailure { error -> + if (reason == HighlightsRefreshReason.USER_RETRY) { + _userRequestedRefresh.value = false + } Timber.w(error, "Highlights VM refresh request completed: reason=%s result=failure", reason) } } diff --git a/app/src/main/java/com/mydeck/app/ui/list/BookmarkListScreen.kt b/app/src/main/java/com/mydeck/app/ui/list/BookmarkListScreen.kt index 36a83bac..a1b94d80 100644 --- a/app/src/main/java/com/mydeck/app/ui/list/BookmarkListScreen.kt +++ b/app/src/main/java/com/mydeck/app/ui/list/BookmarkListScreen.kt @@ -657,7 +657,7 @@ fun BookmarkListScreen( } ) val fraction = syncFraction - if (fraction != null) { + if (isInitialLoading && fraction != null) { LinearProgressIndicator( progress = { fraction }, modifier = Modifier.fillMaxWidth() diff --git a/app/src/test/java/com/mydeck/app/ui/highlights/HighlightsViewModelTest.kt b/app/src/test/java/com/mydeck/app/ui/highlights/HighlightsViewModelTest.kt index 5ab3bf6b..a696f681 100644 --- a/app/src/test/java/com/mydeck/app/ui/highlights/HighlightsViewModelTest.kt +++ b/app/src/test/java/com/mydeck/app/ui/highlights/HighlightsViewModelTest.kt @@ -405,6 +405,23 @@ class HighlightsViewModelTest { assertFalse(state.isInitialLocalLoad) } + @Test + fun `skipped user retry does not mark later automatic refresh as user refresh`() = runTest { + repository.highlights.value = listOf(highlight("h1")) + + val viewModel = HighlightsViewModel(repository) + advanceUntilIdle() + + viewModel.retry() + advanceUntilIdle() + repository.syncState.value = HighlightsSyncState.Running(loadedCount = 1) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state.isRefreshing) + assertFalse(state.isUserRefreshing) + } + private fun visibleHighlightIds(state: HighlightsUiState): List { return state.filteredGroups.flatMap { group -> group.highlights.map { it.id } } } diff --git a/app/src/test/java/com/mydeck/app/ui/list/BookmarkListViewModelTest.kt b/app/src/test/java/com/mydeck/app/ui/list/BookmarkListViewModelTest.kt index 34cbf90c..092ffc62 100644 --- a/app/src/test/java/com/mydeck/app/ui/list/BookmarkListViewModelTest.kt +++ b/app/src/test/java/com/mydeck/app/ui/list/BookmarkListViewModelTest.kt @@ -121,6 +121,7 @@ class BookmarkListViewModelTest { every { bookmarkRepository.observeAllBookmarkCounts() } returns flowOf(BookmarkCounts()) every { bookmarkRepository.observeAllLabelsWithCounts() } returns flowOf(emptyMap()) every { bookmarkRepository.observePendingActionCount() } returns flowOf(0) + every { bookmarkRepository.syncProgress } returns MutableStateFlow(BookmarkRepository.BookmarkSyncProgress.Idle) every { connectivityMonitor.observeConnectivity() } returns flowOf(true) every { connectivityMonitor.isNetworkAvailable() } returns true every { connectivityMonitor.isOnWifi() } returns true