26 KiB
Activity & ActivityManager Migration Guide
This document explains the refactoring from the original per-activity render task model to the centralized ActivityManager introduced in PR #1016. It covers the architectural differences, what changed for activity authors, and the FreeRTOS task and locking model that underpins the system.
Overview of Changes
| Aspect | Old Model | New Model |
|---|---|---|
| Render task | One per activity (8KB stack each) | Single shared task in ActivityManager |
| Render mutex | Per-activity renderingMutex |
Single global mutex in ActivityManager |
RenderLock |
Inner class of Activity |
Standalone class, acquires global mutex |
| Subactivities | ActivityWithSubactivity base class |
Activity stack managed by ActivityManager |
| Navigation | Free functions in main.cpp |
activityManager.goHome(), goToReader(), etc. |
| Forward flow | Parent stays on stack (pushActivity) |
Parent destroyed on forward flow (replaceWith* + ReturnHint) |
| Subactivity results | Callback lambdas stored in parent | startActivityForResult() / setResult() / finish() |
requestUpdate() |
Notifies activity's own render task | Delegates to ActivityManager (immediate or deferred) |
Architecture
Old Model: Per-Activity Render Tasks
Each activity created its own FreeRTOS render task on entry and destroyed it on exit:
┌─────────────────────────────────────────────────────────┐
│ Main Task (Arduino loop) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ currentActivity->loop() │ │
│ │ ├── handle input │ │
│ │ ├── update state (under RenderLock) │ │
│ │ └── requestUpdate() ──notify──► Render Task │ │
│ │ (per-activity)│ │
│ │ 8KB stack │ │
│ │ owns mutex │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ActivityWithSubactivity: │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Parent │────►│ SubActivity │ │
│ │ (has render │ │ (has own │ │
│ │ task) │ │ render task) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
Problems with this approach:
- 8KB per render task: Each activity allocated an 8KB FreeRTOS stack for its render task, even though only one renders at a time
- Dangerous deletion patterns:
exitActivity()+enterNewActivity()in callbacks led todelete thissituations where the caller was destroyed while its code was still on the stack - Subactivity coupling: Parents stored callbacks to child results, creating tight coupling and lifetime hazards
New Model: Centralized ActivityManager
A single ActivityManager owns the render task and manages an activity stack:
┌──────────────────────────────────────────────────────────┐
│ Main Task (Arduino loop) │
│ │
│ activityManager.loop() │
│ │ │
│ ├── currentActivity->loop() │
│ │ ├── handle input │
│ │ ├── update state (under RenderLock) │
│ │ └── requestUpdate() │
│ │ │
│ ├── process pending actions (Push / Pop / Replace) │
│ │ │
│ └── if requestedUpdate: ──notify──► Render Task │
│ (single, shared) │
│ 8KB stack │
│ global mutex │
│ │
│ Activity Stack: │
│ ┌──────────┬──────────┬──────────┐ ┌──────────┐ │
│ │ Home │ Settings │ Wifi │ │ Keyboard │ │
│ │ (stack) │ (stack) │ (stack) │ │ (current)│ │
│ └──────────┴──────────┴──────────┘ └──────────┘ │
│ stackActivities[] currentActivity │
└──────────────────────────────────────────────────────────┘
Migration Checklist
1. Change Base Class
If your activity extended ActivityWithSubactivity, change it to extend Activity:
// BEFORE
class MyActivity final : public ActivityWithSubactivity {
MyActivity(GfxRenderer& r, MappedInputManager& m, std::function<void()> goBack)
: ActivityWithSubactivity("MyActivity", r, m), goBack(goBack) {}
};
// AFTER
class MyActivity final : public Activity {
MyActivity(GfxRenderer& r, MappedInputManager& m)
: Activity("MyActivity", r, m) {}
};
Note that navigation callbacks like goBack are no longer stored — use finish(), onGoHome(), or a direct activityManager.goHome() / goTo*() / replaceWith*() call instead.
2. Replace Navigation Functions
The free functions exitActivity() / enterNewActivity() in main.cpp are gone. Use ActivityManager methods:
// BEFORE (in main.cpp or via stored callbacks)
exitActivity();
enterNewActivity(new SettingsActivity(renderer, mappedInput, onGoHome));
// AFTER (from any Activity method)
activityManager.goToSettings();
// or for arbitrary navigation:
activityManager.replaceActivity(std::make_unique<MyActivity>(renderer, mappedInput));
replaceActivity() destroys the current activity and clears the stack. Use it for top-level navigation (home, reader, settings, etc.). See the "Navigation Flow" section after the checklist for when to use replace vs push, and how the ReturnHint mechanism restores a parent's prior state without keeping it resident.
3. Replace Subactivity Pattern
The enterNewActivity() / exitActivity() subactivity pattern is replaced by a stack with typed results:
// BEFORE
void MyActivity::launchWifi() {
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](bool connected) { onWifiDone(connected); }));
}
// Child calls: onComplete(true); // triggers callback, which may call exitActivity()
// AFTER
void MyActivity::launchWifi() {
startActivityForResult(
std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
[this](const ActivityResult& result) {
if (result.isCancelled) return;
auto& wifi = std::get<WifiResult>(result.data);
onWifiDone(wifi.connected);
});
}
// Child calls:
// setResult(WifiResult{.connected = true, .ssid = ssid});
// finish();
Key differences:
startActivityForResult()pushes the current activity onto the stack and launches the childsetResult()stores a typed result on the child activityfinish()signals the manager to pop the child, call the result handler, and resume the parent- The parent is never deleted during this process — it's safely stored on the stack
4. Update render() Signature
The RenderLock type changed from Activity::RenderLock (inner class) to standalone RenderLock:
// BEFORE
void render(Activity::RenderLock&&) override;
// AFTER
void render(RenderLock&&) override;
Include RenderLock.h if not transitively included via Activity.h.
5. Update onEnter() / onExit()
Activities no longer create or destroy render tasks:
// BEFORE
void MyActivity::onEnter() {
Activity::onEnter(); // created render task + logged
// ... allocate resources
requestUpdate();
}
void MyActivity::onExit() {
// ... free resources
Activity::onExit(); // acquired RenderLock, deleted render task
}
// AFTER
void MyActivity::onEnter() {
Activity::onEnter(); // just logs
// ... allocate resources
requestUpdate();
}
void MyActivity::onExit() {
// ... free resources
Activity::onExit(); // just logs
}
The render task lifecycle is handled entirely by ActivityManager::begin().
6. Update requestUpdate() Calls
The signature changed to accept an immediate flag:
// BEFORE
void requestUpdate(); // always immediate notification to per-activity render task
// AFTER
void requestUpdate(bool immediate = false);
// immediate=false (default): deferred until end of current loop iteration
// immediate=true: sends notification to render task right away
When to use immediate: Almost never. Deferred updates are batched — if loop() triggers multiple state changes that each call requestUpdate(), only one render happens. Use immediate only when you need the render to start before the current function returns (e.g., before a blocking network call).
requestUpdateAndWait(): Blocks the calling task until the render completes. Use sparingly — it's designed for cases where you need the screen to reflect new state before proceeding (e.g., showing "Checking for update..." before calling a network API).
7. Remove Stored Navigation Callbacks
Old activities often stored std::function callbacks for navigation:
// BEFORE
class SettingsActivity : public ActivityWithSubactivity {
const std::function<void()> goBack; // stored callback
const std::function<void()> goHome; // stored callback
public:
SettingsActivity(GfxRenderer& r, MappedInputManager& m,
std::function<void()> goBack, std::function<void()> goHome)
: ActivityWithSubactivity("Settings", r, m), goBack(goBack), goHome(goHome) {}
};
// AFTER
class SettingsActivity : public Activity {
public:
SettingsActivity(GfxRenderer& r, MappedInputManager& m)
: Activity("Settings", r, m) {}
// Use finish() to go back (pops the stack, or returns via ReturnHint if empty),
// onGoHome() to exit the flow ("up and out"), or activityManager.goHome() for a hard reset.
};
This removes std::function overhead (~2-4KB per unique signature) and eliminates lifetime risks from captured this pointers.
Navigation Flow
Push vs Replace: Memory Matters
On an ESP32-C3, heap fragmentation is a real constraint — especially when the next activity is heavy (EPUB reader, TLS sync). There are two ways to launch a new activity, and the choice matters:
| Call | Current activity | Stack | When to use |
|---|---|---|---|
replaceActivity() / goTo*() / replaceWith*() |
destroyed (onExit + delete) | cleared | Forward flow: the caller has no reason to stay resident |
pushActivity() / startActivityForResult() |
kept alive on stack | parent preserved | Modal result flow: the caller needs to resume with a result |
Default to replace. Push is only correct when you need to deliver a result back to a still-living parent (keyboard entry, confirmation dialog, chapter picker, etc.). For plain one-way transitions — opening a book, going to settings, switching tabs — use replace so the parent's memory is freed before the next activity runs.
The ReturnHint Pattern
Replace destroys the parent, but the user still expects Back to return "where they came from" — not always Home. To bridge that, ActivityManager holds a small ReturnHint:
enum class ReturnTo : uint8_t { Home, FileBrowser, RecentBooks };
struct ReturnHint {
ReturnTo target = ReturnTo::Home;
std::string path; // FileBrowser directory to restore
std::string selectName; // item to re-focus (file name, book path)
int selectIndex = -1; // combined-list index (Home selector, Recents row)
};
A parent records a hint before launching a forward flow. When the launched activity (or anything it chains to) eventually exits with an empty stack, ActivityManager::returnFromChild() consumes the hint and routes to the correct parent — restoring its prior selection. If no hint is set, it falls back to goHome().
Two ways to set a hint:
1. Dedicated wrappers — for the common book-open paths:
// FileBrowserActivity::onFileOpen
ReturnHint hint;
hint.target = ReturnTo::FileBrowser;
hint.path = basepath; // "/books/fiction"
hint.selectName = entry; // "war_and_peace.epub"
activityManager.replaceWithReader(fullPath, std::move(hint));
// RecentBooksActivity::onSelect
ReturnHint hint;
hint.target = ReturnTo::RecentBooks;
hint.selectIndex = selectorIndex;
activityManager.replaceWithReader(path, std::move(hint));
2. setReturnHint() + any goTo*() — for arbitrary transitions where a dedicated wrapper would be overkill:
// HomeActivity::dispatchMenuAction
ReturnHint hint;
hint.target = ReturnTo::Home;
hint.selectIndex = selectorIndex; // restore focus on the same menu entry
activityManager.setReturnHint(std::move(hint));
activityManager.goToSettings(); // parent destroyed; hint survives the round trip
goTo*() helpers do not clear the hint — only goHome() (explicit hard-reset) and the replaceWith*() helpers (which overwrite it with their own hint) do. This lets a hint survive chained transitions: Home → Reader → KOReaderSync → Reader → back to Home, hint intact.
How finish() interacts with the hint:
- Non-empty stack:
finish()pops to the parent on the stack (classic modal result flow). Hint is untouched. - Empty stack:
finish()falls through toreturnFromChild()automatically. An activity launched via areplaceWith*()helper has no stack — so its Back-buttonfinish()naturally routes via the hint.
onGoHome() is now semantically "up and out" — it calls returnFromChild(), so long-press Back in a reader returns to whichever view opened the book, not always Home. For an explicit hard-reset, call activityManager.goHome() directly.
Technical Details
FreeRTOS Task Model
The firmware runs on an ESP32-C3, a single-core RISC-V microcontroller. FreeRTOS provides cooperative and preemptive multitasking on this single core — only one task executes at any moment, and the scheduler switches between tasks at yield points (blocking calls, vTaskDelay, taskYIELD) or when a tick interrupt promotes a higher-priority task.
There are two tasks relevant to the activity system:
┌──────────────────────┐ ┌──────────────────────────┐
│ Main Task │ │ Render Task │
│ (Arduino loop) │ │ (ActivityManager-owned) │
│ Priority: 1 │ │ Priority: 1 │
│ │ │ │
│ Runs: │ │ Runs: │
│ - gpio.update() │ │ - ulTaskNotifyTake() │
│ - activity->loop() │ │ (blocks until notified)│
│ - pending actions │ │ - RenderLock (mutex) │
│ - sleep/power mgmt │ │ - activity->render() │
│ - requestUpdate →────┼─────┼─► xTaskNotify() │
│ (end of loop) │ │ │
└──────────────────────┘ └──────────────────────────┘
Both tasks run at priority 1. Since the ESP32-C3 is single-core, they alternate execution: the main task runs loop(), then at the end of the loop iteration, notifies the render task if an update was requested. The render task wakes, acquires the mutex, calls render(), releases the mutex, and blocks again.
Do not use xTaskCreate inside activities. If you have a use case that seems to require a background task, open a discussion to propose a lifecycle-aware Worker abstraction first.
The Render Mutex and RenderLock
A single FreeRTOS mutex (renderingMutex) protects shared state between loop() and render(). Since these run on different tasks, any state read by render() and written by loop() must be guarded.
RenderLock is an RAII wrapper:
// Standalone class (not tied to any specific activity)
class RenderLock {
bool isLocked = false;
public:
explicit RenderLock(); // acquires activityManager.renderingMutex
explicit RenderLock(Activity&); // same — Activity& param kept for compatibility
~RenderLock(); // releases mutex if still held
void unlock(); // early release
};
Usage patterns:
// In loop(): protect state mutations that render() reads
void MyActivity::loop() {
if (somethingChanged) {
RenderLock lock;
state = newState; // safe — render() can't run while lock is held
}
requestUpdate(); // trigger render after lock is released
}
// In render(): lock is passed in, held for duration of render
void MyActivity::render(RenderLock&&) {
// Lock is held — safe to read shared state
renderer.clearScreen();
renderer.drawText(..., stateString, ...);
renderer.displayBuffer();
// Lock released when RenderLock destructor runs
}
Critical rule: Never call requestUpdateAndWait() while holding a RenderLock. The render task needs the mutex to call render(), so holding it while waiting for the render to complete is a deadlock:
Main Task Render Task
────────── ───────────
RenderLock lock; (blocked on mutex)
requestUpdateAndWait();
→ notify render task
→ block waiting for
render to complete → wakes up
→ tries to acquire mutex
→ DEADLOCK: main holds mutex,
waits for render; render
waits for mutex
requestUpdate() vs requestUpdateAndWait()
requestUpdate(false) requestUpdate(true)
───────────────── ─────────────────
Sets flag only. Notifies render task
Render happens after immediately.
loop() returns and Render may start
ActivityManager checks before the calling
the flag. function returns.
(Does NOT wait for
render to complete.)
requestUpdateAndWait()
──────────────────────
Notifies render task AND
blocks calling task until
render is done. Uses
FreeRTOS direct-to-task
notification on the
caller's task handle.
requestUpdateAndWait() flow in detail:
Calling Task Render Task
──────────── ───────────
requestUpdateAndWait()
├─ assert: not render task
├─ assert: not holding RenderLock
├─ store waitingTaskHandle
├─ xTaskNotify(renderTask) → wakes render task
└─ ulTaskNotifyTake() ─┐
(blocked) │ RenderLock lock;
│ activity->render();
│ // render complete
│ taskENTER_CRITICAL
│ waiter = waitingTaskHandle
│ waitingTaskHandle = nullptr
│ taskEXIT_CRITICAL
│ xTaskNotify(waiter) ───┐
│ │
┌──────────────────────┘ │
│ (woken by notification) ◄────────────────────────┘
└─ return
Activity Lifecycle Under ActivityManager
activityManager.replaceActivity(make_unique<MyActivity>(...))
│
▼
╔═══════════════════════════════════════════════════╗
║ pendingAction = Replace ║
║ pendingActivity = MyActivity ║
╚═══════════════════════════════════════════════════╝
│
▼ (next loop iteration)
ActivityManager::loop()
│
├── currentActivity->loop() // old activity's last loop
│
├── process pending action:
│ ├── RenderLock lock;
│ ├── oldActivity->onExit() // cleanup under lock
│ ├── delete oldActivity
│ ├── clear stack
│ ├── currentActivity = MyActivity
│ ├── lock.unlock()
│ └── MyActivity->onEnter() // init new activity
│
└── if requestedUpdate:
└── notify render task
For push/pop (subactivity) navigation:
Parent calls: startActivityForResult(make_unique<Child>(...), handler)
│
▼
╔══════════════════════════════════════╗
║ pendingAction = Push ║
║ pendingActivity = Child ║
║ parent->resultHandler = handler ║
╚══════════════════════════════════════╝
│
▼ (next loop iteration)
├── Parent moved to stackActivities[]
├── currentActivity = Child
└── Child->onEnter()
... child runs ...
Child calls: setResult(MyResult{...}); finish();
│
▼
╔══════════════════════════════════════╗
║ pendingAction = Pop ║
║ child->result = MyResult{...} ║
╚══════════════════════════════════════╝
│
▼ (next loop iteration)
├── result = child->result
├── Child->onExit(); delete Child
├── currentActivity = Parent (popped from stack)
├── Parent->resultHandler(result)
└── requestUpdate() // automatic re-render for parent
Common Pitfalls
Calling finish() and continuing to access this: finish() sets pendingAction = Pop but does not immediately destroy the activity. The activity is destroyed on the next ActivityManager::loop() iteration. It's safe to access member variables after finish() within the same function, but don't rely on the activity surviving past the current loop() call.
Modifying shared state without RenderLock: If render() reads a variable and loop() writes it, the write must be under a RenderLock. Without it, render() could see a half-written value (e.g., a partially updated string or struct).
Creating background tasks that outlive the activity: Any FreeRTOS task created in onEnter() must be deleted in onExit() before the activity is destroyed. The ActivityManager does not track or clean up background tasks.
Using push for forward navigation: pushActivity() / startActivityForResult() keeps the parent alive on the stack. For a heavy child (EPUB reader, TLS sync) on a fragmented heap, the parent's resident allocations can be the difference between a successful launch and OOM. Only push when you need the parent to receive a result — otherwise use replaceActivity() / goTo*() / replaceWith*() and let the parent be freed first. If you do need "back to where I came from" semantics, record a ReturnHint before the replace instead of pushing.
Stale ReturnHint: A hint set by one activity persists until either returnFromChild() / goHome() clears it, or a replaceWith*() helper overwrites it. If you record a hint but the flow aborts down an unusual path (error screen, boot transition), the next unrelated finish() could consume it. Prefer setting the hint immediately before the transition, and call activityManager.clearReturnHint() if you abort the flow without launching the intended target.
Holding RenderLock across blocking calls: The render task is blocked on the mutex while you hold the lock. Keep critical sections short — acquire, mutate state, release, then do blocking work.
// WRONG — blocks render for the entire network call
void MyActivity::doNetworkStuff() {
RenderLock lock;
state = LOADING;
auto result = http.get(url); // blocks for seconds with lock held
state = DONE;
}
// CORRECT — release lock before blocking
void MyActivity::doNetworkStuff() {
{
RenderLock lock;
state = LOADING;
}
requestUpdate(true); // render "Loading..." immediately, before we block
auto result = http.get(url); // lock is not held
{
RenderLock lock;
state = DONE;
}
requestUpdate();
}