diff --git a/Data/system/app_icon/tactility.maps.svg b/Data/system/app_icon/tactility.maps.svg
index 2830323f3..810b99812 100644
--- a/Data/system/app_icon/tactility.maps.svg
+++ b/Data/system/app_icon/tactility.maps.svg
@@ -1 +1 @@
-
+
diff --git a/Data/system/app_icon/tactility.maps_mono.svg b/Data/system/app_icon/tactility.maps_mono.svg
index f57e88b1d..783dc5f75 100644
--- a/Data/system/app_icon/tactility.maps_mono.svg
+++ b/Data/system/app_icon/tactility.maps_mono.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/Data/system/app_icon/tactility.meshchannels.svg b/Data/system/app_icon/tactility.mesh.svg
similarity index 100%
rename from Data/system/app_icon/tactility.meshchannels.svg
rename to Data/system/app_icon/tactility.mesh.svg
diff --git a/Data/system/app_icon/tactility.meshchannels_mono.svg b/Data/system/app_icon/tactility.mesh_mono.svg
similarity index 100%
rename from Data/system/app_icon/tactility.meshchannels_mono.svg
rename to Data/system/app_icon/tactility.mesh_mono.svg
diff --git a/Devices/simulator/Source/SimulatorProfile.cpp b/Devices/simulator/Source/SimulatorProfile.cpp
index 553ad3d83..f85f858ef 100644
--- a/Devices/simulator/Source/SimulatorProfile.cpp
+++ b/Devices/simulator/Source/SimulatorProfile.cpp
@@ -28,12 +28,12 @@ constexpr auto* DISPLAY_SETTINGS_PATH = "data/tactility/appdata/tactility.displa
constexpr auto* ORIENTATION_KEY = "orientation=";
static const Profile PROFILES[PROFILE_COUNT] = {
- { "Default", 640, 320, LV_DPI_DEF, false, false, false, true, true },
- { "CL-32", 384, 168, 139, false, true, true, true, false },
- { "LilyGO T-Deck", 320, 240, 143, false, false, false, true, true },
- { "LilyGO T-Lora Pager", 480, 222, 150, false, false, false, true, false },
- { "M5Stack Cardputer", 240, 135, 139, false, true, false, true, false },
- { "M5Stack Tab5", 1280, 720, 250, true, false, false, true, true },
+ { "Default", 640, 320, LV_DPI_DEF, false, 100, 100, false, true, true },
+ { "CL-32", 384, 168, 139, false, 40, 0, true, true, false },
+ { "LilyGO T-Deck", 320, 240, 143, false, 40, 60, false, true, true },
+ { "LilyGO T-Lora Pager", 480, 222, 150, false, 40, 0, false, true, false },
+ { "M5Stack Cardputer", 240, 135, 139, false, 20, 0, false, true, false },
+ { "M5Stack Tab5", 1280, 720, 250, true, 100, 100, false, true, true },
};
static size_t activeProfileIndex = 0;
diff --git a/Devices/simulator/Source/SimulatorProfile.h b/Devices/simulator/Source/SimulatorProfile.h
index 13fdf8c5a..05d837f85 100644
--- a/Devices/simulator/Source/SimulatorProfile.h
+++ b/Devices/simulator/Source/SimulatorProfile.h
@@ -16,8 +16,10 @@ struct Profile {
uint16_t dpi; // lvgl.dpi from device.properties (if set), otherwise display.dpi
/** The default rotation: the display is taller than wide. The width and height values may be given in either order. */
bool portrait;
- /** Compact UI density */
- bool compact;
+ /** LVGL UI spacing, from lvgl.uiSpacing of the device */
+ uint8_t uiSpacing;
+ /** LVGL UI size, from lvgl.uiSize of the device */
+ uint8_t uiSize;
/** Greyscale display, which makes the monochrome theme apply */
bool mono;
bool keyboard;
diff --git a/Devices/simulator/Source/module.cpp b/Devices/simulator/Source/module.cpp
index c07d5f8c9..5360581d5 100644
--- a/Devices/simulator/Source/module.cpp
+++ b/Devices/simulator/Source/module.cpp
@@ -112,8 +112,8 @@ extern "C" {
static error_t start() {
simulator::initActiveProfile();
- const uint8_t density = simulator::getProfile(simulator::getActiveProfileIndex()).compact ? 0 : 100;
- lvgl_theme_set_default_density(density, density);
+ const auto& profile = simulator::getProfile(simulator::getActiveProfileIndex());
+ lvgl_theme_set_default_density(profile.uiSpacing, profile.uiSize);
error_t error = app_manager_add(&simulator::profileAppManifest);
if (error != ERROR_NONE) {
return error;
diff --git a/Documentation/pics/screenshot-Files.png b/Documentation/pics/screenshot-Files.png
deleted file mode 100644
index e6a579902..000000000
Binary files a/Documentation/pics/screenshot-Files.png and /dev/null differ
diff --git a/Documentation/pics/screenshot-Launcher.png b/Documentation/pics/screenshot-Launcher.png
deleted file mode 100644
index dfdfc183a..000000000
Binary files a/Documentation/pics/screenshot-Launcher.png and /dev/null differ
diff --git a/Documentation/pics/screenshot-Settings.png b/Documentation/pics/screenshot-Settings.png
deleted file mode 100644
index bc92adeaf..000000000
Binary files a/Documentation/pics/screenshot-Settings.png and /dev/null differ
diff --git a/Documentation/pics/screenshot-Terminal.png b/Documentation/pics/screenshot-Terminal.png
deleted file mode 100644
index 01a46e03c..000000000
Binary files a/Documentation/pics/screenshot-Terminal.png and /dev/null differ
diff --git a/Modules/coreutils-module/source/apps/log.cpp b/Modules/coreutils-module/source/apps/log.cpp
new file mode 100644
index 000000000..0e7f03d7a
--- /dev/null
+++ b/Modules/coreutils-module/source/apps/log.cpp
@@ -0,0 +1,143 @@
+#include
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace coreutils::log {
+
+constexpr size_t RING_CAPACITY = 2048;
+constexpr size_t CHUNK_SIZE = 512;
+constexpr int POLL_INTERVAL_MS = 50;
+
+/** Hands lines from the log drain task to the app's task. It only holds what wasn't printed yet. */
+struct Ring {
+ Mutex mutex;
+ size_t head = 0;
+ size_t count = 0;
+ /** Lines that were dropped, also when the callback couldn't get the mutex */
+ std::atomic dropped { 0 };
+ char data[RING_CAPACITY];
+};
+
+static volatile sig_atomic_t stopRequested = 0;
+
+static void onSignal(int) {
+ stopRequested = 1;
+}
+
+/** Never waits, so logging isn't held up. Whole lines only: a line that doesn't fit, or arrives while the ring is busy, is dropped and counted. */
+static void onLogLine(const char* line, size_t length, void* context) {
+ auto* ring = static_cast(context);
+ if (!mutex_try_lock(&ring->mutex, 0)) {
+ ring->dropped++;
+ return;
+ }
+ if (length > RING_CAPACITY - ring->count) {
+ ring->dropped++;
+ } else {
+ size_t tail = (ring->head + ring->count) % RING_CAPACITY;
+ for (size_t i = 0; i < length; i++) {
+ ring->data[(tail + i) % RING_CAPACITY] = line[i];
+ }
+ ring->count += length;
+ }
+ mutex_unlock(&ring->mutex);
+}
+
+/** Writes at most one ring's worth, so the main loop keeps checking for input while logging is busy */
+static void drain(Ring* ring) {
+ char chunk[CHUNK_SIZE];
+ size_t written = 0;
+ while (written < RING_CAPACITY) {
+ size_t length = 0;
+ mutex_lock(&ring->mutex);
+ length = ring->count < sizeof(chunk) ? ring->count : sizeof(chunk);
+ for (size_t i = 0; i < length; i++) {
+ chunk[i] = ring->data[(ring->head + i) % RING_CAPACITY];
+ }
+ ring->head = (ring->head + length) % RING_CAPACITY;
+ ring->count -= length;
+ mutex_unlock(&ring->mutex);
+
+ const size_t dropped = ring->dropped.exchange(0);
+
+ if (dropped > 0) {
+ printf("[%zu log lines dropped]\n", dropped);
+ }
+ if (length == 0) {
+ break;
+ }
+ fwrite(chunk, 1, length, stdout);
+ written += length;
+ }
+ fflush(stdout);
+}
+
+/** Returns true when any key was typed or stdin was closed */
+static bool waitForInput() {
+ pollfd descriptor = { .fd = STDIN_FILENO, .events = POLLIN, .revents = 0 };
+ const int result = poll(&descriptor, 1, POLL_INTERVAL_MS);
+ if (result <= 0) {
+ return false;
+ }
+ if (descriptor.revents & (POLLHUP | POLLERR)) {
+ return true;
+ }
+ char input[16];
+ read(STDIN_FILENO, input, sizeof(input));
+ return true;
+}
+
+static int32_t main(int, char*[]) {
+ MemoryPolicy policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 };
+ auto* ring = static_cast(memory_alloc_with_policy(sizeof(Ring), &policy));
+ if (ring == nullptr) {
+ fprintf(stderr, "log: out of memory\n");
+ return 1;
+ }
+ new (ring) Ring();
+ mutex_construct(&ring->mutex);
+
+ stopRequested = 0;
+ signal(SIGINT, onSignal);
+ signal(SIGTERM, onSignal);
+ signal(SIGHUP, onSignal);
+
+ if (!log_subscribe(onLogLine, ring)) {
+ fprintf(stderr, "log: too many log listeners\n");
+ mutex_destruct(&ring->mutex);
+ memory_free(ring);
+ return 1;
+ }
+
+ while (!stopRequested) {
+ if (waitForInput()) {
+ break;
+ }
+ drain(ring);
+ }
+
+ log_unsubscribe(onLogLine, ring);
+ mutex_destruct(&ring->mutex);
+ memory_free(ring);
+ return 0;
+}
+
+extern const ::AppManifest manifest = {
+ .id = "log",
+ .name = "log",
+ .category = APP_CATEGORY_SYSTEM,
+ .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) },
+ .flags = APP_MANIFEST_FLAG_HEADLESS,
+ .stack = { .depth = 4096, .desired_memory_capability = 0 },
+};
+
+} // namespace coreutils::log
diff --git a/Modules/coreutils-module/source/module.cpp b/Modules/coreutils-module/source/module.cpp
index 1fae962a6..174bb6967 100644
--- a/Modules/coreutils-module/source/module.cpp
+++ b/Modules/coreutils-module/source/module.cpp
@@ -16,6 +16,7 @@ namespace coreutils {
namespace free { extern const ::AppManifest manifest; }
namespace head { extern const ::AppManifest manifest; }
namespace help { extern const ::AppManifest manifest; }
+ namespace log { extern const ::AppManifest manifest; }
namespace ls { extern const ::AppManifest manifest; }
namespace mkdir { extern const ::AppManifest manifest; }
namespace mv { extern const ::AppManifest manifest; }
@@ -40,6 +41,7 @@ static error_t start() {
if ((error = app_manager_add(&coreutils::free::manifest)) != ERROR_NONE) return error;
if ((error = app_manager_add(&coreutils::head::manifest)) != ERROR_NONE) return error;
if ((error = app_manager_add(&coreutils::help::manifest)) != ERROR_NONE) return error;
+ if ((error = app_manager_add(&coreutils::log::manifest)) != ERROR_NONE) return error;
if ((error = app_manager_add(&coreutils::ls::manifest)) != ERROR_NONE) return error;
if ((error = app_manager_add(&coreutils::mkdir::manifest)) != ERROR_NONE) return error;
if ((error = app_manager_add(&coreutils::mv::manifest)) != ERROR_NONE) return error;
@@ -62,6 +64,7 @@ static error_t stop() {
app_manager_remove(coreutils::free::manifest.id);
app_manager_remove(coreutils::head::manifest.id);
app_manager_remove(coreutils::help::manifest.id);
+ app_manager_remove(coreutils::log::manifest.id);
app_manager_remove(coreutils::ls::manifest.id);
app_manager_remove(coreutils::mkdir::manifest.id);
app_manager_remove(coreutils::mv::manifest.id);
diff --git a/README.md b/README.md
index 53b793ae0..6864aa6b2 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,11 @@
Tactility is an operating system that focuses on the ESP32 microcontroller family.
It can load apps from SD card that were built with native C/C++ SDKs.
-See [https://tactilityproject.org](https://tactilityproject.org) for more information.
+Links:
-
+- [Main website](https://tactilityproject.org)
+- [Installer](https://install.tactilityproject.org)
+- [Documentation](https://docs.tactilityproject.org)
## License
diff --git a/Tactility/Private/Tactility/app/terminal/KeyboardInput.h b/Tactility/Private/Tactility/app/terminal/KeyboardInput.h
index beda05d39..89528fa0a 100644
--- a/Tactility/Private/Tactility/app/terminal/KeyboardInput.h
+++ b/Tactility/Private/Tactility/app/terminal/KeyboardInput.h
@@ -4,8 +4,7 @@
#include
#include
-
-#include
+#include
/** Tracks every KEYBOARD_TYPE device, re-scanning at most once a second so a USB keyboard
* plugged/unplugged after the terminal starts is picked up without restarting the app. */
@@ -30,13 +29,28 @@ class KeyboardInput {
private:
static constexpr uint32_t REFRESH_INTERVAL_MS = 1000;
+ /** Keyboards may keep reporting a held key as pressed, so a key only counts on its press edge */
+ struct Keyboard {
+ static constexpr size_t HELD_CAPACITY = 8;
+
+ Device* device;
+ uint32_t held[HELD_CAPACITY] = {};
+ size_t heldCount = 0;
+
+ bool isHeld(uint32_t key) const;
+ void addHeld(uint32_t key);
+ void removeHeld(uint32_t key);
+ void clearHeld() { heldCount = 0; }
+ };
+
static bool collect(Device* device, void* context);
void refresh();
void rescan();
void applyExclusive();
+ void awaitKeyRelease();
- std::vector devices_;
+ tt::std::vector devices_;
TickType_t lastRefresh_ = 0;
bool exclusive_ = false;
};
diff --git a/Tactility/Private/Tactility/lvgl/Fonts.h b/Tactility/Private/Tactility/lvgl/Fonts.h
index 159459566..cbeba0f4b 100644
--- a/Tactility/Private/Tactility/lvgl/Fonts.h
+++ b/Tactility/Private/Tactility/lvgl/Fonts.h
@@ -13,9 +13,9 @@ struct FontConfiguration {
/** Default text font size in pixels, other font sizes are derived from it */
uint16_t defaultSize;
/** TrueType font for regular text, or empty for the system font */
- std::string regularFontPath;
+ ::std::string regularFontPath;
/** TrueType font for monospace text, or empty for the system font */
- std::string monoFontPath;
+ ::std::string monoFontPath;
bool operator==(const FontConfiguration&) const = default;
};
@@ -27,7 +27,7 @@ struct FontConfiguration {
constexpr size_t LARGE_FONT_CHARACTER_COUNT = 1000;
/** @return the number of characters in a TrueType font, or 0 when it can't be read */
-size_t getTtfCharacterCount(const std::string& path);
+size_t getTtfCharacterCount(const ::std::string& path);
/**
* Changing fonts generates them on the device, which needs at least 2 MB of memory in total.
diff --git a/Tactility/Source/InitApps.cpp b/Tactility/Source/InitApps.cpp
index 816623c04..d48e98b76 100644
--- a/Tactility/Source/InitApps.cpp
+++ b/Tactility/Source/InitApps.cpp
@@ -71,8 +71,7 @@ namespace app {
namespace terminal { extern const ::AppManifest manifest; }
namespace timedatesettings { extern const ::AppManifest manifest; }
namespace meshchat {
- extern const ::AppManifest contactsManifest;
- extern const ::AppManifest channelsManifest;
+ extern const ::AppManifest manifest;
extern const ::AppManifest conversationManifest;
extern const ::AppManifest addContactManifest;
}
@@ -169,8 +168,7 @@ static void registerInternalApps() {
}
if (settings::radio::hasRadio()) {
- app_manager_add(&app::meshchat::contactsManifest);
- app_manager_add(&app::meshchat::channelsManifest);
+ app_manager_add(&app::meshchat::manifest);
app_manager_add(&app::meshchat::conversationManifest);
app_manager_add(&app::meshchat::addContactManifest);
app_manager_add(&app::meshsettings::manifest);
diff --git a/Tactility/Source/app/TileGrid.cpp b/Tactility/Source/app/TileGrid.cpp
index 7d584491a..8472b00db 100644
--- a/Tactility/Source/app/TileGrid.cpp
+++ b/Tactility/Source/app/TileGrid.cpp
@@ -46,7 +46,7 @@ PageLayout computePageLayout(lv_obj_t* grid) {
const auto pad = icon_size / 16;
// The focus ring is drawn inside the frame, at most 2dp wide, and leaves 1dp to the icon
const auto frame_pad = LV_DPX(2) + LV_DPX(1);
- const auto label_gap = LV_DPX(2);
+ const auto label_gap = lvgl_theme_lerp(LV_DPX(1), LV_DPX(4), lvgl_theme_get_spacing());
const auto gap = TILE_GAP;
const auto text_height = lv_font_get_line_height(lvgl_get_text_font(FONT_SIZE_SMALL));
// The label needs room for a few characters, also when the icon font is smaller (e.g. a fallback)
diff --git a/Tactility/Source/app/files/View.cpp b/Tactility/Source/app/files/View.cpp
index e1bc5da9e..310ab9214 100644
--- a/Tactility/Source/app/files/View.cpp
+++ b/Tactility/Source/app/files/View.cpp
@@ -386,6 +386,7 @@ View::View(const std::shared_ptr& state, TaskEventGroup* eventGroup) :
.resolveImage = resolveImage,
.resolveIcon = resolveIcon,
.onPageChanged = onPageChanged,
+ .onTilesCleared = nullptr,
.userData = this
}) {
check(task_event_group_claim_bit(eventGroup, &pasteBit) == ERROR_NONE);
diff --git a/Tactility/Source/app/launcher/AppsMode.cpp b/Tactility/Source/app/launcher/AppsMode.cpp
index bf659702f..0974710d9 100644
--- a/Tactility/Source/app/launcher/AppsMode.cpp
+++ b/Tactility/Source/app/launcher/AppsMode.cpp
@@ -64,8 +64,7 @@ const BuiltInIconAsset BUILT_IN_ICONS[] = {
BUILT_IN_ICON("tactility.files", tactility_files),
BUILT_IN_ICON("tactility.i2cscanner", tactility_i2cscanner),
BUILT_IN_ICON("tactility.maps", tactility_maps),
- BUILT_IN_ICON("tactility.meshchannels", tactility_meshchannels),
- BUILT_IN_ICON("tactility.meshcontacts", tactility_meshcontacts),
+ BUILT_IN_ICON("tactility.mesh", tactility_mesh),
BUILT_IN_ICON("tactility.notes", tactility_notes),
BUILT_IN_ICON("tactility.systeminfo", tactility_systeminfo),
BUILT_IN_ICON("tactility.terminal", tactility_terminal),
diff --git a/Tactility/Source/app/meshchat/MeshChannels.cpp b/Tactility/Source/app/meshchat/MeshApp.cpp
similarity index 56%
rename from Tactility/Source/app/meshchat/MeshChannels.cpp
rename to Tactility/Source/app/meshchat/MeshApp.cpp
index 5ad7f5dc4..b98c1284e 100644
--- a/Tactility/Source/app/meshchat/MeshChannels.cpp
+++ b/Tactility/Source/app/meshchat/MeshApp.cpp
@@ -2,6 +2,7 @@
#include
#include
#include
+#include
#include
#include
@@ -20,6 +21,8 @@
#include
#include
+#include
+#include
#include
#include
#include
@@ -27,36 +30,45 @@
#include
#include
#include
+#include
#include
namespace tt::app::meshchat {
-extern const ::AppManifest channelsManifest;
+extern const ::AppManifest manifest;
namespace {
-constexpr auto* TAG = "MeshChannels";
+constexpr auto* TAG = "Mesh";
constexpr size_t CHANNEL_LIMIT = 8;
+constexpr size_t CONTACT_LIMIT = 64;
+
+enum class ViewMode { Menu, Contacts, Channels };
struct Context {
uint32_t appInstanceId;
TaskEventGroup* eventGroup = nullptr;
uint32_t openBit = 0;
+ uint32_t menuBit = 0;
uint32_t settingsBit = 0;
uint32_t addChannelBit = 0;
+ uint32_t showChannelsBit = 0;
+ uint32_t showContactsBit = 0;
+ ViewMode mode = ViewMode::Menu;
/** Written by the LVGL thread before it signals openBit */
::std::atomic openIndex = 0;
+ uint32_t menuDialogId = 0;
uint32_t addTypeDialogId = 0;
/** The kind of channel for each option of the open selection dialog */
::std::vector addTypes;
/** Whether the mesh network supports any way to add a channel. Read by the app's task. */
bool canAddChannel = false;
- // Valid while the window is shown
- lv_obj_t* addButton = nullptr;
uint32_t joinChannelDialogId = 0;
uint32_t createChannelDialogId = 0;
uint32_t privateNameDialogId = 0;
uint32_t privateKeyDialogId = 0;
+ uint32_t contactNameDialogId = 0;
+ uint32_t contactKeyDialogId = 0;
uint32_t messageDialogId = 0;
AppStream channelStream {};
char channelBuffer[MESH_NAME_MAX_LENGTH + 1] = {};
@@ -68,23 +80,56 @@ struct Context {
char privateKeyBuffer[MESH_CHANNEL_KEY_MAX_LENGTH * 2 + 1] = {};
/** The name that was entered while the key is asked */
::std::string pendingChannelName;
+ AppStream contactNameStream {};
+ char contactNameBuffer[MESH_NAME_MAX_LENGTH + 1] = {};
+ AppStream contactKeyStream {};
+ char contactKeyBuffer[MESH_ADDRESS_MAX_LENGTH * 2 + 1] = {};
+ /** The name that was entered while the key is asked */
+ ::std::string pendingContactName;
tt::std::vector channels;
+ tt::std::vector contacts;
// Valid while the window is shown
+ lv_obj_t* root = nullptr;
lv_obj_t* networkHeader = nullptr;
lv_obj_t* content = nullptr;
+ lv_obj_t* addButton = nullptr;
+ NetworkInfo network;
+};
+
+/** What loadData() fetched, committed to the context by commitData() */
+struct Snapshot {
NetworkInfo network;
+ bool canAddChannel = false;
+ tt::std::vector channels;
+ tt::std::vector contacts;
};
/** Queries the service thread, so it's called without the LVGL lock */
-void loadData(Context* ctx) {
- ctx->network = loadNetworkInfo();
- ctx->canAddChannel = mesh_supports(MESH_FEATURE_NAMED_CHANNELS) || mesh_supports(MESH_FEATURE_KEYED_CHANNELS);
- ctx->channels.assign(CHANNEL_LIMIT, MeshChannel {});
+Snapshot loadData() {
+ Snapshot snapshot;
+ snapshot.network = loadNetworkInfo();
+ snapshot.canAddChannel = mesh_supports(MESH_FEATURE_NAMED_CHANNELS) || mesh_supports(MESH_FEATURE_KEYED_CHANNELS);
+ snapshot.channels.assign(CHANNEL_LIMIT, MeshChannel {});
size_t count = 0;
- if (mesh_get_channels(ctx->channels.data(), CHANNEL_LIMIT, &count) != ERROR_NONE) {
+ if (mesh_get_channels(snapshot.channels.data(), CHANNEL_LIMIT, &count) != ERROR_NONE) {
+ count = 0;
+ }
+ snapshot.channels.resize(count);
+ snapshot.contacts.assign(CONTACT_LIMIT, MeshContact {});
+ count = 0;
+ if (mesh_get_contacts(snapshot.contacts.data(), CONTACT_LIMIT, &count) != ERROR_NONE) {
count = 0;
}
- ctx->channels.resize(count);
+ snapshot.contacts.resize(count);
+ return snapshot;
+}
+
+/** Requires the LVGL lock, because create_widgets can run on another thread and reads the cached state */
+void commitData(Context* ctx, Snapshot&& snapshot) {
+ ctx->network = snapshot.network;
+ ctx->canAddChannel = snapshot.canAddChannel;
+ ctx->channels = ::std::move(snapshot.channels);
+ ctx->contacts = ::std::move(snapshot.contacts);
}
lv_obj_t* addListButton(lv_obj_t* list, const char* text, lv_event_cb_t callback, void* callbackData, intptr_t index) {
@@ -94,21 +139,22 @@ lv_obj_t* addListButton(lv_obj_t* list, const char* text, lv_event_cb_t callback
return button;
}
-void onChannelPressed(lv_event_t* event) {
+void onItemPressed(lv_event_t* event) {
auto* ctx = static_cast(lv_event_get_user_data(event));
ctx->openIndex = (int32_t)reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event)));
task_event_group_signal(ctx->eventGroup, ctx->openBit);
}
-void onAddChannelPressed(lv_event_t* event) {
+void signalBit(lv_event_t* event, uint32_t Context::* bit) {
auto* ctx = static_cast(lv_event_get_user_data(event));
- task_event_group_signal(ctx->eventGroup, ctx->addChannelBit);
+ task_event_group_signal(ctx->eventGroup, ctx->*bit);
}
-void onSettingsPressed(lv_event_t* event) {
- auto* ctx = static_cast(lv_event_get_user_data(event));
- task_event_group_signal(ctx->eventGroup, ctx->settingsBit);
-}
+void onAddChannelPressed(lv_event_t* event) { signalBit(event, &Context::addChannelBit); }
+void onMenuPressed(lv_event_t* event) { signalBit(event, &Context::menuBit); }
+void onSettingsPressed(lv_event_t* event) { signalBit(event, &Context::settingsBit); }
+void onShowChannelsPressed(lv_event_t* event) { signalBit(event, &Context::showChannelsBit); }
+void onShowContactsPressed(lv_event_t* event) { signalBit(event, &Context::showContactsBit); }
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast(lv_event_get_user_data(event));
@@ -120,30 +166,59 @@ void updateWidgets(Context* ctx) {
if (ctx->content == nullptr) {
return;
}
+ if (ctx->mode == ViewMode::Menu) {
+ return;
+ }
updateNetworkHeader(ctx->networkHeader, ctx->network);
- lv_obj_set_hidden(ctx->addButton, !ctx->canAddChannel);
lv_obj_clean(ctx->content);
- auto* channelList = createCard(ctx->content);
- for (size_t i = 0; i < ctx->channels.size(); i++) {
- addListButton(channelList, ctx->channels[i].name, onChannelPressed, ctx, (intptr_t)i);
+ auto* list = createCard(ctx->content);
+ if (ctx->mode == ViewMode::Channels) {
+ lv_obj_set_hidden(ctx->addButton, !ctx->canAddChannel);
+ for (size_t i = 0; i < ctx->channels.size(); i++) {
+ addListButton(list, ctx->channels[i].name, onItemPressed, ctx, (intptr_t)i);
+ }
+ } else {
+ for (size_t i = 0; i < ctx->contacts.size(); i++) {
+ addListButton(list, ctx->contacts[i].name, onItemPressed, ctx, (intptr_t)i);
+ }
+ if (ctx->contacts.empty()) {
+ lvgl_list_add_text(list, "No contacts yet. Add a node that you've heard, or wait for a direct message.");
+ }
}
- lvgl_list_hide_last_divider(channelList);
+ lvgl_list_hide_last_divider(list);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast(userData);
+ ctx->root = parent;
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
- auto* toolbar = lvgl_toolbar_create(parent, "Mesh Channels");
- lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
- ctx->addButton = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddChannelPressed, ctx);
- lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsPressed, ctx);
+ const char* title = ctx->mode == ViewMode::Channels ? "Channels" : ctx->mode == ViewMode::Contacts ? "Contacts" : "Mesh";
+ auto* toolbar = lvgl_toolbar_create(parent, title);
+ lvgl_toolbar_set_nav_action(toolbar, ctx->mode == ViewMode::Menu ? LV_SYMBOL_CLOSE : LV_SYMBOL_LEFT, onBackPressed, ctx);
+
+ if (ctx->mode == ViewMode::Menu) {
+ lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsPressed, ctx);
+ auto* menuContent = createContent(parent);
+ ctx->content = menuContent;
+ auto* list = createCard(menuContent);
+ addListButton(list, "Channels", onShowChannelsPressed, ctx, 0);
+ addListButton(list, "Contacts", onShowContactsPressed, ctx, 0);
+ lvgl_list_hide_last_divider(list);
+ return;
+ }
- ctx->networkHeader = createNetworkHeader(parent);
+ if (ctx->mode == ViewMode::Channels) {
+ ctx->addButton = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddChannelPressed, ctx);
+ } else {
+ auto* menuButton = lvgl_toolbar_add_text_button_action(toolbar, LVGL_ICON_SHARED_MORE_VERT, onMenuPressed, ctx);
+ lv_obj_set_style_text_font(menuButton, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT);
+ }
+ ctx->networkHeader = createNetworkHeader(parent);
ctx->content = createContent(parent);
updateWidgets(ctx);
@@ -151,17 +226,42 @@ void createWidgets(lv_obj_t* parent, void* userData) {
void destroyWidgets(void* userData) {
auto* ctx = static_cast(userData);
+ ctx->root = nullptr;
+ ctx->networkHeader = nullptr;
+ ctx->content = nullptr;
+ ctx->addButton = nullptr;
+}
+
+/** Requires the LVGL lock */
+void rebuildWidgets(Context* ctx) {
+ if (ctx->root == nullptr) {
+ return;
+ }
+ auto* root = ctx->root;
+ lv_obj_clean(root);
ctx->networkHeader = nullptr;
ctx->content = nullptr;
ctx->addButton = nullptr;
+ createWidgets(root, ctx);
+}
+
+void setMode(Context* ctx, ViewMode mode) {
+ lvgl_lock();
+ ctx->mode = mode;
+ rebuildWidgets(ctx);
+ lvgl_unlock();
}
void startConversation(Context* ctx) {
const auto index = (size_t)ctx->openIndex.load();
- if (index < ctx->channels.size()) {
+ if (ctx->mode == ViewMode::Channels && index < ctx->channels.size()) {
const ::std::string channelIndex = ::std::to_string(ctx->channels[index].index);
const char* argv[] = { KIND_CHANNEL, channelIndex.c_str(), ctx->channels[index].name };
startApp(CONVERSATION_APP_ID, 3, argv);
+ } else if (ctx->mode == ViewMode::Contacts && index < ctx->contacts.size()) {
+ const ::std::string address = addressToHex(ctx->contacts[index].address);
+ const char* argv[] = { KIND_CONTACT, address.c_str(), ctx->contacts[index].name };
+ startApp(CONVERSATION_APP_ID, 3, argv);
}
}
@@ -343,6 +443,114 @@ void onPrivateKeyResult(Context* ctx, int32_t result) {
ctx->pendingChannelName.clear();
}
+void startAddContact() {
+ startApp(ADD_CONTACT_APP_ID, 0, nullptr);
+}
+
+/** Shows this node as a QR code that other mesh apps can scan */
+void showOwnQr(Context* ctx) {
+ char uri[256];
+ const error_t error = mesh_get_contact_uri(nullptr, uri, sizeof(uri));
+ if (error == ERROR_NONE) {
+ qrcode::start(uri);
+ } else if (error == ERROR_NOT_SUPPORTED) {
+ showMessage(ctx, "Not available", "This mesh network can't share a contact as a QR code.");
+ } else {
+ LOG_E(TAG, "Failed to get the contact: %s", error_to_string(error));
+ showMessage(ctx, "Couldn't create QR code", error == ERROR_INVALID_STATE ? getNotReadyText().c_str() : error_to_string(error));
+ }
+}
+
+void startContactNameDialog(Context* ctx) {
+ if (ctx->contactNameDialogId != 0 || ctx->contactKeyDialogId != 0) {
+ return;
+ }
+ ctx->contactNameDialogId = inputdialog::start(
+ ctx->appInstanceId,
+ "Add contact",
+ "Name of the contact",
+ "",
+ ctx->contactNameStream,
+ ctx->contactNameBuffer,
+ sizeof(ctx->contactNameBuffer),
+ ctx->eventGroup
+ );
+}
+
+void onContactNameResult(Context* ctx, int32_t result) {
+ if (result == 0) {
+ char name[sizeof(Context::contactNameBuffer)] = {};
+ const size_t length = app_stream_read(&ctx->contactNameStream, name, sizeof(name) - 1);
+ name[length] = '\0';
+ ctx->pendingContactName = name;
+ }
+ app_stream_unsubscribe(&ctx->contactNameStream);
+ if (result != 0 || ctx->pendingContactName.empty()) {
+ ctx->pendingContactName.clear();
+ return;
+ }
+ ctx->contactKeyDialogId = inputdialog::start(
+ ctx->appInstanceId,
+ "Public key",
+ "Public key of the contact in hexadecimal",
+ "",
+ ctx->contactKeyStream,
+ ctx->contactKeyBuffer,
+ sizeof(ctx->contactKeyBuffer),
+ ctx->eventGroup
+ );
+}
+
+void onContactKeyResult(Context* ctx, int32_t result) {
+ if (result == 0) {
+ char key[sizeof(Context::contactKeyBuffer)] = {};
+ const size_t length = app_stream_read(&ctx->contactKeyStream, key, sizeof(key) - 1);
+ key[length] = '\0';
+ MeshAddress address;
+ if (!hexToAddress(key, &address)) {
+ showMessage(ctx, "Invalid key", "Enter the public key as hexadecimal characters, two for every byte.");
+ } else {
+ const error_t error = mesh_add_contact(&address, ctx->pendingContactName.c_str());
+ if (error == ERROR_INVALID_ARGUMENT) {
+ showMessage(ctx, "Invalid key", "This mesh network expects a public key of a different length.");
+ } else if (error != ERROR_NONE) {
+ LOG_E(TAG, "Failed to add contact: %s", error_to_string(error));
+ showMessage(ctx, "Couldn't add contact", error == ERROR_RESOURCE ? "There are too many contacts." : error_to_string(error));
+ }
+ }
+ }
+ app_stream_unsubscribe(&ctx->contactKeyStream);
+ ctx->pendingContactName.clear();
+}
+
+/** The order of these matches the options of the selection dialog */
+enum class MenuItem { AddByKey, AddSeen, ShowQr };
+
+void startMenu(Context* ctx) {
+ if (ctx->menuDialogId != 0) {
+ return;
+ }
+ ctx->menuDialogId = selectiondialog::start(ctx->appInstanceId, "Mesh Contacts", {
+ "Add by key",
+ "Add seen",
+ "Show my QR"
+ });
+}
+
+void onMenuSelected(Context* ctx, int32_t selected) {
+ switch (static_cast