From 12e8a0e098b6bc8ac2d8c51db7e80275319476dc Mon Sep 17 00:00:00 2001 From: Stepan Usatiuk Date: Tue, 21 Oct 2025 00:42:35 +0200 Subject: [PATCH] janky notifications --- .../AppIcon.appiconset/Contents.json | 24 - .../AppIcon.appiconset/cardboy-icon-dark.png | Bin 10581 -> 0 bytes .../AppIcon.appiconset/cardboy-icon-dark.svg | 1356 ----------------- .../cardboy-icon-tinted.png | Bin 8938 -> 0 bytes .../cardboy-icon-tinted.svg | 1356 ----------------- .../AppIcon.appiconset/cardboy-icon.png | Bin 10934 -> 13171 bytes .../AppIcon.appiconset/cardboy-icon.svg | 84 +- .../cardboy-companion/ContentView.swift | 5 + .../cardboy-companion/Info.plist | 2 + .../cardboy-companion/TimeSyncManager.swift | 83 +- .../cardboy/backend/esp/time_sync_service.hpp | 11 +- .../include/cardboy/backend/esp_backend.hpp | 2 + .../backend-esp/src/esp_backend.cpp | 102 +- .../backend-esp/src/time_sync_service.cpp | 426 +++++- .../apps/lockscreen/src/lockscreen_app.cpp | 299 +++- .../include/cardboy/sdk/services.hpp | 21 + .../cardboy/backend/desktop_backend.hpp | 18 + .../backends/desktop/src/desktop_backend.cpp | 55 + .../include/cardboy/sdk/app_framework.hpp | 3 + 19 files changed, 1012 insertions(+), 2835 deletions(-) delete mode 100644 Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.png delete mode 100644 Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.svg delete mode 100644 Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.png delete mode 100644 Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.svg diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/Contents.json b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/Contents.json index b6e521d..449cc11 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -6,30 +6,6 @@ "platform" : "ios", "size" : "1024x1024" }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "cardboy-icon-dark.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "filename" : "cardboy-icon-tinted.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } ], "info" : { "author" : "xcode", diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.png b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.png deleted file mode 100644 index 10137e1d0efeda6627867e81c171751cfca4a980..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10581 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 LwQ@hR`szObAX=nn diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.svg b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.svg deleted file mode 100644 index a724a49..0000000 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.svg +++ /dev/null @@ -1,1356 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.png b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.png deleted file mode 100644 index 3aec4643a4b95b97a96d2b84eea173c70205e8b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8938 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 uPxSm1qq@4)BJcM4#p%B!_@69+IGo~S - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.png b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.png index 21122add02069edfb8f3118ad0d3f37186b482ed..cff0ef5a09133a0e9c14f305eb1de861b0121c2b 100644 GIT binary patch literal 13171 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 A(*OVf literal 10934 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 + + + + + + + + + + + + @@ -1247,109 +1281,109 @@ inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" - inkscape:zoom="0.92030791" - inkscape:cx="606.86211" - inkscape:cy="530.80061" - inkscape:window-width="1728" - inkscape:window-height="1186" - inkscape:window-x="832" - inkscape:window-y="99" - inkscape:window-maximized="0" + inkscape:zoom="0.76316607" + inkscape:cx="429.13333" + inkscape:cy="386.54758" + inkscape:window-width="2560" + inkscape:window-height="1381" + inkscape:window-x="0" + inkscape:window-y="31" + inkscape:window-maximized="1" inkscape:current-layer="svg6" /> diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift index ec7a727..f0f290c 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift @@ -77,6 +77,11 @@ private struct TimeSyncTabView: View { } .buttonStyle(.bordered) } + + Button(action: manager.sendTestNotification) { + Label("Send Test Notification", systemImage: "bell.badge.waveform") + } + .buttonStyle(.bordered) } VStack(spacing: 8) { diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Info.plist b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Info.plist index e999718..2c0dafb 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Info.plist +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Info.plist @@ -6,5 +6,7 @@ bluetooth-central + NSUserNotificationUsageDescription + Allow Cardboy Companion to send local notifications for testing. diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift index 8389e6b..4122e40 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift @@ -2,8 +2,9 @@ import Combine import CoreBluetooth import Foundation import UniformTypeIdentifiers +import UserNotifications -final class TimeSyncManager: NSObject, ObservableObject { +final class TimeSyncManager: NSObject, ObservableObject, UNUserNotificationCenterDelegate { enum ConnectionState: String { case idle = "Idle" case scanning = "Scanning" @@ -116,6 +117,7 @@ final class TimeSyncManager: NSObject, ObservableObject { private var pendingListOperationID: UUID? private var simpleOperationID: UUID? private var pendingDirectoryRequest: (path: String, operationID: UUID)? + private let notificationCenter = UNUserNotificationCenter.current() private struct UploadState { let id: UUID @@ -141,6 +143,9 @@ final class TimeSyncManager: NSObject, ObservableObject { // Force central manager to initialise immediately so state updates arrive right away. _ = central + // Ensure we can present notifications while app is foreground + notificationCenter.delegate = self + if central.state == .poweredOn { startScanning() } @@ -201,6 +206,70 @@ final class TimeSyncManager: NSObject, ObservableObject { statusMessage = "Time synced at \(timeString)." } + func sendTestNotification() { + func scheduleNotification() { + DispatchQueue.main.async { + self.statusMessage = "Scheduling test notification…" + } + let content = UNMutableNotificationContent() + content.title = "Cardboy Test Notification" + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .medium + content.body = "Triggered at \(formatter.string(from: Date()))" + content.sound = UNNotificationSound.default + + // Schedule slightly later so the notification is visible when the app is foreground + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) + let request = UNNotificationRequest(identifier: "cardboy-test-\(UUID().uuidString)", + content: content, + trigger: trigger) + + notificationCenter.add(request) { error in + DispatchQueue.main.async { + if let error { + self.statusMessage = "Failed to schedule test notification: \(error.localizedDescription)" + } else { + self.statusMessage = "Test notification scheduled." + } + } + } + } + + func requestPermissionAndSchedule() { + notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + if let error { + DispatchQueue.main.async { + self.statusMessage = "Notification permission error: \(error.localizedDescription)" + } + return + } + if granted { + scheduleNotification() + } else { + DispatchQueue.main.async { + self.statusMessage = "Enable notifications in Settings to test." + } + } + } + } + + notificationCenter.getNotificationSettings { settings in + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + scheduleNotification() + case .notDetermined: + requestPermissionAndSchedule() + case .denied: + DispatchQueue.main.async { + self.statusMessage = "Enable notifications in Settings to test." + } + @unknown default: + requestPermissionAndSchedule() + } + } + } + // MARK: - File operations exposed to UI func refreshDirectory() { @@ -763,7 +832,17 @@ extension TimeSyncManager: CBCentralManagerDelegate { statusMessage = "Turn on Bluetooth to continue." stopScanning() case .poweredOn: - startScanning() + // If there are peripherals already connected that match our services, restore connection. + let connected = central.retrieveConnectedPeripherals(withServices: [timeServiceUUID, fileServiceUUID]) + if let restored = connected.first { + statusMessage = "Restoring connection…" + connectionState = .connecting + targetPeripheral = restored + restored.delegate = self + central.connect(restored, options: nil) + } else { + startScanning() + } @unknown default: connectionState = .failed statusMessage = "Unknown Bluetooth state." diff --git a/Firmware/components/backend-esp/include/cardboy/backend/esp/time_sync_service.hpp b/Firmware/components/backend-esp/include/cardboy/backend/esp/time_sync_service.hpp index cbd5abd..a296cf9 100644 --- a/Firmware/components/backend-esp/include/cardboy/backend/esp/time_sync_service.hpp +++ b/Firmware/components/backend-esp/include/cardboy/backend/esp/time_sync_service.hpp @@ -1,5 +1,9 @@ #pragma once +namespace cardboy::sdk { +class INotificationCenter; +} // namespace cardboy::sdk + namespace cardboy::backend::esp { /** @@ -16,5 +20,10 @@ void ensure_time_sync_service_started(); */ void shutdown_time_sync_service(); -} // namespace cardboy::backend::esp +/** + * Provide a notification sink that receives mirrored notifications from iOS. + * Passing nullptr disables mirroring. + */ +void set_notification_center(cardboy::sdk::INotificationCenter* center); +} // namespace cardboy::backend::esp diff --git a/Firmware/components/backend-esp/include/cardboy/backend/esp_backend.hpp b/Firmware/components/backend-esp/include/cardboy/backend/esp_backend.hpp index a12707b..230b1ae 100644 --- a/Firmware/components/backend-esp/include/cardboy/backend/esp_backend.hpp +++ b/Firmware/components/backend-esp/include/cardboy/backend/esp_backend.hpp @@ -61,6 +61,7 @@ private: class HighResClockService; class FilesystemService; class LoopHooksService; + class NotificationService; std::unique_ptr buzzerService; std::unique_ptr batteryService; @@ -70,6 +71,7 @@ private: std::unique_ptr filesystemService; std::unique_ptr eventBus; std::unique_ptr loopHooksService; + std::unique_ptr notificationService; cardboy::sdk::Services services{}; }; diff --git a/Firmware/components/backend-esp/src/esp_backend.cpp b/Firmware/components/backend-esp/src/esp_backend.cpp index a55b369..ce1c308 100644 --- a/Firmware/components/backend-esp/src/esp_backend.cpp +++ b/Firmware/components/backend-esp/src/esp_backend.cpp @@ -24,8 +24,11 @@ #include #include +#include +#include #include #include +#include namespace cardboy::backend::esp { @@ -37,6 +40,7 @@ void ensureNvsInit() { esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + printf("Erasing flash!\n"); ESP_ERROR_CHECK(nvs_flash_erase()); err = nvs_flash_init(); } @@ -127,6 +131,80 @@ public: void onLoopIteration() override { vTaskDelay(1); } }; +class EspRuntime::NotificationService final : public cardboy::sdk::INotificationCenter { +public: + void pushNotification(Notification notification) override { + if (notification.timestamp == 0) { + notification.timestamp = static_cast(std::time(nullptr)); + } + + capLengths(notification); + + std::lock_guard lock(mutex); + notification.id = nextId++; + notification.unread = true; + + entries.push_back(std::move(notification)); + if (entries.size() > kMaxEntries) + entries.erase(entries.begin()); + ++revisionCounter; + } + + [[nodiscard]] std::uint32_t revision() const override { + std::lock_guard lock(mutex); + return revisionCounter; + } + + [[nodiscard]] std::vector recent(std::size_t limit) const override { + std::lock_guard lock(mutex); + std::vector out; + const std::size_t count = std::min(limit, entries.size()); + out.reserve(count); + for (std::size_t i = 0; i < count; ++i) { + out.push_back(entries[entries.size() - 1 - i]); + } + return out; + } + + void markAllRead() override { + std::lock_guard lock(mutex); + bool changed = false; + for (auto& entry: entries) { + if (entry.unread) { + entry.unread = false; + changed = true; + } + } + if (changed) + ++revisionCounter; + } + + void clear() override { + std::lock_guard lock(mutex); + if (entries.empty()) + return; + entries.clear(); + ++revisionCounter; + } + +private: + static constexpr std::size_t kMaxEntries = 8; + static constexpr std::size_t kMaxTitleBytes = 96; + static constexpr std::size_t kMaxBodyBytes = 256; + + static void capLengths(Notification& notification) { + if (notification.title.size() > kMaxTitleBytes) + notification.title.resize(kMaxTitleBytes); + if (notification.body.size() > kMaxBodyBytes) + notification.body.resize(kMaxBodyBytes); + } + + mutable std::mutex mutex; + std::vector entries; + std::uint64_t nextId = 1; + std::uint32_t revisionCounter = 0; +}; + EspRuntime::EspRuntime() : framebuffer(), input(), clock() { initializeHardware(); @@ -138,20 +216,26 @@ EspRuntime::EspRuntime() : framebuffer(), input(), clock() { filesystemService = std::make_unique(); eventBus = std::make_unique(); loopHooksService = std::make_unique(); + notificationService = std::make_unique(); - services.buzzer = buzzerService.get(); - services.battery = batteryService.get(); - services.storage = storageService.get(); - services.random = randomService.get(); - services.highResClock = highResClockService.get(); - services.filesystem = filesystemService.get(); - services.eventBus = eventBus.get(); - services.loopHooks = loopHooksService.get(); + services.buzzer = buzzerService.get(); + services.battery = batteryService.get(); + services.storage = storageService.get(); + services.random = randomService.get(); + services.highResClock = highResClockService.get(); + services.filesystem = filesystemService.get(); + services.eventBus = eventBus.get(); + services.loopHooks = loopHooksService.get(); + services.notifications = notificationService.get(); Buttons::get().setEventBus(eventBus.get()); + set_notification_center(notificationService.get()); } -EspRuntime::~EspRuntime() { shutdown_time_sync_service(); } +EspRuntime::~EspRuntime() { + set_notification_center(nullptr); + shutdown_time_sync_service(); +} cardboy::sdk::Services& EspRuntime::serviceRegistry() { return services; } diff --git a/Firmware/components/backend-esp/src/time_sync_service.cpp b/Firmware/components/backend-esp/src/time_sync_service.cpp index 6a196ca..3e2a173 100644 --- a/Firmware/components/backend-esp/src/time_sync_service.cpp +++ b/Firmware/components/backend-esp/src/time_sync_service.cpp @@ -15,6 +15,7 @@ #include "host/ble_gatt.h" #include "host/ble_hs.h" #include "host/ble_hs_mbuf.h" +#include "host/ble_store.h" #include "host/util/util.h" #include "nimble/nimble_port.h" #include "nimble/nimble_port_freertos.h" @@ -29,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +39,10 @@ #include #include +#include "cardboy/sdk/services.hpp" + +extern "C" void ble_store_config_init(void); + namespace cardboy::backend::esp { namespace { @@ -44,9 +50,9 @@ namespace { constexpr char kLogTag[] = "TimeSyncBLE"; constexpr char kDeviceName[] = "Cardboy"; -constexpr std::uint16_t kPreferredConnIntervalMin = BLE_GAP_CONN_ITVL_MS(80); // 80 ms -constexpr std::uint16_t kPreferredConnIntervalMax = BLE_GAP_CONN_ITVL_MS(150); // 150 ms -constexpr std::uint16_t kPreferredConnLatency = 2; +constexpr std::uint16_t kPreferredConnIntervalMin = BLE_GAP_CONN_ITVL_MS(200); // 80 ms +constexpr std::uint16_t kPreferredConnIntervalMax = BLE_GAP_CONN_ITVL_MS(300); // 150 ms +constexpr std::uint16_t kPreferredConnLatency = 3; constexpr std::uint16_t kPreferredSupervisionTimeout = BLE_GAP_SUPERVISION_TIMEOUT_MS(5000); // 5 s constexpr float connIntervalUnitsToMs(std::uint16_t units) { return static_cast(units) * 1.25f; } @@ -75,10 +81,12 @@ struct [[gnu::packed]] TimeSyncPayload { static_assert(sizeof(TimeSyncPayload) == 12, "Unexpected payload size"); -static bool g_started = false; -static uint8_t g_ownAddrType = BLE_OWN_ADDR_PUBLIC; -static TaskHandle_t g_hostTaskHandle = nullptr; -static uint16_t g_activeConnHandle = BLE_HS_CONN_HANDLE_NONE; +static bool g_started = false; +static uint8_t g_ownAddrType = BLE_OWN_ADDR_PUBLIC; +static TaskHandle_t g_hostTaskHandle = nullptr; +static uint16_t g_activeConnHandle = BLE_HS_CONN_HANDLE_NONE; +static cardboy::sdk::INotificationCenter* g_notificationCenter = nullptr; +static bool g_securityRequested = false; struct ResponseMessage { uint8_t opcode; @@ -112,6 +120,86 @@ struct FileDownloadContext { static FileUploadContext g_uploadCtx{}; static FileDownloadContext g_downloadCtx{}; +static const ble_uuid128_t kAncsServiceUuid = BLE_UUID128_INIT(0xD0, 0x00, 0x2D, 0x12, 0x1E, 0x4B, 0x0F, 0xA4, 0x99, + 0x4E, 0xCE, 0xB5, 0x31, 0xF4, 0x05, 0x79); +static const ble_uuid128_t kAncsNotificationSourceUuid = BLE_UUID128_INIT( + 0xBD, 0x1D, 0xA2, 0x99, 0xE6, 0x25, 0x58, 0x8C, 0xD9, 0x42, 0x01, 0x63, 0x0D, 0x12, 0xBF, 0x9F); +static const ble_uuid128_t kAncsDataSourceUuid = BLE_UUID128_INIT(0xFB, 0x7B, 0x7C, 0xCE, 0x6A, 0xB3, 0x44, 0xBE, 0xB5, + 0x4B, 0xD6, 0x24, 0xE9, 0xC6, 0xEA, 0x22); +static const ble_uuid128_t kAncsControlPointUuid = BLE_UUID128_INIT(0xD9, 0xD9, 0xAA, 0xFD, 0xBD, 0x9B, 0x21, 0x98, + 0xA8, 0x49, 0xE1, 0x45, 0xF3, 0xD8, 0xD1, 0x69); + +static uint16_t g_ancsServiceEndHandle = 0; +static uint16_t g_ancsNotificationSourceHandle = 0; +static uint16_t g_ancsDataSourceHandle = 0; +static uint16_t g_ancsControlPointHandle = 0; +static uint16_t g_mtuSize = 23; + +struct PendingNotification { + uint32_t uid = 0; + uint8_t category = 0; + uint8_t flags = 0; + std::string appIdentifier; + std::string title; + std::string message; +}; + +static std::vector g_pendingNotifications; +static std::vector g_dataSourceBuffer; +static const ble_uuid16_t kClientConfigUuid = BLE_UUID16_INIT(BLE_GATT_DSC_CLT_CFG_UUID16); + +void resetAncsState() { + g_ancsServiceEndHandle = 0; + g_ancsNotificationSourceHandle = 0; + g_ancsDataSourceHandle = 0; + g_ancsControlPointHandle = 0; + g_mtuSize = 23; + g_dataSourceBuffer.clear(); + g_pendingNotifications.clear(); +} + +PendingNotification* findPending(uint32_t uid) { + for (auto& entry: g_pendingNotifications) { + if (entry.uid == uid) + return &entry; + } + return nullptr; +} + +PendingNotification& ensurePending(uint32_t uid) { + if (auto* existing = findPending(uid)) + return *existing; + g_pendingNotifications.push_back({}); + auto& pending = g_pendingNotifications.back(); + pending.uid = uid; + return pending; +} + +void finalizePending(uint32_t uid) { + if (!g_notificationCenter) + return; + for (auto it = g_pendingNotifications.begin(); it != g_pendingNotifications.end(); ++it) { + if (it->uid != uid) + continue; + + cardboy::sdk::INotificationCenter::Notification note{}; + note.timestamp = static_cast(time(nullptr)); + if (!it->title.empty()) { + note.title = it->title; + } else if (!it->appIdentifier.empty()) { + note.title = it->appIdentifier; + } else { + note.title = "Notification"; + } + note.body = it->message; + g_notificationCenter->pushNotification(std::move(note)); + ESP_LOGI(kLogTag, "Stored notification uid=%" PRIu32 " title='%s' body='%s'", uid, it->title.c_str(), + it->message.c_str()); + g_pendingNotifications.erase(it); + break; + } +} + enum class FileCommandCode : uint8_t { ListDirectory = 0x01, UploadBegin = 0x02, @@ -156,6 +244,15 @@ bool sendFileResponseNow(const ResponseMessage& msg); void notificationTask(void* param); bool scheduleDownloadChunk(); void processDownloadChunk(); +void handleAncsNotificationSource(uint16_t connHandle, const uint8_t* data, uint16_t length); +bool handleAncsDataSource(const uint8_t* data, uint16_t length); +void requestAncsAttributes(uint16_t connHandle, uint32_t uid); +void applyPreferredConnectionParams(uint16_t connHandle); +int ancsServiceDiscoveredCb(uint16_t connHandle, const ble_gatt_error* error, const ble_gatt_svc* svc, void* arg); +int ancsCharacteristicDiscoveredCb(uint16_t connHandle, const ble_gatt_error* error, const ble_gatt_chr* chr, + void* arg); +int ancsDescriptorDiscoveredCb(uint16_t connHandle, const ble_gatt_error* error, uint16_t chrValHandle, + const ble_gatt_dsc* dsc, void* arg); static const ble_gatt_chr_def kTimeServiceCharacteristics[] = { { @@ -811,6 +908,215 @@ void handleRename(const uint8_t* payload, std::size_t length) { } } +void requestAncsAttributes(uint16_t connHandle, uint32_t uid) { + if (!g_notificationCenter || g_ancsControlPointHandle == 0) + return; + + static constexpr uint16_t kMaxTitle = 96; + static constexpr uint16_t kMaxMessage = 256; + + uint8_t buffer[32]; + std::size_t index = 0; + buffer[index++] = 0x00; // CommandIDGetNotificationAttributes + buffer[index++] = static_cast(uid & 0xFF); + buffer[index++] = static_cast((uid >> 8) & 0xFF); + buffer[index++] = static_cast((uid >> 16) & 0xFF); + buffer[index++] = static_cast((uid >> 24) & 0xFF); + + buffer[index++] = 0x00; // App Identifier + + buffer[index++] = 0x01; // Title + buffer[index++] = static_cast(kMaxTitle & 0xFF); + buffer[index++] = static_cast((kMaxTitle >> 8) & 0xFF); + + buffer[index++] = 0x03; // Message + buffer[index++] = static_cast(kMaxMessage & 0xFF); + buffer[index++] = static_cast((kMaxMessage >> 8) & 0xFF); + + const int rc = ble_gattc_write_flat(connHandle, g_ancsControlPointHandle, buffer, index, nullptr, nullptr); + if (rc != 0) { + ESP_LOGW(kLogTag, "ANCS attribute request failed: rc=%d uid=%" PRIu32, rc, uid); + } else { + ESP_LOGI(kLogTag, "Requested ANCS attributes for uid=%" PRIu32, uid); + } +} + +void applyPreferredConnectionParams(uint16_t connHandle) { + ble_gap_upd_params params{ + .itvl_min = kPreferredConnIntervalMin, + .itvl_max = kPreferredConnIntervalMax, + .latency = kPreferredConnLatency, + .supervision_timeout = kPreferredSupervisionTimeout, + .min_ce_len = 0, + .max_ce_len = 0, + }; + + const int rc = ble_gap_update_params(connHandle, ¶ms); + if (rc != 0) { + ESP_LOGW(kLogTag, "ble_gap_update_params failed (rc=%d)", rc); + } else { + ESP_LOGI(kLogTag, "Requested conn params: %.1f-%.1f ms latency %u timeout %.0f ms", + connIntervalUnitsToMs(params.itvl_min), connIntervalUnitsToMs(params.itvl_max), params.latency, + supervisionUnitsToMs(params.supervision_timeout)); + } +} + +void handleAncsNotificationSource(uint16_t connHandle, const uint8_t* data, uint16_t length) { + if (!g_notificationCenter || !data || length < 8) + return; + + const uint8_t eventId = data[0]; + const uint8_t eventFlags = data[1]; + const uint8_t category = data[2]; + const uint32_t uid = static_cast(data[4]) | (static_cast(data[5]) << 8) | + (static_cast(data[6]) << 16) | (static_cast(data[7]) << 24); + + ESP_LOGI(kLogTag, "ANCS notification event=%u flags=0x%02x category=%u uid=%" PRIu32, eventId, eventFlags, category, + uid); + + if (eventId == 2) { // Removed + finalizePending(uid); + return; + } + + auto& pending = ensurePending(uid); + pending.flags = eventFlags; + pending.category = category; + + requestAncsAttributes(connHandle, uid); +} + +bool handleAncsDataSource(const uint8_t* data, uint16_t length) { + if (!g_notificationCenter || !data || length == 0) + return false; + + g_dataSourceBuffer.insert(g_dataSourceBuffer.end(), data, data + length); + if (g_dataSourceBuffer.size() > 2048) { + ESP_LOGW(kLogTag, "Dropping oversized ANCS data buffer (%u bytes)", + static_cast(g_dataSourceBuffer.size())); + g_dataSourceBuffer.clear(); + return false; + } + const uint8_t* buffer = g_dataSourceBuffer.data(); + const uint16_t total = static_cast(g_dataSourceBuffer.size()); + + if (total < 5) + return false; + + if (buffer[0] != 0x00) + return false; + + const uint32_t uid = static_cast(buffer[1]) | (static_cast(buffer[2]) << 8) | + (static_cast(buffer[3]) << 16) | (static_cast(buffer[4]) << 24); + + PendingNotification* pending = findPending(uid); + if (!pending) + pending = &ensurePending(uid); + + std::size_t offset = 5; + while (offset + 3 <= total) { + const uint8_t attrId = buffer[offset]; + const uint16_t attrLen = + static_cast(buffer[offset + 1]) | (static_cast(buffer[offset + 2]) << 8); + offset += 3; + if (offset + attrLen > total) + return false; + + const char* valuePtr = reinterpret_cast(buffer + offset); + const std::string value(valuePtr, valuePtr + attrLen); + switch (attrId) { + case 0x00: + pending->appIdentifier = value; + ESP_LOGD(kLogTag, "ANCS uid=%" PRIu32 " appId=%.*s", uid, static_cast(attrLen), valuePtr); + break; + case 0x01: + pending->title = value; + ESP_LOGD(kLogTag, "ANCS uid=%" PRIu32 " title=%.*s", uid, static_cast(attrLen), valuePtr); + break; + case 0x03: + pending->message = value; + ESP_LOGD(kLogTag, "ANCS uid=%" PRIu32 " message=%.*s", uid, static_cast(attrLen), valuePtr); + break; + default: + break; + } + offset += attrLen; + } + + if (offset != total) + return false; + + ESP_LOGI(kLogTag, "ANCS data complete uid=%" PRIu32, uid); + finalizePending(uid); + g_dataSourceBuffer.clear(); + return true; +} + +int ancsDescriptorDiscoveredCb(uint16_t connHandle, const ble_gatt_error* error, uint16_t /*chr_val_handle*/, + const ble_gatt_dsc* dsc, void* /*arg*/) { + if (error->status == 0 && dsc) { + if (ble_uuid_cmp(&dsc->uuid.u, &kClientConfigUuid.u) == 0) { + const uint8_t enable[2] = {0x01, 0x00}; + const int rc = ble_gattc_write_flat(connHandle, dsc->handle, enable, sizeof(enable), nullptr, nullptr); + if (rc != 0) + ESP_LOGW(kLogTag, "Failed to enable ANCS notifications (rc=%d) handle=%u", rc, dsc->handle); + else + ESP_LOGI(kLogTag, "Subscribed ANCS descriptor handle=%u", dsc->handle); + } + return 0; + } + if (error->status == BLE_HS_EDONE) + return 0; + return error->status; +} + +int ancsCharacteristicDiscoveredCb(uint16_t connHandle, const ble_gatt_error* error, const ble_gatt_chr* chr, + void* /*arg*/) { + if (error->status == BLE_HS_EDONE) + return 0; + if (error->status != 0) + return error->status; + + if (!chr) + return 0; + + if ((chr->properties & BLE_GATT_CHR_PROP_NOTIFY) && + ble_uuid_cmp(&chr->uuid.u, &kAncsNotificationSourceUuid.u) == 0) { + g_ancsNotificationSourceHandle = chr->val_handle; + ESP_LOGI(kLogTag, "ANCS notification source handle=%u", g_ancsNotificationSourceHandle); + ble_gattc_disc_all_dscs(connHandle, chr->val_handle, g_ancsServiceEndHandle, ancsDescriptorDiscoveredCb, + nullptr); + } else if ((chr->properties & BLE_GATT_CHR_PROP_NOTIFY) && + ble_uuid_cmp(&chr->uuid.u, &kAncsDataSourceUuid.u) == 0) { + g_ancsDataSourceHandle = chr->val_handle; + ESP_LOGI(kLogTag, "ANCS data source handle=%u", g_ancsDataSourceHandle); + ble_gattc_disc_all_dscs(connHandle, chr->val_handle, g_ancsServiceEndHandle, ancsDescriptorDiscoveredCb, + nullptr); + } else if ((chr->properties & BLE_GATT_CHR_PROP_WRITE) && + ble_uuid_cmp(&chr->uuid.u, &kAncsControlPointUuid.u) == 0) { + g_ancsControlPointHandle = chr->val_handle; + ESP_LOGI(kLogTag, "ANCS control point handle=%u", g_ancsControlPointHandle); + } + + return 0; +} + +int ancsServiceDiscoveredCb(uint16_t connHandle, const ble_gatt_error* error, const ble_gatt_svc* svc, void* /*arg*/) { + if (error->status == BLE_HS_EDONE) + return 0; + if (error->status != 0) + return error->status; + if (!svc) { + ESP_LOGW(kLogTag, "ANCS service missing"); + return 0; + } + + g_ancsServiceEndHandle = svc->end_handle; + ESP_LOGI(kLogTag, "ANCS service discovered: start=%u end=%u", svc->start_handle, svc->end_handle); + return ble_gattc_disc_all_chrs(connHandle, svc->start_handle, svc->end_handle, ancsCharacteristicDiscoveredCb, + nullptr); +} + void handleGattsRegister(ble_gatt_register_ctxt* ctxt, void* /*arg*/) { if (ctxt->op == BLE_GATT_REGISTER_OP_CHR) { if (ble_uuid_cmp(ctxt->chr.chr_def->uuid, &kFileCommandCharUuid.u) == 0) { @@ -969,27 +1275,6 @@ void logConnectionParams(uint16_t connHandle, const char* context) { static_cast(desc.conn_latency), timeoutMs); } -void applyPreferredConnectionParams(uint16_t connHandle) { - ble_gap_upd_params params{ - .itvl_min = kPreferredConnIntervalMin, - .itvl_max = kPreferredConnIntervalMax, - .latency = kPreferredConnLatency, - .supervision_timeout = kPreferredSupervisionTimeout, - .min_ce_len = 0, - .max_ce_len = 0, - }; - - const int rc = ble_gap_update_params(connHandle, ¶ms); - if (rc != 0) { - ESP_LOGW(kLogTag, "Requesting preferred conn params failed (rc=%d)", rc); - return; - } - - ESP_LOGI(kLogTag, "Requested conn params: interval=%.0f-%.0f ms latency=%u supervision=%.0f ms", - connIntervalUnitsToMs(kPreferredConnIntervalMin), connIntervalUnitsToMs(kPreferredConnIntervalMax), - kPreferredConnLatency, supervisionUnitsToMs(kPreferredSupervisionTimeout)); -} - void startAdvertising() { ble_hs_adv_fields fields{}; std::memset(&fields, 0, sizeof(fields)); @@ -1075,7 +1360,22 @@ int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) { ESP_LOGI(kLogTag, "Connected; handle=%d", event->connect.conn_handle); g_activeConnHandle = event->connect.conn_handle; logConnectionParams(event->connect.conn_handle, "Initial"); - applyPreferredConnectionParams(event->connect.conn_handle); + + ble_gap_conn_desc desc{}; + if (ble_gap_conn_find(event->connect.conn_handle, &desc) == 0) { + ESP_LOGI(kLogTag, "Security state on connect: bonded=%d encrypted=%d authenticated=%d key_size=%u", + desc.sec_state.bonded, desc.sec_state.encrypted, desc.sec_state.authenticated, + static_cast(desc.sec_state.key_size)); + if (!desc.sec_state.encrypted && !desc.sec_state.bonded && !g_securityRequested) { + const int src = ble_gap_security_initiate(event->connect.conn_handle); + if (src == 0) { + g_securityRequested = true; + ESP_LOGI(kLogTag, "Security procedure initiated"); + } else { + ESP_LOGW(kLogTag, "Failed to initiate security (rc=%d)", src); + } + } + } } else { ESP_LOGW(kLogTag, "Connection attempt failed; status=%d", event->connect.status); startAdvertising(); @@ -1084,7 +1384,9 @@ int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) { case BLE_GAP_EVENT_DISCONNECT: ESP_LOGI(kLogTag, "Disconnected; reason=%d", event->disconnect.reason); - g_activeConnHandle = BLE_HS_CONN_HANDLE_NONE; + g_activeConnHandle = BLE_HS_CONN_HANDLE_NONE; + g_securityRequested = false; + resetAncsState(); resetUploadContext(); resetDownloadContext(); if (g_responseQueue) @@ -1092,6 +1394,20 @@ int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) { startAdvertising(); break; + case BLE_GAP_EVENT_ENC_CHANGE: + if (event->enc_change.status == 0) { + ESP_LOGI(kLogTag, "Link encrypted; discovering ANCS"); + resetAncsState(); + g_securityRequested = false; + ble_gattc_disc_svc_by_uuid(event->enc_change.conn_handle, &kAncsServiceUuid.u, ancsServiceDiscoveredCb, + nullptr); + applyPreferredConnectionParams(event->enc_change.conn_handle); + } else { + ESP_LOGW(kLogTag, "Encryption change failed; status=%d", event->enc_change.status); + g_securityRequested = false; + } + break; + case BLE_GAP_EVENT_ADV_COMPLETE: ESP_LOGI(kLogTag, "Advertising complete; restarting"); startAdvertising(); @@ -1108,24 +1424,39 @@ int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) { case BLE_GAP_EVENT_CONN_UPDATE_REQ: if (event->conn_update_req.self_params) { - auto& params = *event->conn_update_req.self_params; - if (params.itvl_max > kPreferredConnIntervalMax) - params.itvl_max = kPreferredConnIntervalMax; - if (params.itvl_min > params.itvl_max) - params.itvl_min = params.itvl_max; - if (params.latency > kPreferredConnLatency) - params.latency = kPreferredConnLatency; - if (params.supervision_timeout > kPreferredSupervisionTimeout) - params.supervision_timeout = kPreferredSupervisionTimeout; - params.min_ce_len = 0; - params.max_ce_len = 0; - + const auto& params = *event->conn_update_req.self_params; ESP_LOGI(kLogTag, "Peer update request -> interval %.1f-%.1f ms latency %u timeout %.0f ms", connIntervalUnitsToMs(params.itvl_min), connIntervalUnitsToMs(params.itvl_max), params.latency, supervisionUnitsToMs(params.supervision_timeout)); } break; + case BLE_GAP_EVENT_NOTIFY_RX: + if (event->notify_rx.attr_handle == g_ancsNotificationSourceHandle) { + handleAncsNotificationSource(event->notify_rx.conn_handle, event->notify_rx.om->om_data, + event->notify_rx.om->om_len); + } else if (event->notify_rx.attr_handle == g_ancsDataSourceHandle) { + const uint16_t len = event->notify_rx.om->om_len; + ESP_LOGD(kLogTag, "ANCS data chunk len=%u", static_cast(len)); + handleAncsDataSource(event->notify_rx.om->om_data, len); + } + break; + + case BLE_GAP_EVENT_MTU: + g_mtuSize = event->mtu.value; + ESP_LOGI(kLogTag, "MTU updated to %u", g_mtuSize); + break; + + case BLE_GAP_EVENT_REPEAT_PAIRING: { + ble_gap_conn_desc desc{}; + if (ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc) == 0) { + ESP_LOGI(kLogTag, "Repeat pairing requested by %02X:%02X:%02X:%02X:%02X:%02X; keeping existing bond", + desc.peer_id_addr.val[0], desc.peer_id_addr.val[1], desc.peer_id_addr.val[2], + desc.peer_id_addr.val[3], desc.peer_id_addr.val[4], desc.peer_id_addr.val[5]); + } + return BLE_GAP_REPEAT_PAIRING_IGNORE; + } + default: break; } @@ -1153,12 +1484,15 @@ bool initController() { ble_hs_cfg.gatts_register_cb = handleGattsRegister; ble_hs_cfg.store_status_cb = ble_store_util_status_rr; ble_hs_cfg.sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT; - ble_hs_cfg.sm_bonding = 0; + ble_hs_cfg.sm_bonding = 1; ble_hs_cfg.sm_mitm = 0; ble_hs_cfg.sm_sc = 0; + ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID; + ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID; ESP_ERROR_CHECK(nimble_port_init()); configureGap(); + ble_store_config_init(); int gattRc = ble_gatts_count_cfg(kGattServices); if (gattRc != 0) { @@ -1182,6 +1516,8 @@ void ensure_time_sync_service_started() { return; } + resetAncsState(); + if (!initController()) { ESP_LOGE(kLogTag, "Unable to initialise BLE time sync service"); return; @@ -1254,6 +1590,10 @@ void shutdown_time_sync_service() { vQueueDelete(g_responseQueue); g_responseQueue = nullptr; } + + resetAncsState(); } +void set_notification_center(cardboy::sdk::INotificationCenter* center) { g_notificationCenter = center; } + } // namespace cardboy::backend::esp diff --git a/Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp b/Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp index eed5a74..fd1ed5a 100644 --- a/Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp +++ b/Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp @@ -10,6 +10,7 @@ #include #include #include +#include namespace apps { @@ -37,7 +38,8 @@ struct TimeSnapshot { class LockscreenApp final : public cardboy::sdk::IApp { public: - explicit LockscreenApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer), clock(ctx.clock) {} + explicit LockscreenApp(AppContext& ctx) : + context(ctx), framebuffer(ctx.framebuffer), clock(ctx.clock), notificationCenter(ctx.notificationCenter()) {} void onStart() override { cancelRefreshTimer(); @@ -45,6 +47,8 @@ public: holdActive = false; holdProgressMs = 0; dirty = true; + lastNotificationInteractionMs = clock.millis(); + refreshNotifications(); const auto snap = captureTime(); renderIfNeeded(snap); lastSnapshot = snap; @@ -68,15 +72,22 @@ public: } private: - AppContext& context; - Framebuffer& framebuffer; - Clock& clock; + static constexpr std::size_t kMaxDisplayedNotifications = 5; + static constexpr std::uint32_t kNotificationHideMs = 8000; + AppContext& context; + Framebuffer& framebuffer; + Clock& clock; + cardboy::sdk::INotificationCenter* notificationCenter = nullptr; + std::uint32_t lastNotificationRevision = 0; + std::vector notifications; + std::size_t selectedNotification = 0; bool dirty = false; cardboy::sdk::AppTimerHandle refreshTimer = cardboy::sdk::kInvalidAppTimer; TimeSnapshot lastSnapshot{}; bool holdActive = false; std::uint32_t holdProgressMs = 0; + std::uint32_t lastNotificationInteractionMs = 0; void cancelRefreshTimer() { if (refreshTimer != cardboy::sdk::kInvalidAppTimer) { @@ -88,11 +99,68 @@ private: static bool comboPressed(const cardboy::sdk::InputState& state) { return state.a && state.select; } void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) { + const bool upPressed = button.current.up && !button.previous.up; + const bool downPressed = button.current.down && !button.previous.down; + bool navPressed = false; + + if (!notifications.empty() && (upPressed || downPressed)) { + const std::size_t count = notifications.size(); + lastNotificationInteractionMs = clock.millis(); + navPressed = true; + if (count > 1) { + if (upPressed) + selectedNotification = (selectedNotification + count - 1) % count; + else if (downPressed) + selectedNotification = (selectedNotification + 1) % count; + } + } + const bool comboNow = comboPressed(button.current); updateHoldState(comboNow); + if (navPressed) + dirty = true; updateDisplay(); } + void refreshNotifications() { + if (!notificationCenter) { + if (!notifications.empty() || lastNotificationRevision != 0) { + notifications.clear(); + selectedNotification = 0; + lastNotificationRevision = 0; + dirty = true; + } + return; + } + const std::uint32_t revision = notificationCenter->revision(); + if (revision == lastNotificationRevision) + return; + lastNotificationRevision = revision; + + const std::uint64_t previousId = + (selectedNotification < notifications.size()) ? notifications[selectedNotification].id : 0; + + auto latest = notificationCenter->recent(kMaxDisplayedNotifications); + notifications = std::move(latest); + + if (notifications.empty()) { + selectedNotification = 0; + } else if (previousId != 0) { + auto it = std::find_if(notifications.begin(), notifications.end(), + [previousId](const auto& note) { return note.id == previousId; }); + if (it != notifications.end()) { + selectedNotification = static_cast(std::distance(notifications.begin(), it)); + } else { + selectedNotification = 0; + } + } else { + selectedNotification = 0; + } + + lastNotificationInteractionMs = clock.millis(); + dirty = true; + } + void updateHoldState(bool comboNow) { if (comboNow) { if (!holdActive) { @@ -127,6 +195,7 @@ private: } void updateDisplay() { + refreshNotifications(); const auto snap = captureTime(); if (!sameSnapshot(snap, lastSnapshot)) dirty = true; @@ -195,6 +264,98 @@ private: } } + static std::string truncateWithEllipsis(std::string_view text, int maxWidth, int scale, int letterSpacing) { + if (font16x8::measureText(text, scale, letterSpacing) <= maxWidth) + return std::string(text); + + std::string result(text.begin(), text.end()); + const std::string ellipsis = "..."; + while (!result.empty()) { + result.pop_back(); + std::string candidate = result + ellipsis; + if (font16x8::measureText(candidate, scale, letterSpacing) <= maxWidth) + return candidate; + } + return ellipsis; + } + + static std::vector wrapText(std::string_view text, int maxWidth, int scale, int letterSpacing, + int maxLines) { + std::vector lines; + if (text.empty() || maxWidth <= 0 || maxLines <= 0) + return lines; + + std::string current; + std::string word; + bool truncated = false; + + auto flushCurrent = [&]() { + if (current.empty()) + return; + if (lines.size() < static_cast(maxLines)) { + lines.push_back(current); + } else { + truncated = true; + } + current.clear(); + }; + + for (std::size_t i = 0; i <= text.size(); ++i) { + char ch = (i < text.size()) ? text[i] : ' '; + const bool isBreak = (ch == ' ' || ch == '\n' || ch == '\r' || i == text.size()); + if (!isBreak) { + word.push_back(ch); + continue; + } + + if (!word.empty()) { + std::string candidate = current.empty() ? word : current + " " + word; + if (!current.empty() && + font16x8::measureText(candidate, scale, letterSpacing) > maxWidth) { + flushCurrent(); + if (lines.size() >= static_cast(maxLines)) { + truncated = true; + break; + } + candidate = word; + } + + if (font16x8::measureText(candidate, scale, letterSpacing) > maxWidth) { + std::string shortened = truncateWithEllipsis(word, maxWidth, scale, letterSpacing); + flushCurrent(); + if (lines.size() < static_cast(maxLines)) { + lines.push_back(shortened); + } else { + truncated = true; + break; + } + current.clear(); + } else { + current = candidate; + } + word.clear(); + } + + if (ch == '\n' || ch == '\r') { + flushCurrent(); + if (lines.size() >= static_cast(maxLines)) { + truncated = true; + break; + } + } + } + + flushCurrent(); + if (lines.size() > static_cast(maxLines)) { + truncated = true; + lines.resize(maxLines); + } + if (truncated && !lines.empty()) { + lines.back() = truncateWithEllipsis(lines.back(), maxWidth, scale, letterSpacing); + } + return lines; + } + static std::string formatDate(const TimeSnapshot& snap) { static const char* kWeekdays[] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"}; if (!snap.hasWallTime) @@ -215,14 +376,105 @@ private: const int scaleTime = 4; const int scaleSeconds = 2; const int scaleSmall = 1; + const int textLineHeight = font16x8::kGlyphHeight * scaleSmall; + + const int cardMarginTop = 4; + const int cardMarginSide = 8; + const int cardPadding = 6; + const int cardLineSpacing = 4; + int cardHeight = 0; + const int cardWidth = framebuffer.width() - cardMarginSide * 2; + + const std::uint32_t nowMs = clock.millis(); + const bool hasNotifications = !notifications.empty(); + const bool showNotificationDetails = + hasNotifications && (nowMs - lastNotificationInteractionMs <= kNotificationHideMs); + + if (hasNotifications) { + const auto& note = notifications[selectedNotification]; + if (showNotificationDetails) { + std::string title = note.title.empty() ? std::string("Notification") : note.title; + title = truncateWithEllipsis(title, cardWidth - cardPadding * 2, scaleSmall, 1); + + auto bodyLines = wrapText(note.body, cardWidth - cardPadding * 2, scaleSmall, 1, 4); + + cardHeight = cardPadding * 2 + textLineHeight; + if (!bodyLines.empty()) { + cardHeight += cardLineSpacing; + cardHeight += static_cast(bodyLines.size()) * textLineHeight; + if (bodyLines.size() > 1) + cardHeight += (static_cast(bodyLines.size()) - 1) * cardLineSpacing; + } + + if (notifications.size() > 1) { + cardHeight = std::max(cardHeight, cardPadding * 2 + textLineHeight * 2 + cardLineSpacing + 8); + } + + drawRectOutline(framebuffer, cardMarginSide, cardMarginTop, cardWidth, cardHeight); + if (cardWidth > 2 && cardHeight > 2) + drawRectOutline(framebuffer, cardMarginSide + 1, cardMarginTop + 1, cardWidth - 2, cardHeight - 2); + + font16x8::drawText(framebuffer, cardMarginSide + cardPadding, cardMarginTop + cardPadding, title, + scaleSmall, true, 1); + + if (notifications.size() > 1) { + char counter[16]; + std::snprintf(counter, sizeof(counter), "%zu/%zu", selectedNotification + 1, notifications.size()); + const int counterWidth = font16x8::measureText(counter, scaleSmall, 1); + const int counterX = cardMarginSide + cardWidth - cardPadding - counterWidth; + font16x8::drawText(framebuffer, counterX, cardMarginTop + cardPadding, counter, scaleSmall, true, 1); + const int arrowX = counterX + (counterWidth - 8) / 2; + int arrowY = cardMarginTop + cardPadding + textLineHeight + 1; + font16x8::drawText(framebuffer, arrowX, arrowY, "^", scaleSmall, true, 0); + arrowY += textLineHeight + 2; + font16x8::drawText(framebuffer, arrowX, arrowY, "v", scaleSmall, true, 0); + } + + if (!bodyLines.empty()) { + int bodyY = cardMarginTop + cardPadding + textLineHeight + cardLineSpacing; + for (const auto& line : bodyLines) { + font16x8::drawText(framebuffer, cardMarginSide + cardPadding, bodyY, line, scaleSmall, true, 1); + bodyY += textLineHeight + cardLineSpacing; + } + } + } else { + cardHeight = textLineHeight + cardPadding * 2; + char summary[32]; + if (notifications.size() == 1) + std::snprintf(summary, sizeof(summary), "1 NOTIFICATION"); + else + std::snprintf(summary, sizeof(summary), "%zu NOTIFICATIONS", notifications.size()); + const int summaryWidth = font16x8::measureText(summary, scaleSmall, 1); + const int summaryX = (framebuffer.width() - summaryWidth) / 2; + const int summaryY = cardMarginTop; + font16x8::drawText(framebuffer, summaryX, summaryY, summary, scaleSmall, true, 1); + + if (notifications.size() > 1) { + int arrowX = (framebuffer.width() - 8) / 2; + int arrowY = summaryY + textLineHeight + 1; + font16x8::drawText(framebuffer, arrowX, arrowY, "^", scaleSmall, true, 0); + arrowY += textLineHeight + 2; + font16x8::drawText(framebuffer, arrowX, arrowY, "v", scaleSmall, true, 0); + cardHeight = std::max(cardHeight, arrowY + textLineHeight - cardMarginTop); + } + } + } + + const int defaultTimeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleTime) / 2 - 8; + int timeY = defaultTimeY; + if (cardHeight > 0) + timeY = cardMarginTop + cardHeight + 16; + const int minTimeY = (cardHeight > 0) ? (cardMarginTop + cardHeight + 12) : 16; + const int maxTimeY = + std::max(minTimeY, framebuffer.height() - font16x8::kGlyphHeight * scaleTime - 48); + timeY = std::clamp(timeY, minTimeY, maxTimeY); char hoursMinutes[6]; std::snprintf(hoursMinutes, sizeof(hoursMinutes), "%02d:%02d", snap.hour24, snap.minute); - const int mainW = font16x8::measureText(hoursMinutes, scaleTime, 0); - const int timeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleTime) / 2 - 8; - const int timeX = (framebuffer.width() - mainW) / 2; - const int secX = timeX + mainW + 12; - const int secY = timeY + font16x8::kGlyphHeight * scaleTime - font16x8::kGlyphHeight * scaleSeconds; + const int mainW = font16x8::measureText(hoursMinutes, scaleTime, 0); + const int timeX = (framebuffer.width() - mainW) / 2; + const int secX = timeX + mainW + 12; + const int secY = timeY + font16x8::kGlyphHeight * scaleTime - font16x8::kGlyphHeight * scaleSeconds; char secs[3]; std::snprintf(secs, sizeof(secs), "%02d", snap.second); @@ -230,19 +482,28 @@ private: font16x8::drawText(framebuffer, secX, secY, secs, scaleSeconds, true, 0); const std::string dateLine = formatDate(snap); - drawCenteredText(framebuffer, timeY + font16x8::kGlyphHeight * scaleTime + 24, dateLine, scaleSmall, 1); - const char* instruction = holdActive ? "KEEP HOLDING A+SELECT" : "HOLD A+SELECT"; - drawCenteredText(framebuffer, framebuffer.height() - 52, instruction, scaleSmall, 1); + drawCenteredText(framebuffer, timeY + font16x8::kGlyphHeight * scaleTime + 16, dateLine, scaleSmall, 1); + + const std::string instruction = holdActive ? "KEEP HOLDING A+SELECT" : "HOLD A+SELECT"; + const int instructionWidth = font16x8::measureText(instruction, scaleSmall, 1); + const int barHeight = 14; + const int barY = framebuffer.height() - 24; + const int textY = barY + (barHeight - textLineHeight) / 2; + const int textX = 8; + font16x8::drawText(framebuffer, textX, textY, instruction, scaleSmall, true, 1); + + int barX = textX + instructionWidth + 12; + int barWidth = framebuffer.width() - barX - 8; + if (barWidth < 40) { + barWidth = 40; + barX = std::min(barX, framebuffer.width() - barWidth - 8); + } + + drawRectOutline(framebuffer, barX, barY, barWidth, barHeight); if (holdActive || holdProgressMs > 0) { - const int barWidth = framebuffer.width() - 64; - const int barHeight = 14; - const int barX = (framebuffer.width() - barWidth) / 2; - const int barY = framebuffer.height() - 32; - const int innerWidth = barWidth - 2; + const int innerWidth = barWidth - 2; const int innerHeight = barHeight - 2; - drawRectOutline(framebuffer, barX, barY, barWidth, barHeight); - const float ratio = std::clamp(holdProgressMs / static_cast(kUnlockHoldMs), 0.0f, 1.0f); const int fillWidth = static_cast(ratio * innerWidth + 0.5f); diff --git a/Firmware/sdk/backend_interface/include/cardboy/sdk/services.hpp b/Firmware/sdk/backend_interface/include/cardboy/sdk/services.hpp index aeb45a9..94170e6 100644 --- a/Firmware/sdk/backend_interface/include/cardboy/sdk/services.hpp +++ b/Firmware/sdk/backend_interface/include/cardboy/sdk/services.hpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace cardboy::sdk { @@ -69,6 +70,25 @@ public: [[nodiscard]] virtual std::string basePath() const = 0; }; +class INotificationCenter { +public: + struct Notification { + std::uint64_t id = 0; + std::uint64_t timestamp = 0; + std::string title; + std::string body; + bool unread = true; + }; + + virtual ~INotificationCenter() = default; + + virtual void pushNotification(Notification notification) = 0; + [[nodiscard]] virtual std::uint32_t revision() const = 0; + [[nodiscard]] virtual std::vector recent(std::size_t limit) const = 0; + virtual void markAllRead() = 0; + virtual void clear() = 0; +}; + struct Services { IBuzzer* buzzer = nullptr; IBatteryMonitor* battery = nullptr; @@ -78,6 +98,7 @@ struct Services { IFilesystem* filesystem = nullptr; IEventBus* eventBus = nullptr; ILoopHooks* loopHooks = nullptr; + INotificationCenter* notifications = nullptr; }; } // namespace cardboy::sdk diff --git a/Firmware/sdk/backends/desktop/include/cardboy/backend/desktop_backend.hpp b/Firmware/sdk/backends/desktop/include/cardboy/backend/desktop_backend.hpp index 1c2075d..0a0336b 100644 --- a/Firmware/sdk/backends/desktop/include/cardboy/backend/desktop_backend.hpp +++ b/Firmware/sdk/backends/desktop/include/cardboy/backend/desktop_backend.hpp @@ -86,6 +86,23 @@ private: bool mounted = false; }; +class DesktopNotificationCenter final : public cardboy::sdk::INotificationCenter { +public: + void pushNotification(Notification notification) override; + [[nodiscard]] std::uint32_t revision() const override; + [[nodiscard]] std::vector recent(std::size_t limit) const override; + void markAllRead() override; + void clear() override; + +private: + static constexpr std::size_t kMaxEntries = 8; + + mutable std::mutex mutex; + std::vector entries; + std::uint64_t nextId = 1; + std::uint32_t revisionCounter = 0; +}; + class DesktopEventBus final : public cardboy::sdk::IEventBus { public: explicit DesktopEventBus(DesktopRuntime& owner); @@ -187,6 +204,7 @@ private: DesktopHighResClock highResService; DesktopFilesystem filesystemService; DesktopEventBus eventBusService; + DesktopNotificationCenter notificationService; cardboy::sdk::Services services{}; }; diff --git a/Firmware/sdk/backends/desktop/src/desktop_backend.cpp b/Firmware/sdk/backends/desktop/src/desktop_backend.cpp index 761f9d3..644f1be 100644 --- a/Firmware/sdk/backends/desktop/src/desktop_backend.cpp +++ b/Firmware/sdk/backends/desktop/src/desktop_backend.cpp @@ -8,8 +8,10 @@ #include #include #include +#include #include #include +#include namespace cardboy::backend::desktop { @@ -149,6 +151,58 @@ bool DesktopFilesystem::mount() { return mounted; } +void DesktopNotificationCenter::pushNotification(Notification notification) { + if (notification.timestamp == 0) { + notification.timestamp = static_cast(std::time(nullptr)); + } + + std::lock_guard lock(mutex); + notification.id = nextId++; + notification.unread = true; + + if (entries.size() >= kMaxEntries) + entries.erase(entries.begin()); + entries.push_back(std::move(notification)); + ++revisionCounter; +} + +std::uint32_t DesktopNotificationCenter::revision() const { + std::lock_guard lock(mutex); + return revisionCounter; +} + +std::vector DesktopNotificationCenter::recent(std::size_t limit) const { + std::lock_guard lock(mutex); + const std::size_t count = std::min(limit, entries.size()); + std::vector result; + result.reserve(count); + for (std::size_t i = 0; i < count; ++i) { + result.push_back(entries[entries.size() - 1 - i]); + } + return result; +} + +void DesktopNotificationCenter::markAllRead() { + std::lock_guard lock(mutex); + bool changed = false; + for (auto& entry : entries) { + if (entry.unread) { + entry.unread = false; + changed = true; + } + } + if (changed) + ++revisionCounter; +} + +void DesktopNotificationCenter::clear() { + std::lock_guard lock(mutex); + if (entries.empty()) + return; + entries.clear(); + ++revisionCounter; +} + DesktopFramebuffer::DesktopFramebuffer(DesktopRuntime& runtime) : runtime(runtime) {} int DesktopFramebuffer::width_impl() const { return cardboy::sdk::kDisplayWidth; } @@ -244,6 +298,7 @@ DesktopRuntime::DesktopRuntime() : services.filesystem = &filesystemService; services.eventBus = &eventBusService; services.loopHooks = nullptr; + services.notifications = ¬ificationService; } cardboy::sdk::Services& DesktopRuntime::serviceRegistry() { return services; } diff --git a/Firmware/sdk/core/include/cardboy/sdk/app_framework.hpp b/Firmware/sdk/core/include/cardboy/sdk/app_framework.hpp index e244f89..ec0c2cf 100644 --- a/Firmware/sdk/core/include/cardboy/sdk/app_framework.hpp +++ b/Firmware/sdk/core/include/cardboy/sdk/app_framework.hpp @@ -64,6 +64,9 @@ struct AppContext { [[nodiscard]] IFilesystem* filesystem() const { return services ? services->filesystem : nullptr; } [[nodiscard]] IEventBus* eventBus() const { return services ? services->eventBus : nullptr; } [[nodiscard]] ILoopHooks* loopHooks() const { return services ? services->loopHooks : nullptr; } + [[nodiscard]] INotificationCenter* notificationCenter() const { + return services ? services->notifications : nullptr; + } void requestAppSwitchByIndex(std::size_t index) { pendingAppIndex = index;