diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileCa.h b/lib/tdeck_ui/Hardware/TDeck/MapTileCa.h new file mode 100644 index 00000000..a0fd3955 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileCa.h @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_MAP_TILE_CA_H +#define HARDWARE_TDECK_MAP_TILE_CA_H + +namespace Hardware { +namespace TDeck { + +// ISRG Root X1, valid through 2035-06-04. Public trust anchor for the +// default OpenStreetMap endpoint; this is not a credential. +static const char MAP_TILE_ISRG_ROOT_X1[] = R"CERT(-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- +)CERT"; + +} // namespace TDeck +} // namespace Hardware + +#endif diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.cpp b/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.cpp index 40404271..90ae87ea 100644 --- a/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.cpp +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.cpp @@ -81,7 +81,7 @@ MapTileUrlResult MapTileDownloader::canonicalUrl(const char* endpoint, const Til } MapTileEnqueueResult MapTileDownloader::enqueue(const TileKey& key, std::uint32_t generation) { - if (!policy_.enabled) return MapTileEnqueueResult::DISABLED; + if (!policy_.enabled) return MapTileEnqueueResult::POLICY_DISABLED; if (!validKey(key)) return MapTileEnqueueResult::INVALID_KEY; if (active_ && sameKey(current_.key, key)) return MapTileEnqueueResult::DUPLICATE; for (std::size_t i = 0U; i < queue_count_; ++i) { @@ -94,6 +94,22 @@ MapTileEnqueueResult MapTileDownloader::enqueue(const TileKey& key, std::uint32_ return MapTileEnqueueResult::ACCEPTED; } +void MapTileDownloader::setEnabled(bool enabled) { + if (policy_.enabled == enabled) return; + policy_.enabled = enabled; + if (enabled) return; + if (active_) finish(MapTileResultCode::CANCELED, store_open_); + while (queue_count_ != 0U) { + current_ = queue_[0]; + for (std::size_t index = 1U; index < queue_count_; ++index) { + queue_[index - 1U] = queue_[index]; + } + --queue_count_; + received_ = 0U; + publish(MapTileResultCode::CANCELED); + } +} + void MapTileDownloader::publish(MapTileResultCode code) { MapTileDownloadResult result = {current_.key, current_.generation, code, received_}; if (result_count_ == RESULT_CAPACITY) { diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h b/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h index e8f1a387..0e64aa9b 100644 --- a/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h @@ -78,7 +78,7 @@ private: MapTileStore& store_; }; -enum class MapTileEnqueueResult : std::uint8_t { ACCEPTED, DISABLED, INVALID_KEY, DUPLICATE, QUEUE_FULL }; +enum class MapTileEnqueueResult : std::uint8_t { ACCEPTED, POLICY_DISABLED, INVALID_KEY, DUPLICATE, QUEUE_FULL }; enum class MapTileUrlResult : std::uint8_t { OK, INVALID_ARGUMENT, INVALID_KEY, TOO_LONG }; enum class MapTilePumpResult : std::uint8_t { IDLE, PROGRESSED }; enum class MapTileResultCode : std::uint8_t { @@ -119,6 +119,8 @@ public: static MapTileUrlResult canonicalUrl(const char* endpoint, const TileKey& key, char* output, std::size_t capacity); MapTileEnqueueResult enqueue(const TileKey& key, std::uint32_t generation); + /** Worker-owner only: disabling aborts active and queued work immediately. */ + void setEnabled(bool enabled); std::size_t cancelGeneration(std::uint32_t generation); MapTilePumpResult pump(); bool takeResult(MapTileDownloadResult& result); diff --git a/lib/tdeck_ui/UI/LXMF/MapScreen.cpp b/lib/tdeck_ui/UI/LXMF/MapScreen.cpp index 978b1522..ed63eaea 100644 --- a/lib/tdeck_ui/UI/LXMF/MapScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/MapScreen.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT #include "MapScreen.h" +#include "Hardware/TDeck/MapTileCa.h" #ifdef ARDUINO @@ -26,6 +27,17 @@ constexpr std::size_t TILE_PIXEL_COUNT = 256U * 256U; constexpr std::uint32_t STORE_BYTE_QUOTA = 64U * 1024U * 1024U; constexpr std::uint16_t STORE_ENTRY_CAPACITY = 128U; +#ifndef FIRMWARE_VERSION +#define FIRMWARE_VERSION "dev" +#endif + +Hardware::TDeck::MapTileDownloadConfig makeDownloadConfig() { + Hardware::TDeck::MapTileDownloadConfig config; + config.ca_certificate = Hardware::TDeck::MAP_TILE_ISRG_ROOT_X1; + config.firmware_version = FIRMWARE_VERSION; + return config; +} + lv_obj_t* createToolbarButton(lv_obj_t* parent, const char* text, lv_event_cb_t callback, void* context, lv_coord_t width) { @@ -59,7 +71,12 @@ MapScreen::MapScreen(lv_obj_t* parent) presenter_(), storage_(), store_config_{STORE_ENTRY_CAPACITY, STORE_BYTE_QUOTA, MAX_COMPRESSED_TILE_BYTES}, - store_(storage_, store_config_), compressed_staging_(nullptr), + store_(storage_, store_config_), download_store_(store_), + download_transport_(), download_clock_(), download_policy_(), + download_config_(makeDownloadConfig()), + downloader_(download_store_, download_transport_, download_clock_, + download_policy_, download_config_), + downloads_enabled_(false), compressed_staging_(nullptr), state_mutex_(nullptr), worker_task_(nullptr), stop_requested_(false), worker_exited_(true), worker_started_(false), store_initialized_(false), requests_released_(false), @@ -295,6 +312,61 @@ void MapScreen::workerLoop() { } Pyxis::MapTileLoadResult MapScreen::loadTile( + const Pyxis::MapTileRequest& request) { + const Pyxis::MapTileLoadResult cached = readTile(request); + if (cached != Pyxis::MapTileLoadResult::MISS) return cached; + const Pyxis::MapTileLoadResult downloaded = downloadTile(request); + return downloaded == Pyxis::MapTileLoadResult::READY + ? readTile(request) : downloaded; +} + +Pyxis::MapTileLoadResult MapScreen::downloadTile( + const Pyxis::MapTileRequest& request) { + const bool enabled = downloads_enabled_.load(std::memory_order_acquire); + downloader_.setEnabled(enabled); + if (!enabled) return Pyxis::MapTileLoadResult::MISS; + + Hardware::TDeck::MapTileDownloadResult ignored{}; + while (downloader_.takeResult(ignored)) {} + const Hardware::TDeck::MapTileEnqueueResult queued = + downloader_.enqueue(request.key, request.frame_epoch); + if (queued != Hardware::TDeck::MapTileEnqueueResult::ACCEPTED) { + return Pyxis::MapTileLoadResult::DOWNLOAD_FAILED; + } + + while (downloader_.isBusy()) { + if (stop_requested_.load(std::memory_order_acquire) || + !downloads_enabled_.load(std::memory_order_acquire)) { + downloader_.setEnabled(false); + return Pyxis::MapTileLoadResult::DOWNLOAD_FAILED; + } + bool stale = false; + if (lockState(pdMS_TO_TICKS(20))) { + stale = presenter_.generation() != request.generation || + presenter_.frameEpoch() != request.frame_epoch; + unlockState(); + } + if (stale) { + (void)downloader_.cancelGeneration(request.frame_epoch); + } + (void)downloader_.pump(); + if (downloader_.isBusy()) vTaskDelay(pdMS_TO_TICKS(1)); + } + + Hardware::TDeck::MapTileDownloadResult result{}; + while (downloader_.takeResult(result)) { + if (result.generation == request.frame_epoch && + result.key.zoom == request.key.zoom && result.key.x == request.key.x && + result.key.y == request.key.y) { + return result.code == Hardware::TDeck::MapTileResultCode::SUCCESS + ? Pyxis::MapTileLoadResult::READY + : Pyxis::MapTileLoadResult::DOWNLOAD_FAILED; + } + } + return Pyxis::MapTileLoadResult::DOWNLOAD_FAILED; +} + +Pyxis::MapTileLoadResult MapScreen::readTile( const Pyxis::MapTileRequest& request) { if (!store_initialized_) { return Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE; @@ -340,8 +412,22 @@ Pyxis::MapTileLoadResult MapScreen::loadTile( unsigned char* rgb = nullptr; unsigned width = 0U; unsigned height = 0U; - const unsigned decode_error = lodepng_decode24( - &rgb, &width, &height, compressed_staging_, total); + LodePNGState decode_state; + lodepng_state_init(&decode_state); + // Bound the largest legal 256x256 source scanline stream (RGBA16 plus + // filter bytes/interlace overhead) independently of compressed size. + decode_state.decoder.zlibsettings.max_output_size = 528U * 1024U; + unsigned decode_error = lodepng_inspect( + &width, &height, &decode_state, compressed_staging_, total); + if (decode_error != 0U || width != 256U || height != 256U) { + lodepng_state_cleanup(&decode_state); + return Pyxis::MapTileLoadResult::INVALID_PNG; + } + decode_state.info_raw.colortype = LCT_RGB; + decode_state.info_raw.bitdepth = 8U; + decode_error = lodepng_decode( + &rgb, &width, &height, &decode_state, compressed_staging_, total); + lodepng_state_cleanup(&decode_state); if (decode_error != 0U || rgb == nullptr || width != 256U || height != 256U) { if (rgb) lv_mem_free(rgb); return Pyxis::MapTileLoadResult::INVALID_PNG; @@ -464,6 +550,9 @@ void MapScreen::setStatusFor(Pyxis::MapTileLoadResult result) { case Pyxis::MapTileLoadResult::TOO_LARGE: lv_label_set_text(status_label_, "Tile too large"); break; + case Pyxis::MapTileLoadResult::DOWNLOAD_FAILED: + lv_label_set_text(status_label_, "Download failed"); + break; case Pyxis::MapTileLoadResult::IO_ERROR: lv_label_set_text(status_label_, "Tile I/O error"); break; diff --git a/lib/tdeck_ui/UI/LXMF/MapScreen.h b/lib/tdeck_ui/UI/LXMF/MapScreen.h index fbb99783..8e95c5e3 100644 --- a/lib/tdeck_ui/UI/LXMF/MapScreen.h +++ b/lib/tdeck_ui/UI/LXMF/MapScreen.h @@ -18,6 +18,8 @@ #include "Hardware/TDeck/MapTileStore.h" #include "Hardware/TDeck/MapTileStoreSD.h" +#include "Hardware/TDeck/MapTileDownloader.h" +#include "Hardware/TDeck/MapTileHttpArduino.h" namespace UI { namespace LXMF { @@ -46,6 +48,9 @@ public: // These methods never call LVGL and are invoked before LVGL_LOCK. void serviceIo(); void updateModel(const Pyxis::MapView::Request& request); + void setDownloadEnabled(bool enabled) { + downloads_enabled_.store(enabled, std::memory_order_release); + } // These methods only mutate the pre-created object pool and are invoked // while UIManager owns LVGL_LOCK. @@ -75,6 +80,13 @@ private: Hardware::TDeck::MapTileStoreSD storage_; Hardware::TDeck::TileStoreConfig store_config_; Hardware::TDeck::MapTileStore store_; + Hardware::TDeck::MapTileStoreDownloadAdapter download_store_; + Hardware::TDeck::MapTileHttpArduino download_transport_; + Hardware::TDeck::MapTileMillisClock download_clock_; + Hardware::TDeck::MapTileDownloadPolicy download_policy_; + Hardware::TDeck::MapTileDownloadConfig download_config_; + Hardware::TDeck::MapTileDownloader downloader_; + std::atomic downloads_enabled_; std::uint8_t* compressed_staging_; SemaphoreHandle_t state_mutex_; TaskHandle_t worker_task_; @@ -93,6 +105,8 @@ private: static void workerEntry(void* context); void workerLoop(); Pyxis::MapTileLoadResult loadTile(const Pyxis::MapTileRequest& request); + Pyxis::MapTileLoadResult readTile(const Pyxis::MapTileRequest& request); + Pyxis::MapTileLoadResult downloadTile(const Pyxis::MapTileRequest& request); bool startWorker(); void stopWorker(); bool lockState(TickType_t ticks = portMAX_DELAY); diff --git a/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.cpp b/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.cpp index 4e3a8512..9a9deaf0 100644 --- a/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.cpp +++ b/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.cpp @@ -314,6 +314,7 @@ MapTileSlot::State MapScreenPresenter::stateFor(MapTileLoadResult result) { return MapTileSlot::STORAGE_UNAVAILABLE; case MapTileLoadResult::INVALID_PNG: return MapTileSlot::INVALID_PNG; case MapTileLoadResult::TOO_LARGE: return MapTileSlot::TOO_LARGE; + case MapTileLoadResult::DOWNLOAD_FAILED: return MapTileSlot::IO_ERROR; case MapTileLoadResult::IO_ERROR: return MapTileSlot::IO_ERROR; } return MapTileSlot::IO_ERROR; diff --git a/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.h b/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.h index 6b89af92..67ffb874 100644 --- a/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.h +++ b/lib/tdeck_ui/UI/LXMF/MapScreenPresenter.h @@ -18,6 +18,7 @@ enum class MapTileLoadResult : std::uint8_t { STORAGE_UNAVAILABLE, INVALID_PNG, TOO_LARGE, + DOWNLOAD_FAILED, IO_ERROR }; diff --git a/lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp b/lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp index 5efb2d93..b0ed058f 100644 --- a/lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp @@ -34,6 +34,7 @@ static const char* KEY_ANNOUNCE_INT = "announce"; static const char* KEY_SYNC_INT = "sync_int"; static const char* KEY_GPS_SYNC = "gps_sync"; static const char* KEY_TRANSPORT_ENABLED = "transport"; +static const char* KEY_MAP_DOWNLOAD = "map_dl"; // Notification settings static const char* KEY_NOTIF_SND = "notif_snd"; static const char* KEY_NOTIF_VOL = "notif_vol"; @@ -71,6 +72,7 @@ SettingsScreen::SettingsScreen(lv_obj_t* parent) _ta_announce_interval(nullptr), _ta_sync_interval(nullptr), _switch_gps_sync(nullptr), _switch_transport_enabled(nullptr), _transport_warning_modal(nullptr), _transport_modal_group(nullptr), _transport_enable_confirmed(false), + _switch_map_download(nullptr), _btn_propagation_nodes(nullptr), _switch_prop_fallback(nullptr), _switch_prop_only(nullptr), _gps(nullptr) { LVGL_LOCK(); @@ -867,6 +869,27 @@ void SettingsScreen::create_advanced_section(lv_obj_t* parent) { lv_obj_align(_switch_gps_sync, LV_ALIGN_RIGHT_MID, 0, 0); lv_obj_set_style_bg_color(_switch_gps_sync, Theme::border(), LV_PART_MAIN); lv_obj_set_style_bg_color(_switch_gps_sync, Theme::primary(), LV_PART_INDICATOR | LV_STATE_CHECKED); + + // Optional network tile downloads are explicit opt-in and visible-tile only. + lv_obj_t* map_download_row = lv_obj_create(parent); + lv_obj_set_width(map_download_row, LV_PCT(100)); + lv_obj_set_height(map_download_row, 28); + lv_obj_set_style_bg_opa(map_download_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(map_download_row, 0, 0); + lv_obj_set_style_pad_all(map_download_row, 0, 0); + lv_obj_clear_flag(map_download_row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* map_download_label = lv_label_create(map_download_row); + lv_label_set_text(map_download_label, "Download map tiles:"); + lv_obj_align(map_download_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(map_download_label, Theme::textTertiary(), 0); + lv_obj_set_style_text_font(map_download_label, &lv_font_montserrat_14, 0); + _switch_map_download = lv_switch_create(map_download_row); + lv_obj_set_size(_switch_map_download, 40, 20); + lv_obj_align(_switch_map_download, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_switch_map_download, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_switch_map_download, Theme::primary(), + LV_PART_INDICATOR | LV_STATE_CHECKED); + if (grp) lv_group_add_obj(grp, _switch_map_download); } void SettingsScreen::create_transport_mode_section(lv_obj_t* parent) { @@ -1030,6 +1053,7 @@ void SettingsScreen::load_settings() { _settings.sync_interval = prefs.getUInt(KEY_SYNC_INT, 14400); // Default 14400s = 4 hours _settings.gps_time_sync = prefs.getBool(KEY_GPS_SYNC, true); _settings.transport_enabled = prefs.getBool(KEY_TRANSPORT_ENABLED, false); + _settings.map_download_enabled = prefs.getBool(KEY_MAP_DOWNLOAD, false); // Notification settings _settings.notification_sound = prefs.getBool(KEY_NOTIF_SND, true); @@ -1080,6 +1104,7 @@ void SettingsScreen::save_settings() { prefs.putUInt(KEY_SYNC_INT, _settings.sync_interval); prefs.putBool(KEY_GPS_SYNC, _settings.gps_time_sync); prefs.putBool(KEY_TRANSPORT_ENABLED, _settings.transport_enabled); + prefs.putBool(KEY_MAP_DOWNLOAD, _settings.map_download_enabled); // Notification settings prefs.putBool(KEY_NOTIF_SND, _settings.notification_sound); @@ -1190,6 +1215,13 @@ void SettingsScreen::update_ui_from_settings() { } _transport_enable_confirmed = false; } + if (_switch_map_download) { + if (_settings.map_download_enabled) { + lv_obj_add_state(_switch_map_download, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(_switch_map_download, LV_STATE_CHECKED); + } + } // Interface settings if (_switch_tcp_enabled) { @@ -1328,6 +1360,10 @@ void SettingsScreen::update_settings_from_ui() { if (_switch_transport_enabled) { _settings.transport_enabled = lv_obj_has_state(_switch_transport_enabled, LV_STATE_CHECKED); } + if (_switch_map_download) { + _settings.map_download_enabled = + lv_obj_has_state(_switch_map_download, LV_STATE_CHECKED); + } // Interface settings if (_switch_tcp_enabled) { diff --git a/lib/tdeck_ui/UI/LXMF/SettingsScreen.h b/lib/tdeck_ui/UI/LXMF/SettingsScreen.h index a74792e7..5e7f3fa0 100644 --- a/lib/tdeck_ui/UI/LXMF/SettingsScreen.h +++ b/lib/tdeck_ui/UI/LXMF/SettingsScreen.h @@ -56,6 +56,7 @@ struct AppSettings { uint32_t sync_interval; // seconds (0 = disabled; UI shows hours; default 14400 = 4h) bool gps_time_sync; bool transport_enabled; // Route traffic for other nodes; default off, requires reboot + bool map_download_enabled; // Explicit opt-in; visible tiles only // Propagation bool prop_auto_select; // Auto-select best propagation node @@ -85,6 +86,7 @@ struct AppSettings { sync_interval(14400), gps_time_sync(true), transport_enabled(false), + map_download_enabled(false), prop_auto_select(true), prop_selected_node(""), prop_fallback_enabled(true), @@ -293,6 +295,7 @@ private: lv_obj_t* _ta_announce_interval; lv_obj_t* _ta_sync_interval; lv_obj_t* _switch_gps_sync; + lv_obj_t* _switch_map_download; // Dangerous transport-mode section (must remain last in Settings) lv_obj_t* _switch_transport_enabled; diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index c631fcf0..cec5c957 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -1178,6 +1178,10 @@ void UIManager::set_gps(TinyGPSPlus* gps) { } } +void UIManager::set_map_download_enabled(bool enabled) { + if (_map_screen) _map_screen->setDownloadEnabled(enabled); +} + Telemetry::LocationConsentResult UIManager::start_location_sharing( const Bytes& peer_hash, const Telemetry::ShareStartOptions& options) { diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index e3c8331c..10bd0434 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -197,6 +197,7 @@ public: * @param gps TinyGPSPlus instance */ void set_gps(TinyGPSPlus* gps); + void set_map_download_enabled(bool enabled); // Location sharing is always explicit and peer-scoped. No session exists // until the UI calls start_location_sharing(). diff --git a/src/main.cpp b/src/main.cpp index 4303b249..8aa111b7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -700,6 +700,7 @@ void load_app_settings() { app_settings.sync_interval = prefs.getULong("sync_int", 14400); // Default 14400s = 4 hours app_settings.gps_time_sync = prefs.getBool("gps_sync", true); app_settings.transport_enabled = prefs.getBool("transport", false); + app_settings.map_download_enabled = prefs.getBool("map_dl", false); // Propagation app_settings.prop_auto_select = prefs.getBool("prop_auto", true); @@ -1343,6 +1344,7 @@ void setup_ui_manager() { while (1) delay(1000); } update_radio_activity_source(); + ui_manager->set_map_download_enabled(app_settings.map_download_enabled); // Set initial RNS connection status (check all interfaces) { @@ -1415,6 +1417,9 @@ void setup_ui_manager() { // Set save callback (update app_settings and apply) settings->set_save_callback([](const UI::LXMF::AppSettings& new_settings) { + if (ui_manager) { + ui_manager->set_map_download_enabled(new_settings.map_download_enabled); + } UI::LXMF::RouterLock router_lock(0); if (!router_lock.acquired()) { WARNING("Router busy; settings application deferred by user retry"); diff --git a/tests/build_scripts/test_map_screen_contract.py b/tests/build_scripts/test_map_screen_contract.py index 3a4387fa..e7197273 100644 --- a/tests/build_scripts/test_map_screen_contract.py +++ b/tests/build_scripts/test_map_screen_contract.py @@ -49,7 +49,9 @@ def test_fixed_pool_and_cache_contracts(): def test_worker_predecodes_and_render_path_has_no_io(): source = text(UI / "MapScreen.cpp") - assert "lodepng_decode24" in source + assert "lodepng_decode(" in source + assert "lodepng_inspect" in source + assert "max_output_size" in source assert "beginGet" in source and "readGetChunk" in source assert 'lv_img_set_src(tile_images_[index], &tile_descriptors_[index])' in source assert 'lv_img_set_src(tile_images_[index], "' not in source diff --git a/tests/build_scripts/test_map_tile_downloader_contract.py b/tests/build_scripts/test_map_tile_downloader_contract.py index 5dfff801..aea09eac 100644 --- a/tests/build_scripts/test_map_tile_downloader_contract.py +++ b/tests/build_scripts/test_map_tile_downloader_contract.py @@ -5,6 +5,8 @@ CORE_H = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.h" CORE_CPP = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileDownloader.cpp" ADAPTER_H = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileHttpArduino.h" ADAPTER_CPP = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileHttpArduino.cpp" +MAP_SCREEN = ROOT / "lib/tdeck_ui/UI/LXMF/MapScreen.cpp" +SETTINGS = ROOT / "lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp" def test_portable_core_is_bounded_and_allocation_free(): @@ -29,3 +31,14 @@ def test_https_adapter_verifies_peer_with_explicit_ca_and_has_no_credentials(): assert "setTimeout" in source for forbidden in ("Authorization", "Cookie", "username", "password", "SD.begin", "format(", "LittleFS"): assert forbidden not in source + + +def test_downloader_is_explicitly_opt_in_and_wired_only_for_visible_misses(): + screen = MAP_SCREEN.read_text() + settings = SETTINGS.read_text() + assert "downloadTile(request)" in screen + assert "downloader_.enqueue(request.key, request.frame_epoch)" in screen + assert "presenter_.frameEpoch() != request.frame_epoch" in screen + assert 'KEY_MAP_DOWNLOAD = "map_dl"' in settings + assert "prefs.getBool(KEY_MAP_DOWNLOAD, false)" in settings + assert "Download map tiles:" in settings diff --git a/tests/native/test_map_tile_downloader.cpp b/tests/native/test_map_tile_downloader.cpp index 6dca33c9..c938095b 100644 --- a/tests/native/test_map_tile_downloader.cpp +++ b/tests/native/test_map_tile_downloader.cpp @@ -114,7 +114,7 @@ void runUntilIdle(MapTileDownloader& d, FakeClock& clock, int limit = 40) { MapTileDownloadResult take(MapTileDownloader& d) { MapTileDownloadResult r; CHECK(d.takeResult(r)); return r; } void testDisabledByDefault() { beginTest(); FakeStore s; FakeTransport t; FakeClock c; MapTileDownloadPolicy p; MapTileDownloader d(s,t,c,p,config()); - CHECK(!p.enabled); CHECK(d.enqueue(key(),7U)==MapTileEnqueueResult::DISABLED); CHECK(d.queuedCount()==0U); CHECK(t.starts==0); } + CHECK(!p.enabled); CHECK(d.enqueue(key(),7U)==MapTileEnqueueResult::POLICY_DISABLED); CHECK(d.queuedCount()==0U); CHECK(t.starts==0); } void testCanonicalUrlAndBounds() { beginTest(); char out[MapTileDownloader::URL_CAPACITY]; CHECK(MapTileDownloader::canonicalUrl("https://tile.openstreetmap.org/",TileKey{22U,4194303U,4194303U},out,sizeof(out))==MapTileUrlResult::OK); CHECK(std::string(out)=="https://tile.openstreetmap.org/22/4194303/4194303.png"); @@ -160,9 +160,16 @@ void testDestructorAbortsOwnedResources() { beginTest(); FakeStore s; FakeTransp { MapTileDownloader d(s,t,c,enabled(),config()); CHECK(d.enqueue(key(),1U)==MapTileEnqueueResult::ACCEPTED); CHECK(d.pump()==MapTilePumpResult::PROGRESSED); CHECK(d.pump()==MapTilePumpResult::PROGRESSED); CHECK(d.pump()==MapTilePumpResult::PROGRESSED); CHECK(s.open); } CHECK(s.aborts==1); CHECK(t.closes==1); CHECK(!s.open); } +void testRuntimeDisableCancelsAllWork() { beginTest(); FakeStore s; FakeTransport t; t.body=bytes(5000U); FakeClock c; MapTileDownloader d(s,t,c,enabled(),config()); + CHECK(d.enqueue(key(1U),7U)==MapTileEnqueueResult::ACCEPTED); CHECK(d.enqueue(key(2U),8U)==MapTileEnqueueResult::ACCEPTED); + CHECK(d.pump()==MapTilePumpResult::PROGRESSED); CHECK(d.pump()==MapTilePumpResult::PROGRESSED); CHECK(d.pump()==MapTilePumpResult::PROGRESSED); CHECK(s.open); + d.setEnabled(false); runUntilIdle(d,c); CHECK(s.aborts==1); CHECK(t.closes==1); CHECK(d.queuedCount()==0U); + CHECK(take(d).code==MapTileResultCode::CANCELED); CHECK(take(d).code==MapTileResultCode::CANCELED); + CHECK(d.enqueue(key(),9U)==MapTileEnqueueResult::POLICY_DISABLED); +} void testSdDisappearanceAndMailboxBound() { beginTest(); FakeStore s; FakeTransport t; t.body=bytes(5000U); FakeClock c; MapTileDownloader d(s,t,c,enabled(),config()); CHECK(d.enqueue(key(),1U)==MapTileEnqueueResult::ACCEPTED); CHECK(d.pump()==MapTilePumpResult::PROGRESSED); CHECK(d.pump()==MapTilePumpResult::PROGRESSED); s.available=false; runUntilIdle(d,c); CHECK(take(d).code==MapTileResultCode::STORE_UNAVAILABLE); FakeStore s2; FakeTransport t2; FakeClock c2; MapTileDownloader d2(s2,t2,c2,enabled(),config()); for(std::uint32_t i=0;i<6U;++i) CHECK(d2.enqueue(key(i),i)==MapTileEnqueueResult::ACCEPTED); for(std::uint32_t i=0;i<6U;++i) CHECK(d2.cancelGeneration(i)==1U); CHECK(d2.resultCount()==MapTileDownloader::RESULT_CAPACITY); CHECK(d2.droppedResultCount()==0U); } void testStress() { beginTest(); FakeStore s; FakeTransport t; FakeClock c; MapTileDownloader d(s,t,c,enabled(),config()); for(std::uint32_t i=0;i<100000U;++i){ TileKey k=key(i&3U); const std::uint32_t g=i&7U; MapTileEnqueueResult r=d.enqueue(k,g); CHECK(r==MapTileEnqueueResult::ACCEPTED||r==MapTileEnqueueResult::DUPLICATE||r==MapTileEnqueueResult::QUEUE_FULL); if((i&3U)==0U)d.cancelGeneration(g); MapTileDownloadResult ignored; while(d.takeResult(ignored)){} } CHECK(d.queuedCount()<=MapTileDownloader::QUEUE_CAPACITY); } } -int main(){ testDisabledByDefault(); testCanonicalUrlAndBounds(); testDedupeAndQueueFullNoEviction(); testSuccessExactChunksAndPublicContract(); testStatusAndContentTypeFailures(); testLengthOverUnderAndChunkOverCap(); testTransportAndStoreFailuresAbort(); testCancellationAtStagesAndGenerationIsolation(); testTimeoutRollbackAndSaturation(); testDestructorAbortsOwnedResources(); testSdDisappearanceAndMailboxBound(); testStress(); std::cout<<"map tile downloader: "< None: env["UBSAN_OPTIONS"] = "halt_on_error=1:print_stacktrace=1" ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60, env=env) assert ran.returncode == 0, ran.stdout + ran.stderr - assert ran.stdout == "map tile downloader: 12 tests passed\n" + assert ran.stdout == "map tile downloader: 13 tests passed\n"