Public Access
Enter on a picture shows it: shrunk to fit the screen, or at its own size with Enter again and the arrows to move. Dithered to the screen's 256 colours; a colour the screen has exactly is left alone, so screenshots are shown as they are. The picture is decoded once, straight into the screen's buffer, and kept there (App::retainsContent): no copy in memory. Decoding runs on the storage task, so the keys keep working and a 12 megapixel photograph appears as it comes instead of tripping the watchdog. PNG, BMP and GIF are read by decoders of our own, host-tested against files made by Pillow; the PNG one needs 32 KB where the display library's needed 44 KB in one block, which the device often doesn't have. JPEG uses the library's TJpgDec. Also corrects two sentences that still gave 16 KB as the editing limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
492 lines
19 KiB
C++
492 lines
19 KiB
C++
#include "app_keys.h"
|
|
#include "file_viewer.h"
|
|
|
|
#include <Arduino.h>
|
|
#include <SD.h>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <cstdio>
|
|
#include <ctime>
|
|
|
|
#include "cleanup_plan.h"
|
|
#include "file_list.h"
|
|
#include "file_names.h"
|
|
#include "file_views.h"
|
|
#include "meshtastic_presets.h"
|
|
#include "packet_view.h"
|
|
#include "platform/ota_device.h"
|
|
#include "ui/fonts.h"
|
|
#include "ui/widgets.h"
|
|
#include "update_parser.h"
|
|
#include "version.h"
|
|
|
|
namespace roro {
|
|
|
|
namespace {
|
|
constexpr uint32_t kSliceMs = 150; // like the Storage App's operations: the Logs get their turn
|
|
constexpr size_t kPiece = 1024;
|
|
constexpr size_t kMaxPackets = 1000; // their places in the file, 4 KB
|
|
constexpr size_t kMaxPacketBytes = 271; // LoRaTap and the longest LoRa packet
|
|
|
|
// Checks an Update File as an install would, writing nothing.
|
|
struct NoSink : UpdateSink {
|
|
bool begin(size_t) override { return true; }
|
|
bool write(const uint8_t*, size_t) override { return true; }
|
|
bool finish() override { return true; }
|
|
void abort() override {}
|
|
};
|
|
|
|
std::string clockTime(uint32_t utcSeconds) {
|
|
time_t t = static_cast<time_t>(utcSeconds);
|
|
struct tm local;
|
|
localtime_r(&t, &local);
|
|
char s[12];
|
|
std::snprintf(s, sizeof s, "%02d:%02d:%02d", local.tm_hour, local.tm_min, local.tm_sec);
|
|
return s;
|
|
}
|
|
} // namespace
|
|
|
|
struct FileViewer::Scan {
|
|
std::atomic<bool> done{false}, stop{false};
|
|
std::atomic<int> percent{0};
|
|
Mode what = Mode::Gpx;
|
|
std::string path;
|
|
// Touched by the storage task until `done`, by the viewer after.
|
|
fs::File file;
|
|
bool opened = false;
|
|
uint32_t at = 0, size = 0;
|
|
std::string error;
|
|
files::GpxSummary gpx;
|
|
files::PcapHeader pcap;
|
|
std::vector<uint32_t> packets; // where each record starts
|
|
bool morePackets = false;
|
|
std::unique_ptr<EcdsaVerifier> verifier;
|
|
NoSink sink;
|
|
std::unique_ptr<UpdateParser> parser;
|
|
std::string version;
|
|
bool genuine = false, older = false;
|
|
|
|
bool slice(); // true when there's nothing more to read
|
|
static void run(StorageService* storage, std::shared_ptr<Scan> self) {
|
|
bool over = self->stop || self->slice();
|
|
if (over) {
|
|
if (self->file) self->file.close();
|
|
self->parser.reset();
|
|
self->verifier.reset();
|
|
self->done = true;
|
|
} else {
|
|
storage->runJob([storage, self]() { run(storage, self); });
|
|
}
|
|
}
|
|
};
|
|
|
|
bool FileViewer::Scan::slice() {
|
|
uint32_t t0 = millis();
|
|
if (!opened) {
|
|
opened = true;
|
|
file = SD.open(path.c_str(), FILE_READ);
|
|
if (!file) {
|
|
error = "The card refused to open it";
|
|
return true;
|
|
}
|
|
size = static_cast<uint32_t>(file.size());
|
|
if (what == Mode::Ota) {
|
|
verifier.reset(new EcdsaVerifier());
|
|
parser.reset(new UpdateParser(*verifier, sink, EspOtaSink().capacity(), versionString()));
|
|
}
|
|
}
|
|
std::unique_ptr<uint8_t[]> piece(new uint8_t[kPiece]);
|
|
while (millis() - t0 < kSliceMs) {
|
|
if (stop) return true;
|
|
if (what == Mode::Pcap) {
|
|
if (at == 0) {
|
|
int n = file.read(piece.get(), files::kPcapHeaderSize);
|
|
pcap = files::parsePcapHeader(piece.get(), n < 0 ? 0 : n);
|
|
if (!pcap.ok) {
|
|
error = "Not a pcap file this viewer reads";
|
|
return true;
|
|
}
|
|
at = files::kPcapHeaderSize;
|
|
}
|
|
files::PcapRecord r;
|
|
if (!file.seek(at)) return true;
|
|
int n = file.read(piece.get(), files::kPcapRecordSize);
|
|
if (n < 0 || !files::parsePcapRecord(piece.get(), n, r) || at + files::kPcapRecordSize + r.length > size) return true;
|
|
if (packets.size() >= kMaxPackets) {
|
|
morePackets = true;
|
|
return true;
|
|
}
|
|
packets.push_back(at);
|
|
at += files::kPcapRecordSize + r.length;
|
|
continue;
|
|
}
|
|
int n = file.read(piece.get(), kPiece);
|
|
if (n <= 0) break;
|
|
at += n;
|
|
percent = size ? static_cast<int>(static_cast<uint64_t>(at) * 100 / size) : 100;
|
|
if (what == Mode::Gpx) {
|
|
gpx.feed(reinterpret_cast<const char*>(piece.get()), n);
|
|
} else {
|
|
parser->feed(piece.get(), n);
|
|
if (parser->state() == UpdateParser::State::Failed) break;
|
|
}
|
|
}
|
|
if (at < size && !(parser && parser->state() == UpdateParser::State::Failed) && millis() - t0 >= kSliceMs) return false;
|
|
if (parser) {
|
|
genuine = parser->end();
|
|
version = parser->version();
|
|
older = parser->isDowngrade();
|
|
error = parser->error();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
size_t FileViewer::readAt(uint32_t offset, uint8_t* into, size_t len) {
|
|
size_t got = 0;
|
|
auto file = file_;
|
|
std::string path = path_;
|
|
storage_.runAndWait([&, file, path]() {
|
|
if (!*file) *file = SD.open(path.c_str(), FILE_READ);
|
|
if (!*file || !file->seek(offset)) return;
|
|
int n = file->read(into, len);
|
|
got = n < 0 ? 0 : n;
|
|
});
|
|
return got;
|
|
}
|
|
|
|
void FileViewer::open(const std::string& path, uint32_t size) {
|
|
close();
|
|
path_ = path;
|
|
size_ = size;
|
|
file_ = std::make_shared<fs::File>();
|
|
std::string name = files::baseName(path);
|
|
files::FileKind kind = files::kindOf(name);
|
|
files::ImageKind picture = files::imageKindOfName(name);
|
|
if (kind == files::FileKind::Unknown && picture == files::ImageKind::None) { // what do its first bytes look like?
|
|
uint8_t head[256];
|
|
size_t n = readAt(0, head, sizeof head);
|
|
picture = files::imageKindOfBytes(head, n);
|
|
kind = files::looksLikeText(head, n) ? files::FileKind::Text : files::FileKind::Unknown;
|
|
}
|
|
std::string notShown;
|
|
if (picture != files::ImageKind::None) notShown = image_.open(path, size);
|
|
switch (kind) {
|
|
case files::FileKind::Text: base_ = Mode::Text; break;
|
|
case files::FileKind::Gpx: base_ = Mode::Gpx; break;
|
|
case files::FileKind::Pcap: base_ = Mode::Pcap; break;
|
|
case files::FileKind::Ota: base_ = Mode::Ota; break;
|
|
default: base_ = Mode::Hex; break;
|
|
}
|
|
if (picture != files::ImageKind::None && notShown.empty()) base_ = Mode::Image;
|
|
pager_.reset(new files::TextPager([this](uint32_t offset, uint8_t* into, size_t len) { return readAt(offset, into, len); }, size_,
|
|
kCols, kRows));
|
|
if (files::opensAtEnd(name)) pager_->toEnd();
|
|
hexTop_ = 0;
|
|
show(base_);
|
|
if (!notShown.empty()) say(notShown); // a picture that can't be shown: its bytes, and why
|
|
if (base_ == Mode::Gpx || base_ == Mode::Pcap || base_ == Mode::Ota) startScan(base_);
|
|
}
|
|
|
|
void FileViewer::startScan(Mode mode) {
|
|
scan_ = std::make_shared<Scan>();
|
|
scan_->what = mode;
|
|
scan_->path = path_;
|
|
scanShown_ = false;
|
|
shownPercent_ = -1;
|
|
auto scan = scan_;
|
|
StorageService* storage = &storage_;
|
|
storage_.runJob([storage, scan]() { Scan::run(storage, scan); });
|
|
}
|
|
|
|
void FileViewer::close() {
|
|
if (scan_) scan_->stop = true; // its job ends at the next piece and frees itself
|
|
scan_.reset();
|
|
if (file_) {
|
|
auto file = file_;
|
|
storage_.runJob([file]() {
|
|
if (*file) file->close();
|
|
});
|
|
}
|
|
file_.reset();
|
|
image_.close();
|
|
pager_.reset();
|
|
confirm_.reset();
|
|
message_.clear();
|
|
std::vector<std::string>().swap(shown_);
|
|
std::vector<std::string>().swap(details_);
|
|
packets_.setCount(0);
|
|
rowsFrom_ = -1;
|
|
}
|
|
|
|
void FileViewer::show(Mode mode) {
|
|
mode_ = mode;
|
|
if (mode == Mode::Image) image_.lost(); // another view was drawn where it was
|
|
stale_ = true;
|
|
rowsFrom_ = -1;
|
|
}
|
|
|
|
void FileViewer::say(const std::string& text) {
|
|
if (mode_ == Mode::Image) return image_.say(text);
|
|
message_ = text;
|
|
messageMs_ = millis();
|
|
}
|
|
|
|
std::string FileViewer::title() const { return files::fitName(files::baseName(path_), 24); }
|
|
|
|
void FileViewer::scroll(int lines) {
|
|
if (mode_ == Mode::Text && pager_) {
|
|
if (lines > 0) pager_->down(lines);
|
|
else pager_->up(-lines);
|
|
} else if (mode_ == Mode::Hex) {
|
|
uint32_t rows = (size_ + 7) / 8, last = rows > static_cast<uint32_t>(kRows) ? rows - kRows : 0;
|
|
int64_t to = static_cast<int64_t>(hexTop_) + lines;
|
|
hexTop_ = static_cast<uint32_t>(std::clamp<int64_t>(to, 0, last));
|
|
}
|
|
stale_ = true;
|
|
}
|
|
|
|
void FileViewer::openPacket(int index) {
|
|
if (!scan_ || !scan_->done || index < 0 || index >= static_cast<int>(scan_->packets.size())) return;
|
|
uint8_t buf[files::kPcapRecordSize + kMaxPacketBytes];
|
|
size_t n = readAt(scan_->packets[index], buf, sizeof buf);
|
|
files::PcapRecord r;
|
|
if (!files::parsePcapRecord(buf, n, r)) return;
|
|
const uint8_t* body = buf + files::kPcapRecordSize;
|
|
size_t have = std::min<size_t>(r.length, n - files::kPcapRecordSize);
|
|
details_.clear();
|
|
char line[64];
|
|
lora::RxInfo rx;
|
|
bool tap = scan_->pcap.linkType == files::kLinkLoraTap && files::parseLoraTap(body, have, rx);
|
|
size_t skip = tap ? lora::kLoraTapSize : 0;
|
|
std::snprintf(line, sizeof line, "%s, %u bytes", clockTime(r.seconds).c_str(), static_cast<unsigned>(r.length - skip));
|
|
details_.push_back(line);
|
|
if (tap) {
|
|
std::snprintf(line, sizeof line, "%.0f dBm, SNR %.1f dB, noise %.0f", rx.rssi, rx.snr, rx.noiseFloor);
|
|
details_.push_back(line);
|
|
std::snprintf(line, sizeof line, "%.4f MHz, SF%u, %.0f kHz", rx.frequencyHz / 1e6, rx.spreadingFactor, rx.bandwidthKHz);
|
|
details_.push_back(line);
|
|
if (rx.syncWord == meshtastic::kSyncWord)
|
|
for (auto& l : lora::headerLines(body + skip, have - skip)) details_.push_back(l);
|
|
}
|
|
for (auto& l : lora::hexDump(body + skip, have - skip)) details_.push_back(l);
|
|
if (have < r.length) details_.push_back("(the first bytes only)");
|
|
detailsTop_ = 0;
|
|
mode_ = Mode::Packet;
|
|
}
|
|
|
|
void FileViewer::help(std::vector<KeyHelp>& out) const {
|
|
if (confirm_) return keys::add(out, keys::kDialog);
|
|
switch (mode_) {
|
|
case Mode::Text: keys::add(out, keys::kViewerText); break;
|
|
case Mode::Hex: keys::add(out, keys::kViewerHex); break;
|
|
case Mode::Pcap: keys::add(out, keys::kViewerPcap); break;
|
|
case Mode::Packet: keys::add(out, keys::kViewerPacket); break;
|
|
case Mode::Gpx: keys::add(out, keys::kViewerGpx); break;
|
|
case Mode::Ota: keys::add(out, keys::kViewerOta); break;
|
|
case Mode::Image: image_.help(out); break;
|
|
}
|
|
}
|
|
|
|
bool FileViewer::onKey(const KeyEvent& e) {
|
|
if (confirm_) {
|
|
confirm_->onKey(e);
|
|
if (confirm_->result() == 1) update_.installFromSd(path_);
|
|
if (confirm_->result() != DialogModel::kPending) confirm_.reset();
|
|
return true;
|
|
}
|
|
if (mode_ == Mode::Packet) {
|
|
int last = std::max(0, static_cast<int>(details_.size()) - (kRows + 1));
|
|
if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1);
|
|
else if (e.key == Key::Down) detailsTop_ = std::min(last, detailsTop_ + 1);
|
|
else if (e.key == Key::Back || e.key == Key::Select) mode_ = Mode::Pcap;
|
|
return true;
|
|
}
|
|
if (e.key == Key::Back) return false;
|
|
if (e.key == Key::Tab) {
|
|
// The file's own view, or what it's made of: text for a Track, bytes for the others.
|
|
if (mode_ != base_) show(base_);
|
|
else show(base_ == Mode::Hex || base_ == Mode::Gpx ? Mode::Text : Mode::Hex);
|
|
return true;
|
|
}
|
|
if (mode_ == Mode::Image) return image_.onKey(e), true;
|
|
uint32_t ch = e.key == Key::Char ? e.ch : 0;
|
|
switch (mode_) {
|
|
case Mode::Text:
|
|
case Mode::Hex:
|
|
if (e.key == Key::Up) scroll(-1);
|
|
else if (e.key == Key::Down) scroll(1);
|
|
else if (e.key == Key::Left) scroll(-(kRows - 1));
|
|
else if (e.key == Key::Right) scroll(kRows - 1);
|
|
else if (ch == 't' || ch == 'T') {
|
|
if (pager_) pager_->toStart();
|
|
hexTop_ = 0;
|
|
stale_ = true;
|
|
} else if (ch == 'b' || ch == 'B') {
|
|
if (mode_ == Mode::Text && pager_) pager_->toEnd();
|
|
else scroll(INT32_MAX / 2);
|
|
stale_ = true;
|
|
}
|
|
break;
|
|
case Mode::Pcap:
|
|
if (e.key == Key::Up) packets_.up();
|
|
else if (e.key == Key::Down) packets_.down();
|
|
else if (e.key == Key::Left) packets_.pageUp();
|
|
else if (e.key == Key::Right) packets_.pageDown();
|
|
else if (e.key == Key::Select) openPacket(packets_.selected());
|
|
break;
|
|
case Mode::Ota:
|
|
if (e.key == Key::Select && scan_ && scan_->done && scan_->genuine) confirm_.reset(new DialogModel({"Cancel", "Install"}));
|
|
break;
|
|
default: break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool FileViewer::update(uint32_t nowMs) {
|
|
if (mode_ == Mode::Image) return image_.update(nowMs);
|
|
if (!message_.empty() && millis() - messageMs_ >= 4000) {
|
|
message_.clear();
|
|
return true;
|
|
}
|
|
if (!scan_) return false;
|
|
if (scan_->done && !scanShown_) {
|
|
scanShown_ = true;
|
|
if (scan_->what == Mode::Pcap) packets_.setCount(static_cast<int>(scan_->packets.size()));
|
|
return true;
|
|
}
|
|
int percent = scan_->percent;
|
|
if (!scan_->done && percent != shownPercent_) {
|
|
shownPercent_ = percent;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void FileViewer::refresh() {
|
|
if (mode_ == Mode::Text && stale_ && pager_) {
|
|
shown_ = pager_->lines();
|
|
stale_ = false;
|
|
} else if (mode_ == Mode::Hex && stale_) {
|
|
uint8_t buf[kRows * 8];
|
|
size_t n = readAt(hexTop_ * 8, buf, sizeof buf);
|
|
shown_.clear();
|
|
for (size_t at = 0; at < n; at += 8) shown_.push_back(files::hexRow(hexTop_ * 8 + at, buf + at, std::min<size_t>(8, n - at)));
|
|
stale_ = false;
|
|
} else if (mode_ == Mode::Pcap && scan_ && scan_->done && rowsFrom_ != packets_.firstVisible()) {
|
|
// The rows on screen, read from the card once each time the list moves.
|
|
rowsFrom_ = packets_.firstVisible();
|
|
shown_.clear();
|
|
bool tap = scan_->pcap.linkType == files::kLinkLoraTap;
|
|
for (int i = rowsFrom_; i < rowsFrom_ + kRows && i < static_cast<int>(scan_->packets.size()); i++) {
|
|
uint8_t buf[files::kPcapRecordSize + kMaxPacketBytes];
|
|
size_t n = readAt(scan_->packets[i], buf, sizeof buf);
|
|
files::PcapRecord r;
|
|
lora::RxInfo rx;
|
|
if (!files::parsePcapRecord(buf, n, r)) {
|
|
shown_.push_back("(unreadable)");
|
|
continue;
|
|
}
|
|
const uint8_t* body = buf + files::kPcapRecordSize;
|
|
size_t have = std::min<size_t>(r.length, n - files::kPcapRecordSize);
|
|
if (tap && files::parseLoraTap(body, have, rx)) {
|
|
shown_.push_back(lora::row({body + lora::kLoraTapSize, have - lora::kLoraTapSize, rx.rssi, rx.snr, true,
|
|
rx.syncWord == meshtastic::kSyncWord},
|
|
clockTime(r.seconds)));
|
|
} else {
|
|
shown_.push_back(clockTime(r.seconds) + " " + std::to_string(r.length) + " bytes");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void FileViewer::draw(Canvas& c) {
|
|
if (mode_ == Mode::Image) return image_.draw(c);
|
|
const auto& area = theme::kContent;
|
|
refresh();
|
|
c.setTextDatum(top_left);
|
|
theme::Rect body{area.x, area.y + 10, area.w, kRows * theme::kLineHeight};
|
|
std::string right;
|
|
bool scanning = scan_ && !scan_->done;
|
|
|
|
switch (mode_) {
|
|
case Mode::Text:
|
|
case Mode::Hex: {
|
|
c.setFont(&fonts::body);
|
|
c.setTextColor(size_ == 0 ? theme::kMuted : theme::kText);
|
|
if (size_ == 0) c.drawString("(empty)", 4, body.y + 1);
|
|
for (size_t i = 0; i < shown_.size(); i++) c.drawString(shown_[i].c_str(), 4, body.y + 1 + static_cast<int>(i) * theme::kLineHeight);
|
|
uint32_t at = mode_ == Mode::Text && pager_ ? pager_->top() : hexTop_ * 8;
|
|
if (size_ > 0) { // where in the file the screen is
|
|
int barH = 12, barY = body.y + static_cast<int>(static_cast<uint64_t>(body.h - barH) * at / size_);
|
|
c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted);
|
|
}
|
|
right = formatBytes(size_);
|
|
break;
|
|
}
|
|
case Mode::Gpx: {
|
|
std::vector<std::string> lines;
|
|
if (scanning) lines.push_back("Reading the Track... " + std::to_string(scan_->percent.load()) + "%");
|
|
else if (scan_ && !scan_->error.empty()) lines.push_back(scan_->error);
|
|
else if (scan_) lines = scan_->gpx.lines();
|
|
widgets::textLines(c, lines, 0, body);
|
|
right = formatBytes(size_);
|
|
break;
|
|
}
|
|
case Mode::Pcap: {
|
|
c.setFont(&fonts::body);
|
|
c.setTextColor(theme::kMuted);
|
|
if (scanning) c.drawString("Reading the Capture...", 4, body.y + 1);
|
|
else if (scan_ && !scan_->error.empty()) c.drawString(scan_->error.c_str(), 4, body.y + 1);
|
|
else if (scan_ && scan_->packets.empty()) c.drawString("No packets in this Capture.", 4, body.y + 1);
|
|
else if (scan_) {
|
|
int from = rowsFrom_;
|
|
widgets::list(c, packets_, body, [&](int i) {
|
|
size_t row = static_cast<size_t>(i - from);
|
|
return row < shown_.size() ? shown_[row] : std::string();
|
|
});
|
|
right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets");
|
|
}
|
|
break;
|
|
}
|
|
case Mode::Packet: widgets::textLines(c, details_, detailsTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 2}); return;
|
|
case Mode::Ota: {
|
|
std::vector<std::string> lines;
|
|
bool ok = scan_ && scan_->done && scan_->genuine;
|
|
if (scanning) {
|
|
lines.push_back("Checking it as an install would:");
|
|
lines.push_back("signature and contents, " + std::to_string(scan_->percent.load()) + "%");
|
|
} else if (scan_) {
|
|
if (!scan_->version.empty()) lines.push_back("Version " + scan_->version);
|
|
lines.push_back(std::string("Running ") + versionString());
|
|
if (ok) {
|
|
lines.push_back("Signed with the project's key, intact.");
|
|
if (scan_->older) lines.push_back("Older than what's running.");
|
|
} else {
|
|
lines.push_back("Not installable:");
|
|
lines.push_back(scan_->error.empty() ? "it ends too early" : scan_->error);
|
|
}
|
|
}
|
|
widgets::textLines(c, lines, 0, body);
|
|
right = formatBytes(size_);
|
|
if (confirm_) {
|
|
widgets::dialog(c, "Install update?", "The device restarts into " + scan_->version + " once it's written.", *confirm_);
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
c.setFont(&fonts::small);
|
|
c.setTextColor(theme::kMuted);
|
|
c.setTextDatum(top_left);
|
|
c.drawString(title().c_str(), 4, area.y + 1);
|
|
c.setTextDatum(top_right);
|
|
c.drawString(right.c_str(), area.w - 3, area.y + 1);
|
|
c.setTextDatum(top_left);
|
|
if (!message_.empty()) c.setTextColor(theme::kWarning);
|
|
if (!message_.empty()) c.drawString(message_.c_str(), 4, area.y + area.h - 9);
|
|
}
|
|
|
|
} // namespace roro
|