#include "file_viewer.h" #include #include #include #include #include #include #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(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 done{false}, stop{false}; std::atomic 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 packets; // where each record starts bool morePackets = false; std::unique_ptr verifier; NoSink sink; std::unique_ptr 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 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(file.size()); if (what == Mode::Ota) { verifier.reset(new EcdsaVerifier()); parser.reset(new UpdateParser(*verifier, sink, EspOtaSink().capacity(), versionString())); } } std::unique_ptr 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(static_cast(at) * 100 / size) : 100; if (what == Mode::Gpx) { gpx.feed(reinterpret_cast(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(); std::string name = files::baseName(path); files::FileKind kind = files::kindOf(name); if (kind == files::FileKind::Unknown) { // what do its first bytes look like? uint8_t head[256]; size_t n = readAt(0, head, sizeof head); kind = files::looksLikeText(head, n) ? files::FileKind::Text : files::FileKind::Unknown; } 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; } 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 (base_ == Mode::Gpx || base_ == Mode::Pcap || base_ == Mode::Ota) startScan(base_); } void FileViewer::startScan(Mode mode) { scan_ = std::make_shared(); 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(); pager_.reset(); confirm_.reset(); message_.clear(); std::vector().swap(shown_); std::vector().swap(details_); packets_.setCount(0); rowsFrom_ = -1; } void FileViewer::show(Mode mode) { mode_ = mode; stale_ = true; rowsFrom_ = -1; } void FileViewer::say(const std::string& 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(kRows) ? rows - kRows : 0; int64_t to = static_cast(hexTop_) + lines; hexTop_ = static_cast(std::clamp(to, 0, last)); } stale_ = true; } void FileViewer::openPacket(int index) { if (!scan_ || !scan_->done || index < 0 || index >= static_cast(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(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(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; } 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(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; } 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) { 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(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(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(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(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) { 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, keys = "Tab: hex"; 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(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(static_cast(body.h - barH) * at / size_); c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted); } right = formatBytes(size_); keys = std::string("Tab: ") + (mode_ != base_ ? "back" : mode_ == Mode::Text ? "hex" : "text") + " t: top b: end"; if (mode_ == Mode::Text) keys += " e: edit"; break; } case Mode::Gpx: { std::vector 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_); keys = "Tab: the file as text"; 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(i - from); return row < shown_.size() ? shown_[row] : std::string(); }); right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets"); keys = "Enter: the packet Tab: hex"; } 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 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_); keys = ok ? "Enter: install Tab: hex" : "Tab: hex"; 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); c.drawString(message_.empty() ? keys.c_str() : message_.c_str(), 4, area.y + area.h - 9); } } // namespace roro