## Summary - **SleepActivity.cpp**: Add missing `file.close()` calls in 3 code paths that open BMP files for sleep screen rendering but never close them before returning. Affects random custom sleep images, the `/sleep.bmp` fallback, and book cover sleep screens. - **CrossPointWebServer.cpp**: Add missing `dir.close()` in the delete handler when `Storage.open()` returns a valid `FsFile` that is not a directory. ## Context SdFat is configured with `DESTRUCTOR_CLOSES_FILE=0`, which means `FsFile` objects are **not** automatically closed when they go out of scope. Every opened file must be explicitly closed. The SleepActivity leaks are particularly impactful because they occur on every sleep cycle. While ESP32 deep sleep clears RAM on wake, these leaks can still affect the current session if sleep screen rendering is triggered multiple times (e.g., cover preview, or if deep sleep fails to engage). The web server leak in `handleDelete()` is a minor edge case (directory path that opens successfully but `isDirectory()` returns false), but it's still worth fixing for correctness. ## Test plan - [x] Verify sleep screen still renders correctly (custom BMP, fallback, cover modes) - [x] Verify folder deletion still works via the web UI - [ ] Monitor free heap before/after sleep screen rendering to confirm no leak 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Jan Bažant <janbazant@Jan--Mac-mini.local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Dave Allie <dave@daveallie.com>
287 lines
9.9 KiB
C++
287 lines
9.9 KiB
C++
#include "SleepActivity.h"
|
|
|
|
#include <Epub.h>
|
|
#include <GfxRenderer.h>
|
|
#include <HalStorage.h>
|
|
#include <I18n.h>
|
|
#include <Txt.h>
|
|
#include <Xtc.h>
|
|
|
|
#include "CrossPointSettings.h"
|
|
#include "CrossPointState.h"
|
|
#include "components/UITheme.h"
|
|
#include "fontIds.h"
|
|
#include "images/Logo120.h"
|
|
#include "util/StringUtils.h"
|
|
|
|
void SleepActivity::onEnter() {
|
|
Activity::onEnter();
|
|
GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP));
|
|
|
|
switch (SETTINGS.sleepScreen) {
|
|
case (CrossPointSettings::SLEEP_SCREEN_MODE::BLANK):
|
|
return renderBlankSleepScreen();
|
|
case (CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM):
|
|
return renderCustomSleepScreen();
|
|
case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER):
|
|
case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER_CUSTOM):
|
|
return renderCoverSleepScreen();
|
|
default:
|
|
return renderDefaultSleepScreen();
|
|
}
|
|
}
|
|
|
|
void SleepActivity::renderCustomSleepScreen() const {
|
|
// Check if we have a /sleep directory
|
|
auto dir = Storage.open("/sleep");
|
|
if (dir && dir.isDirectory()) {
|
|
std::vector<std::string> files;
|
|
char name[500];
|
|
// collect all valid BMP files
|
|
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
|
|
if (file.isDirectory()) {
|
|
file.close();
|
|
continue;
|
|
}
|
|
file.getName(name, sizeof(name));
|
|
auto filename = std::string(name);
|
|
if (filename[0] == '.') {
|
|
file.close();
|
|
continue;
|
|
}
|
|
|
|
if (filename.substr(filename.length() - 4) != ".bmp") {
|
|
LOG_DBG("SLP", "Skipping non-.bmp file name: %s", name);
|
|
file.close();
|
|
continue;
|
|
}
|
|
Bitmap bitmap(file);
|
|
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
|
|
LOG_DBG("SLP", "Skipping invalid BMP file: %s", name);
|
|
file.close();
|
|
continue;
|
|
}
|
|
files.emplace_back(filename);
|
|
file.close();
|
|
}
|
|
const auto numFiles = files.size();
|
|
if (numFiles > 0) {
|
|
// Generate a random number between 1 and numFiles
|
|
auto randomFileIndex = random(numFiles);
|
|
// If we picked the same image as last time, reroll
|
|
while (numFiles > 1 && randomFileIndex == APP_STATE.lastSleepImage) {
|
|
randomFileIndex = random(numFiles);
|
|
}
|
|
APP_STATE.lastSleepImage = randomFileIndex;
|
|
APP_STATE.saveToFile();
|
|
const auto filename = "/sleep/" + files[randomFileIndex];
|
|
FsFile file;
|
|
if (Storage.openFileForRead("SLP", filename, file)) {
|
|
LOG_DBG("SLP", "Randomly loading: /sleep/%s", files[randomFileIndex].c_str());
|
|
delay(100);
|
|
Bitmap bitmap(file, true);
|
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
|
renderBitmapSleepScreen(bitmap);
|
|
file.close();
|
|
dir.close();
|
|
return;
|
|
}
|
|
file.close();
|
|
}
|
|
}
|
|
}
|
|
if (dir) dir.close();
|
|
|
|
// Look for sleep.bmp on the root of the sd card to determine if we should
|
|
// render a custom sleep screen instead of the default.
|
|
FsFile file;
|
|
if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) {
|
|
Bitmap bitmap(file, true);
|
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
|
LOG_DBG("SLP", "Loading: /sleep.bmp");
|
|
renderBitmapSleepScreen(bitmap);
|
|
file.close();
|
|
return;
|
|
}
|
|
file.close();
|
|
}
|
|
|
|
renderDefaultSleepScreen();
|
|
}
|
|
|
|
void SleepActivity::renderDefaultSleepScreen() const {
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
|
|
renderer.clearScreen();
|
|
renderer.drawImage(Logo120, (pageWidth - 120) / 2, (pageHeight - 120) / 2, 120, 120);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, tr(STR_CROSSPOINT), true, EpdFontFamily::BOLD);
|
|
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, tr(STR_SLEEPING));
|
|
|
|
// Make sleep screen dark unless light is selected in settings
|
|
if (SETTINGS.sleepScreen != CrossPointSettings::SLEEP_SCREEN_MODE::LIGHT) {
|
|
renderer.invertScreen();
|
|
}
|
|
|
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
|
}
|
|
|
|
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
|
int x, y;
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const auto pageHeight = renderer.getScreenHeight();
|
|
float cropX = 0, cropY = 0;
|
|
|
|
LOG_DBG("SLP", "bitmap %d x %d, screen %d x %d", bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight);
|
|
if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) {
|
|
// image will scale, make sure placement is right
|
|
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
|
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
|
|
|
|
LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio);
|
|
if (ratio > screenRatio) {
|
|
// image wider than viewport ratio, scaled down image needs to be centered vertically
|
|
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
|
cropX = 1.0f - (screenRatio / ratio);
|
|
LOG_DBG("SLP", "Cropping bitmap x: %f", cropX);
|
|
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
|
}
|
|
x = 0;
|
|
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
|
|
LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y);
|
|
} else {
|
|
// image taller than viewport ratio, scaled down image needs to be centered horizontally
|
|
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
|
cropY = 1.0f - (ratio / screenRatio);
|
|
LOG_DBG("SLP", "Cropping bitmap y: %f", cropY);
|
|
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
|
|
}
|
|
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
|
|
y = 0;
|
|
LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x);
|
|
}
|
|
} else {
|
|
// center the image
|
|
x = (pageWidth - bitmap.getWidth()) / 2;
|
|
y = (pageHeight - bitmap.getHeight()) / 2;
|
|
}
|
|
|
|
LOG_DBG("SLP", "drawing to %d x %d", x, y);
|
|
renderer.clearScreen();
|
|
|
|
const bool hasGreyscale = bitmap.hasGreyscale() &&
|
|
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
|
|
|
|
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
|
|
|
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
|
|
renderer.invertScreen();
|
|
}
|
|
|
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
|
|
|
if (hasGreyscale) {
|
|
bitmap.rewindToData();
|
|
renderer.clearScreen(0x00);
|
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
|
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
|
renderer.copyGrayscaleLsbBuffers();
|
|
|
|
bitmap.rewindToData();
|
|
renderer.clearScreen(0x00);
|
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
|
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
|
renderer.copyGrayscaleMsbBuffers();
|
|
|
|
renderer.displayGrayBuffer();
|
|
renderer.setRenderMode(GfxRenderer::BW);
|
|
}
|
|
}
|
|
|
|
void SleepActivity::renderCoverSleepScreen() const {
|
|
void (SleepActivity::*renderNoCoverSleepScreen)() const;
|
|
switch (SETTINGS.sleepScreen) {
|
|
case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER_CUSTOM):
|
|
renderNoCoverSleepScreen = &SleepActivity::renderCustomSleepScreen;
|
|
break;
|
|
default:
|
|
renderNoCoverSleepScreen = &SleepActivity::renderDefaultSleepScreen;
|
|
break;
|
|
}
|
|
|
|
if (APP_STATE.openEpubPath.empty()) {
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
std::string coverBmpPath;
|
|
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
|
|
|
|
// Check if the current book is XTC, TXT, or EPUB
|
|
if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") ||
|
|
StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) {
|
|
// Handle XTC file
|
|
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
|
|
if (!lastXtc.load()) {
|
|
LOG_ERR("SLP", "Failed to load last XTC");
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
if (!lastXtc.generateCoverBmp()) {
|
|
LOG_ERR("SLP", "Failed to generate XTC cover bmp");
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
coverBmpPath = lastXtc.getCoverBmpPath();
|
|
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
|
|
// Handle TXT file - looks for cover image in the same folder
|
|
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
|
|
if (!lastTxt.load()) {
|
|
LOG_ERR("SLP", "Failed to load last TXT");
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
if (!lastTxt.generateCoverBmp()) {
|
|
LOG_ERR("SLP", "No cover image found for TXT file");
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
coverBmpPath = lastTxt.getCoverBmpPath();
|
|
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
|
|
// Handle EPUB file
|
|
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
|
// Skip loading css since we only need metadata here
|
|
if (!lastEpub.load(true, true)) {
|
|
LOG_ERR("SLP", "Failed to load last epub");
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
if (!lastEpub.generateCoverBmp(cropped)) {
|
|
LOG_ERR("SLP", "Failed to generate cover bmp");
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
coverBmpPath = lastEpub.getCoverBmpPath(cropped);
|
|
} else {
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
FsFile file;
|
|
if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
|
|
Bitmap bitmap(file);
|
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
|
LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str());
|
|
renderBitmapSleepScreen(bitmap);
|
|
file.close();
|
|
return;
|
|
}
|
|
file.close();
|
|
}
|
|
|
|
return (this->*renderNoCoverSleepScreen)();
|
|
}
|
|
|
|
void SleepActivity::renderBlankSleepScreen() const {
|
|
renderer.clearScreen();
|
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
|
}
|