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 10137e1..0000000 Binary files a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.png and /dev/null differ 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 3aec464..0000000 Binary files a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.png and /dev/null differ diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.svg b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.svg deleted file mode 100644 index 3928af1..0000000 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.svg +++ /dev/null @@ -1,1356 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 21122ad..cff0ef5 100644 Binary files a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.png and b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.png differ diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.svg b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.svg index e9ab20a..343abbe 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.svg +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.svg @@ -13,6 +13,40 @@ xmlns:svg="http://www.w3.org/2000/svg"> + + + + + + + + + + + + @@ -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;