feat: Move Sync feature to menu (#680)

## Summary

* **What is the goal of this PR?** 
Move the "Sync Progress" option from TOC (Chapter Selection) screen to
the Reader Menu, and fix use-after-free crashes related to callback
handling in activity lifecycle.

* **What changes are included?**
- Added "Sync Progress" as a menu item in `EpubReaderMenuActivity` (now
4 items: Go to Chapter, Sync Progress, Go Home, Delete Book Cache)
- Removed sync-related logic from `EpubReaderChapterSelectionActivity` -
TOC now only displays chapters
- Implemented `pendingGoHome` and `pendingSubactivityExit` flags in
`EpubReaderActivity` to safely handle activity destruction
- Fixed GO_HOME, DELETE_CACHE, and SYNC menu actions to use deferred
callbacks avoiding use-after-free

## Additional Context

* Root cause of crashes: callbacks like `onGoHome()` or `onCancel()`
invoked from activity handlers could destroy the current activity while
code was still executing, causing use-after-free and race conditions
with FreeRTOS display task.
* Solution: Deferred execution pattern - set flags and process them in
`loop()` after all nested activity loops have safely returned.
* Files changed: `EpubReaderMenuActivity.h`,
`EpubReaderActivity.h/.cpp`, `EpubReaderChapterSelectionActivity.h/.cpp`

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_

Co-authored-by: danoooob <danoooob@example.com>
Co-authored-by: Dave Allie <dave@daveallie.com>
This commit is contained in:
danoob 2026-02-05 22:04:38 +07:00 committed by GitHub
parent cb4d86fec6
commit fb0af32ec0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 88 additions and 91 deletions

View File

@ -9,6 +9,8 @@
#include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "EpubReaderPercentSelectionActivity.h"
#include "KOReaderCredentialStore.h"
#include "KOReaderSyncActivity.h"
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
@ -140,6 +142,45 @@ void EpubReaderActivity::loop() {
// Pass input responsibility to sub activity if exists
if (subActivity) {
subActivity->loop();
// Deferred exit: process after subActivity->loop() returns to avoid use-after-free
if (pendingSubactivityExit) {
pendingSubactivityExit = false;
exitActivity();
updateRequired = true;
skipNextButtonCheck = true; // Skip button processing to ignore stale events
}
// Deferred go home: process after subActivity->loop() returns to avoid race condition
if (pendingGoHome) {
pendingGoHome = false;
exitActivity();
if (onGoHome) {
onGoHome();
}
return; // Don't access 'this' after callback
}
return;
}
// Handle pending go home when no subactivity (e.g., from long press back)
if (pendingGoHome) {
pendingGoHome = false;
if (onGoHome) {
onGoHome();
}
return; // Don't access 'this' after callback
}
// Skip button processing after returning from subactivity
// This prevents stale button release events from triggering actions
// We wait until: (1) all relevant buttons are released, AND (2) wasReleased events have been cleared
if (skipNextButtonCheck) {
const bool confirmCleared = !mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
!mappedInput.wasReleased(MappedInputManager::Button::Confirm);
const bool backCleared = !mappedInput.isPressed(MappedInputManager::Button::Back) &&
!mappedInput.wasReleased(MappedInputManager::Button::Back);
if (confirmCleared && backCleared) {
skipNextButtonCheck = false;
}
return;
}
@ -387,11 +428,8 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
break;
}
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
// 2. Trigger the reader's "Go Home" callback
if (onGoHome) {
onGoHome();
}
// Defer go home to avoid race condition with display task
pendingGoHome = true;
break;
}
case EpubReaderMenuActivity::MenuAction::DELETE_CACHE: {
@ -412,10 +450,34 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
saveProgress(backupSpine, backupPage, backupPageCount);
}
exitActivity();
updateRequired = true;
xSemaphoreGive(renderingMutex);
if (onGoHome) onGoHome();
// Defer go home to avoid race condition with display task
pendingGoHome = true;
break;
}
case EpubReaderMenuActivity::MenuAction::SYNC: {
if (KOREADER_STORE.hasCredentials()) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
const int currentPage = section ? section->currentPage : 0;
const int totalPages = section ? section->pageCount : 0;
exitActivity();
enterNewActivity(new KOReaderSyncActivity(
renderer, mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages,
[this]() {
// On cancel - defer exit to avoid use-after-free
pendingSubactivityExit = true;
},
[this](int newSpineIndex, int newPage) {
// On sync complete - update position and defer exit
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
currentSpineIndex = newSpineIndex;
nextPageNumber = newPage;
section.reset();
}
pendingSubactivityExit = true;
}));
xSemaphoreGive(renderingMutex);
}
break;
}
}

View File

@ -24,6 +24,9 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
// Normalized 0.0-1.0 progress within the target spine item, computed from book percentage.
float pendingSpineProgress = 0.0f;
bool updateRequired = false;
bool pendingSubactivityExit = false; // Defer subactivity exit to avoid use-after-free
bool pendingGoHome = false; // Defer go home to avoid race condition with display task
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;

View File

@ -2,10 +2,6 @@
#include <GfxRenderer.h>
#include <algorithm>
#include "KOReaderCredentialStore.h"
#include "KOReaderSyncActivity.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
@ -15,25 +11,7 @@ namespace {
constexpr int SKIP_PAGE_MS = 700;
} // namespace
bool EpubReaderChapterSelectionActivity::hasSyncOption() const { return KOREADER_STORE.hasCredentials(); }
int EpubReaderChapterSelectionActivity::getTotalItems() const {
// Add 2 for sync options (top and bottom) if credentials are configured
const int syncCount = hasSyncOption() ? 2 : 0;
return epub->getTocItemsCount() + syncCount;
}
bool EpubReaderChapterSelectionActivity::isSyncItem(int index) const {
if (!hasSyncOption()) return false;
// First item and last item are sync options
return index == 0 || index == getTotalItems() - 1;
}
int EpubReaderChapterSelectionActivity::tocIndexFromItemIndex(int itemIndex) const {
// Account for the sync option at the top
const int offset = hasSyncOption() ? 1 : 0;
return itemIndex - offset;
}
int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub->getTocItemsCount(); }
int EpubReaderChapterSelectionActivity::getPageItems() const {
// Layout constants used in renderScreen
@ -65,13 +43,10 @@ void EpubReaderChapterSelectionActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
// Account for sync option offset when finding current TOC index
const int syncOffset = hasSyncOption() ? 1 : 0;
selectorIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
if (selectorIndex == -1) {
selectorIndex = 0;
}
selectorIndex += syncOffset; // Offset for top sync option
// Trigger first update
updateRequired = true;
@ -96,24 +71,6 @@ void EpubReaderChapterSelectionActivity::onExit() {
renderingMutex = nullptr;
}
void EpubReaderChapterSelectionActivity::launchSyncActivity() {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new KOReaderSyncActivity(
renderer, mappedInput, epub, epubPath, currentSpineIndex, currentPage, totalPagesInSpine,
[this]() {
// On cancel
exitActivity();
updateRequired = true;
},
[this](int newSpineIndex, int newPage) {
// On sync complete
exitActivity();
onSyncPosition(newSpineIndex, newPage);
}));
xSemaphoreGive(renderingMutex);
}
void EpubReaderChapterSelectionActivity::loop() {
if (subActivity) {
subActivity->loop();
@ -130,15 +87,7 @@ void EpubReaderChapterSelectionActivity::loop() {
const int totalItems = getTotalItems();
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Check if sync option is selected (first or last item)
if (isSyncItem(selectorIndex)) {
launchSyncActivity();
return;
}
// Get TOC index (account for top sync offset)
const int tocIndex = tocIndexFromItemIndex(selectorIndex);
const auto newSpineIndex = epub->getSpineIndexForTocIndex(tocIndex);
const auto newSpineIndex = epub->getSpineIndexForTocIndex(selectorIndex);
if (newSpineIndex == -1) {
onGoBack();
} else {
@ -209,20 +158,14 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const int displayY = 60 + contentY + i * 30;
const bool isSelected = (itemIndex == selectorIndex);
if (isSyncItem(itemIndex)) {
// Sync option uses a fixed label and stays aligned to the content margin.
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, ">> Sync Progress", !isSelected);
} else {
const int tocIndex = tocIndexFromItemIndex(itemIndex);
auto item = epub->getTocItem(tocIndex);
auto item = epub->getTocItem(itemIndex);
// Indent per TOC level while keeping content within the gutter-safe region.
const int indentSize = contentX + 20 + (item.level - 1) * 15;
const std::string chapterName =
renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), contentWidth - 40 - indentSize);
// Indent per TOC level while keeping content within the gutter-safe region.
const int indentSize = contentX + 20 + (item.level - 1) * 15;
const std::string chapterName =
renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), contentWidth - 40 - indentSize);
renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected);
}
renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected);
}
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");

View File

@ -26,22 +26,12 @@ class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity
// This adapts automatically when switching between portrait and landscape.
int getPageItems() const;
// Total items including sync options (top and bottom)
// Total TOC items count
int getTotalItems() const;
// Check if sync option is available (credentials configured)
bool hasSyncOption() const;
// Check if given item index is a sync option (first or last)
bool isSyncItem(int index) const;
// Convert item index to TOC index (accounting for top sync option offset)
int tocIndexFromItemIndex(int itemIndex) const;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();
void launchSyncActivity();
public:
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,

View File

@ -2,6 +2,7 @@
#include <GfxRenderer.h>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"

View File

@ -9,12 +9,11 @@
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "MappedInputManager.h"
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
public:
// Menu actions available from the reader menu.
enum class MenuAction { SELECT_CHAPTER, GO_TO_PERCENT, ROTATE_SCREEN, GO_HOME, DELETE_CACHE };
enum class MenuAction { SELECT_CHAPTER, GO_TO_PERCENT, ROTATE_SCREEN, GO_HOME, SYNC, DELETE_CACHE };
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const int currentPage, const int totalPages, const int bookProgressPercent,
@ -40,11 +39,10 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
};
// Fixed menu layout (order matters for up/down navigation).
const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, "Go to Chapter"},
{MenuAction::ROTATE_SCREEN, "Reading Orientation"},
{MenuAction::GO_TO_PERCENT, "Go to %"},
{MenuAction::GO_HOME, "Go Home"},
{MenuAction::DELETE_CACHE, "Delete Book Cache"}};
const std::vector<MenuItem> menuItems = {
{MenuAction::SELECT_CHAPTER, "Go to Chapter"}, {MenuAction::ROTATE_SCREEN, "Reading Orientation"},
{MenuAction::GO_TO_PERCENT, "Go to %"}, {MenuAction::GO_HOME, "Go Home"},
{MenuAction::SYNC, "Sync Progress"}, {MenuAction::DELETE_CACHE, "Delete Book Cache"}};
int selectedIndex = 0;
bool updateRequired = false;