Skip to content

Commit 24672ff

Browse files
authored
Merge pull request #1 from sam-higton/feature/download-news
feat: add Download News home menu item
2 parents fdffc2e + 579fc00 commit 24672ff

9 files changed

Lines changed: 373 additions & 13 deletions

File tree

lib/I18n/translations/english.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ STR_RESTARTING_HINT: "Restarting... If device does not restart, hold the power b
191191
STR_NO_ENTRIES: "No entries found"
192192
STR_DOWNLOADING: "Downloading..."
193193
STR_DOWNLOAD_FAILED: "Download failed"
194+
STR_DOWNLOAD_NEWS: "Download News"
195+
STR_NEWS_DOWNLOADED: "News downloaded"
194196
STR_ERROR_MSG: "Error:"
195197
STR_UNNAMED: "Unnamed"
196198
STR_HOLD_OPEN_TO_DELETE: "Hold Open to Delete"

src/activities/ActivityManager.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ void ActivityManager::goHome(HomeMenuItem initialMenuItem) {
221221
initialMenuItem = HomeMenuItem::OPDS_BROWSER;
222222
} else if (activityName == "CrossPointWebServer") {
223223
initialMenuItem = HomeMenuItem::FILE_TRANSFER;
224+
} else if (activityName == "NewsDownload") {
225+
initialMenuItem = HomeMenuItem::DOWNLOAD_NEWS;
224226
} else if (activityName == "Settings") {
225227
initialMenuItem = HomeMenuItem::SETTINGS_MENU;
226228
}

src/activities/ActivityManager.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
class Activity; // forward declaration
1818
class RenderLock; // forward declaration
1919

20-
enum class HomeMenuItem { NONE, FILE_BROWSER, RECENTS, OPDS_BROWSER, FILE_TRANSFER, SETTINGS_MENU };
20+
enum class HomeMenuItem { NONE, FILE_BROWSER, RECENTS, OPDS_BROWSER, FILE_TRANSFER, DOWNLOAD_NEWS, SETTINGS_MENU };
2121

2222
/**
2323
* ActivityManager

src/activities/home/HomeActivity.cpp

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@
1717
#include "MappedInputManager.h"
1818
#include "OpdsServerStore.h"
1919
#include "RecentBooksStore.h"
20+
#include "activities/network/NewsDownloadActivity.h"
2021
#include "components/UITheme.h"
2122
#include "fontIds.h"
2223

2324
int HomeActivity::getMenuItemCount() const {
24-
int count = 4; // File Browser, Recents, File transfer, Settings
25+
int count = 5; // File Browser, Recents, File transfer, Download News, Settings
2526
if (!recentBooks.empty()) {
2627
count += recentBooks.size();
2728
}
@@ -208,6 +209,9 @@ void HomeActivity::loop() {
208209
case HomeMenuItem::FILE_TRANSFER:
209210
onFileTransferOpen();
210211
break;
212+
case HomeMenuItem::DOWNLOAD_NEWS:
213+
onDownloadNewsOpen();
214+
break;
211215
case HomeMenuItem::SETTINGS_MENU:
212216
onSettingsOpen();
213217
break;
@@ -243,8 +247,8 @@ void HomeActivity::render(RenderLock&&) {
243247

244248
// Build menu items dynamically
245249
std::vector<const char*> menuItems = {tr(STR_BROWSE_FILES), tr(STR_MENU_RECENT_BOOKS), tr(STR_FILE_TRANSFER),
246-
tr(STR_SETTINGS_TITLE)};
247-
std::vector<UIIcon> menuIcons = {Folder, Recent, Transfer, Settings};
250+
tr(STR_DOWNLOAD_NEWS), tr(STR_SETTINGS_TITLE)};
251+
std::vector<UIIcon> menuIcons = {Folder, Recent, Transfer, Wifi, Settings};
248252

249253
if (hasOpdsServers) {
250254
menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER));
@@ -292,4 +296,8 @@ void HomeActivity::onSettingsOpen() { activityManager.goToSettings(); }
292296

293297
void HomeActivity::onFileTransferOpen() { activityManager.goToFileTransfer(); }
294298

299+
void HomeActivity::onDownloadNewsOpen() {
300+
startActivityForResult(std::make_unique<NewsDownloadActivity>(renderer, mappedInput), [](const ActivityResult&) {});
301+
}
302+
295303
void HomeActivity::onOpdsBrowserOpen() { activityManager.goToBrowser(); }

src/activities/home/HomeActivity.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ class HomeActivity final : public Activity {
4444
if (hasOpdsUrl) ++i;
4545
if (item == HomeMenuItem::FILE_TRANSFER) return i;
4646
++i;
47+
if (item == HomeMenuItem::DOWNLOAD_NEWS) return i;
48+
++i;
4749
if (item == HomeMenuItem::SETTINGS_MENU) return i;
4850
return 0;
4951
}
@@ -55,6 +57,7 @@ class HomeActivity final : public Activity {
5557
if (idx == i++) return HomeMenuItem::RECENTS;
5658
if (hasOpdsUrl && idx == i++) return HomeMenuItem::OPDS_BROWSER;
5759
if (idx == i++) return HomeMenuItem::FILE_TRANSFER;
60+
if (idx == i++) return HomeMenuItem::DOWNLOAD_NEWS;
5861
if (idx == i) return HomeMenuItem::SETTINGS_MENU;
5962
return HomeMenuItem::NONE;
6063
}
@@ -63,6 +66,7 @@ class HomeActivity final : public Activity {
6366
void onRecentsOpen();
6467
void onSettingsOpen();
6568
void onFileTransferOpen();
69+
void onDownloadNewsOpen();
6670
void onOpdsBrowserOpen();
6771

6872
int getMenuItemCount() const;
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
#include "NewsDownloadActivity.h"
2+
3+
#include <ArduinoJson.h>
4+
#include <GfxRenderer.h>
5+
#include <HalStorage.h>
6+
#include <I18n.h>
7+
#include <Logging.h>
8+
#include <WiFi.h>
9+
10+
#include <cstring>
11+
#include <memory>
12+
13+
#include "MappedInputManager.h"
14+
#include "SilentRestart.h"
15+
#include "activities/network/WifiSelectionActivity.h"
16+
#include "components/UITheme.h"
17+
#include "fontIds.h"
18+
#include "network/HttpDownloader.h"
19+
20+
namespace {
21+
// SD-card config file with the GitHub repo URL and access token, e.g.:
22+
// { "repoUrl": "https://github.com/user/news-repo",
23+
// "token": "github_pat_...",
24+
// "path": "daily.epub" } // optional, defaults to daily.epub
25+
constexpr const char* NEWS_CONFIG_PATH = "/news_reader.json";
26+
constexpr const char* NEWS_DIR = "/_news";
27+
constexpr const char* NEWS_EPUB_PATH = "/_news/daily.epub";
28+
// Downloaded to a temp name first so a failed transfer never destroys the
29+
// previous edition; renamed over daily.epub only after the download succeeds.
30+
constexpr const char* NEWS_EPUB_TMP_PATH = "/_news/daily.epub.tmp";
31+
} // namespace
32+
33+
// --- Lifecycle ---
34+
35+
void NewsDownloadActivity::onEnter() {
36+
Activity::onEnter();
37+
WiFi.mode(WIFI_STA);
38+
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
39+
[this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
40+
}
41+
42+
void NewsDownloadActivity::onExit() {
43+
Activity::onExit();
44+
45+
if (WiFi.getMode() != WIFI_MODE_NULL) {
46+
WiFi.disconnect(false);
47+
delay(30);
48+
silentRestart();
49+
}
50+
}
51+
52+
void NewsDownloadActivity::onWifiSelectionComplete(const bool success) {
53+
if (!success) {
54+
finish();
55+
return;
56+
}
57+
startDownload();
58+
}
59+
60+
// --- Config ---
61+
62+
bool NewsDownloadActivity::loadConfig() {
63+
HalFile file;
64+
if (!Storage.openFileForRead("NEWS", NEWS_CONFIG_PATH, file)) {
65+
LOG_ERR("NEWS", "Missing config: %s", NEWS_CONFIG_PATH);
66+
errorMessage_ = "Missing news_reader.json on SD card";
67+
return false;
68+
}
69+
70+
// Config is a handful of short strings; JsonDocument stays small and is
71+
// freed when this function returns.
72+
JsonDocument doc;
73+
const DeserializationError err = deserializeJson(doc, file);
74+
if (err) {
75+
LOG_ERR("NEWS", "Config parse error: %s", err.c_str());
76+
errorMessage_ = "Invalid news_reader.json";
77+
return false;
78+
}
79+
80+
const std::string repoUrl = doc["repoUrl"] | "";
81+
token_ = doc["token"] | "";
82+
std::string path = doc["path"] | "daily.epub";
83+
if (repoUrl.empty() || token_.empty()) {
84+
LOG_ERR("NEWS", "Config missing repoUrl or token");
85+
errorMessage_ = "Config needs repoUrl and token";
86+
return false;
87+
}
88+
89+
// Accept "https://github.com/owner/repo" (optionally with trailing "/" or
90+
// ".git") or a bare "owner/repo", and derive the Contents API endpoint for
91+
// the file on the repo's default branch. With the raw media type the
92+
// response body is the file itself (supported for files up to 100 MB), so
93+
// it can be streamed straight to the SD card.
94+
std::string ownerRepo = repoUrl;
95+
const size_t hostPos = ownerRepo.find("github.com/");
96+
if (hostPos != std::string::npos) {
97+
ownerRepo = ownerRepo.substr(hostPos + strlen("github.com/"));
98+
}
99+
while (!ownerRepo.empty() && ownerRepo.back() == '/') {
100+
ownerRepo.pop_back();
101+
}
102+
if (ownerRepo.size() > 4 && ownerRepo.compare(ownerRepo.size() - 4, 4, ".git") == 0) {
103+
ownerRepo.erase(ownerRepo.size() - 4);
104+
}
105+
if (ownerRepo.empty() || ownerRepo.find('/') == std::string::npos) {
106+
LOG_ERR("NEWS", "Bad repoUrl: %s", repoUrl.c_str());
107+
errorMessage_ = "Invalid repoUrl in news_reader.json";
108+
return false;
109+
}
110+
111+
while (!path.empty() && path.front() == '/') {
112+
path.erase(path.begin());
113+
}
114+
downloadUrl_ = "https://api.github.com/repos/" + ownerRepo + "/contents/" + path;
115+
LOG_DBG("NEWS", "News source: %s", downloadUrl_.c_str());
116+
return true;
117+
}
118+
119+
// --- Download ---
120+
121+
void NewsDownloadActivity::startDownload() {
122+
{
123+
RenderLock lock(*this);
124+
state_ = DOWNLOADING;
125+
fileProgress_ = 0;
126+
fileTotal_ = 0;
127+
cancelRequested_ = false;
128+
}
129+
requestUpdateAndWait();
130+
131+
// Re-read on every attempt so a Retry picks up an edited config file.
132+
if (!loadConfig()) {
133+
RenderLock lock(*this);
134+
state_ = ERROR; // errorMessage_ set by loadConfig()
135+
return;
136+
}
137+
138+
if (!Storage.ensureDirectoryExists(NEWS_DIR)) {
139+
LOG_ERR("NEWS", "Failed to create %s", NEWS_DIR);
140+
RenderLock lock(*this);
141+
state_ = ERROR;
142+
errorMessage_ = "Failed to create news folder";
143+
return;
144+
}
145+
146+
const HttpDownloader::Headers headers = {
147+
{"Authorization", "Bearer " + token_},
148+
{"Accept", "application/vnd.github.raw+json"},
149+
};
150+
151+
const auto result = HttpDownloader::downloadToFile(
152+
downloadUrl_, NEWS_EPUB_TMP_PATH,
153+
[this](size_t downloaded, size_t total) {
154+
fileProgress_ = downloaded;
155+
fileTotal_ = total;
156+
mappedInput.update();
157+
if (mappedInput.isPressed(MappedInputManager::Button::Back) ||
158+
mappedInput.wasPressed(MappedInputManager::Button::Back)) {
159+
cancelRequested_ = true;
160+
}
161+
requestUpdate(true);
162+
},
163+
&cancelRequested_, "", "", headers);
164+
165+
if (result == HttpDownloader::ABORTED) {
166+
finish();
167+
return;
168+
}
169+
170+
if (result != HttpDownloader::OK) {
171+
LOG_ERR("NEWS", "Download failed (%d)", result);
172+
RenderLock lock(*this);
173+
state_ = ERROR;
174+
errorMessage_ = result == HttpDownloader::FILE_ERROR ? "Failed to write to SD card" : "Check Wi-Fi and try again";
175+
return;
176+
}
177+
178+
// Replace the previous edition only once the new one is fully on disk.
179+
if (Storage.exists(NEWS_EPUB_PATH) && !Storage.remove(NEWS_EPUB_PATH)) {
180+
LOG_ERR("NEWS", "Failed to remove old %s", NEWS_EPUB_PATH);
181+
Storage.remove(NEWS_EPUB_TMP_PATH);
182+
RenderLock lock(*this);
183+
state_ = ERROR;
184+
errorMessage_ = "Failed to replace old edition";
185+
return;
186+
}
187+
if (!Storage.rename(NEWS_EPUB_TMP_PATH, NEWS_EPUB_PATH)) {
188+
LOG_ERR("NEWS", "Failed to rename %s -> %s", NEWS_EPUB_TMP_PATH, NEWS_EPUB_PATH);
189+
Storage.remove(NEWS_EPUB_TMP_PATH);
190+
RenderLock lock(*this);
191+
state_ = ERROR;
192+
errorMessage_ = "Failed to save news file";
193+
return;
194+
}
195+
196+
LOG_DBG("NEWS", "Downloaded %s (%zu bytes)", NEWS_EPUB_PATH, fileProgress_);
197+
{
198+
RenderLock lock(*this);
199+
state_ = COMPLETE;
200+
}
201+
requestUpdate();
202+
}
203+
204+
// --- Input handling ---
205+
206+
void NewsDownloadActivity::loop() {
207+
switch (state_) {
208+
case COMPLETE:
209+
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
210+
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
211+
finish();
212+
}
213+
break;
214+
case ERROR:
215+
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
216+
finish();
217+
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
218+
startDownload();
219+
requestUpdateAndWait();
220+
}
221+
break;
222+
case WIFI_SELECTION:
223+
case DOWNLOADING:
224+
// WIFI_SELECTION: the child activity owns input. DOWNLOADING: the
225+
// download blocks the main loop; cancel is handled in its progress
226+
// callback.
227+
break;
228+
}
229+
}
230+
231+
// --- Rendering ---
232+
233+
void NewsDownloadActivity::render(RenderLock&&) {
234+
const auto& metrics = UITheme::getInstance().getMetrics();
235+
const auto pageWidth = renderer.getScreenWidth();
236+
const auto pageHeight = renderer.getScreenHeight();
237+
238+
renderer.clearScreen();
239+
240+
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_DOWNLOAD_NEWS));
241+
242+
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
243+
const auto centerY = (pageHeight - lineHeight) / 2;
244+
245+
if (state_ == DOWNLOADING) {
246+
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, tr(STR_DOWNLOADING));
247+
248+
float progress = 0;
249+
if (fileTotal_ > 0) {
250+
progress = static_cast<float>(fileProgress_) / static_cast<float>(fileTotal_);
251+
}
252+
const int barY = centerY + metrics.verticalSpacing;
253+
GUI.drawProgressBar(
254+
renderer,
255+
Rect{metrics.contentSidePadding, barY, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight},
256+
static_cast<int>(progress * 100), 100);
257+
258+
const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), "", "", "");
259+
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
260+
} else if (state_ == COMPLETE) {
261+
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_NEWS_DOWNLOADED), true, EpdFontFamily::BOLD);
262+
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
263+
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
264+
} else if (state_ == ERROR) {
265+
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, tr(STR_DOWNLOAD_FAILED), true, EpdFontFamily::BOLD);
266+
if (!errorMessage_.empty()) {
267+
renderer.drawCenteredText(UI_10_FONT_ID, centerY + metrics.verticalSpacing, errorMessage_.c_str());
268+
}
269+
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
270+
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
271+
}
272+
273+
renderer.displayBuffer();
274+
}

0 commit comments

Comments
 (0)