janky notifications
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
@@ -13,6 +13,40 @@
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs6">
|
||||
<linearGradient
|
||||
id="swatch19"
|
||||
inkscape:swatch="solid">
|
||||
<stop
|
||||
style="stop-color:#cccccc;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop20" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="swatch15">
|
||||
<stop
|
||||
style="stop-color:#cccccc;stop-opacity:1;"
|
||||
offset="0.52188009"
|
||||
id="stop15" />
|
||||
</linearGradient>
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath5">
|
||||
<rect
|
||||
style="fill:#000000;stroke:#000000;stroke-width:0;stroke-linejoin:bevel;paint-order:stroke markers fill;stop-color:#000000"
|
||||
id="rect5-4"
|
||||
width="20"
|
||||
height="200"
|
||||
x="530"
|
||||
y="595" />
|
||||
</clipPath>
|
||||
<linearGradient
|
||||
id="swatch4"
|
||||
inkscape:swatch="solid">
|
||||
<stop
|
||||
style="stop-color:#cccccc;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop4" />
|
||||
</linearGradient>
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath61">
|
||||
@@ -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" />
|
||||
<path
|
||||
id="path222"
|
||||
clip-path="url(#clipPath223)"
|
||||
style="fill:#e6e6e6;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
style="fill:#cccccc;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.18386638,0.18386638,0,-502.11504,-1071.3194)"
|
||||
d="m 9421.5,3980 1327.75,0.5 c 45.813,0.5 82.75,37.4365 82.75,82.75 v 2905 c 0,45.8135 -36.937,82.75 -82.75,82.75 H 9140.5 l -2667.25,2e-4 c -45.3134,3e-4 -82.2499,-36.9362 -82.75,-82.75 v -2905 c 0.5001,-45.3132 37.4366,-82.2497 82.75,-82.75 z"
|
||||
sodipodi:nodetypes="ccccccccccc" />
|
||||
<path
|
||||
id="path104"
|
||||
d="m 9098,4379 h 264 v 233 l -132,132 -132,-132 v -233"
|
||||
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
|
||||
clip-path="url(#clipPath105)" />
|
||||
<path
|
||||
id="path106"
|
||||
d="m 9622,4904 h -232 l -133,-133 133,-132 h 232 v 265"
|
||||
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
|
||||
clip-path="url(#clipPath107)" />
|
||||
<path
|
||||
id="path108"
|
||||
d="m 10006,5513 v -335 h 264 v 335 h -264"
|
||||
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.22983461,0.22983461,0,-755.97458,-1537.3453)"
|
||||
clip-path="url(#clipPath109)" />
|
||||
<path
|
||||
id="path110"
|
||||
d="m 9457,6189 h -312 v -265 h 312 v 265"
|
||||
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.22979634,0.22979634,0,-788.97058,-1495.2529)"
|
||||
clip-path="url(#clipPath111)" />
|
||||
<path
|
||||
id="path114"
|
||||
d="m 9003,6567 v -265 h 312 v 265 h -312"
|
||||
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.22979634,0.22979634,0,-788.97058,-1495.2529)"
|
||||
clip-path="url(#clipPath115)" />
|
||||
<path
|
||||
id="path116"
|
||||
d="m 10006,5867 v -335 h 264 v 335 h -264"
|
||||
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.22983461,0.22983461,0,-755.97458,-1537.3453)"
|
||||
clip-path="url(#clipPath117)" />
|
||||
<path
|
||||
id="path120"
|
||||
d="m 9362,4931 v 233 h -264 v -233 l 132,-132 132,132"
|
||||
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
|
||||
clip-path="url(#clipPath121)" />
|
||||
<path
|
||||
id="path122"
|
||||
d="m 9071,4639 132,132 -132,133 h -233 v -265 h 233"
|
||||
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
|
||||
clip-path="url(#clipPath123)" />
|
||||
<path
|
||||
id="path126"
|
||||
d="M 6650,4098 H 8379 V 6933 H 6650 V 4098"
|
||||
style="fill:#ffffff;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
style="fill:#ffffff;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
|
||||
transform="matrix(0,0.18386638,0.18386638,0,-502.11504,-1071.3194)"
|
||||
clip-path="url(#clipPath127)" />
|
||||
<path
|
||||
id="path130"
|
||||
d="M 8379,4098 H 6650"
|
||||
style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.18386638,0.18386638,0,-502.11504,-1071.3194)"
|
||||
clip-path="url(#clipPath131)"
|
||||
sodipodi:nodetypes="cc" />
|
||||
<path
|
||||
id="path132"
|
||||
d="M 6650,6933 H 8379"
|
||||
style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.18386638,0.18386638,0,-502.11504,-1071.3194)"
|
||||
clip-path="url(#clipPath133)"
|
||||
sodipodi:nodetypes="cc" />
|
||||
<path
|
||||
id="path158"
|
||||
d="m 9098,4612 132,132"
|
||||
style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
|
||||
clip-path="url(#clipPath159)"
|
||||
sodipodi:nodetypes="cc" />
|
||||
<path
|
||||
id="path164"
|
||||
d="m 9230,4799 -132,132"
|
||||
style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
|
||||
clip-path="url(#clipPath165)"
|
||||
sodipodi:nodetypes="cc" />
|
||||
<path
|
||||
id="path174"
|
||||
d="M 9622,4639 H 9390"
|
||||
style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
|
||||
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
|
||||
clip-path="url(#clipPath175)"
|
||||
sodipodi:nodetypes="cc"
|
||||
inkscape:label="path174" />
|
||||
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
@@ -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) {
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
<key>NSUserNotificationUsageDescription</key>
|
||||
<string>Allow Cardboy Companion to send local notifications for testing.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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:
|
||||
// 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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,6 +61,7 @@ private:
|
||||
class HighResClockService;
|
||||
class FilesystemService;
|
||||
class LoopHooksService;
|
||||
class NotificationService;
|
||||
|
||||
std::unique_ptr<BuzzerService> buzzerService;
|
||||
std::unique_ptr<BatteryService> batteryService;
|
||||
@@ -70,6 +71,7 @@ private:
|
||||
std::unique_ptr<FilesystemService> filesystemService;
|
||||
std::unique_ptr<EventBus> eventBus;
|
||||
std::unique_ptr<LoopHooksService> loopHooksService;
|
||||
std::unique_ptr<NotificationService> notificationService;
|
||||
|
||||
cardboy::sdk::Services services{};
|
||||
};
|
||||
|
||||
@@ -24,8 +24,11 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
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::uint64_t>(std::time(nullptr));
|
||||
}
|
||||
|
||||
capLengths(notification);
|
||||
|
||||
std::lock_guard<std::mutex> 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<std::mutex> lock(mutex);
|
||||
return revisionCounter;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<Notification> recent(std::size_t limit) const override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
std::vector<Notification> out;
|
||||
const std::size_t count = std::min<std::size_t>(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<std::mutex> 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<std::mutex> 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<Notification> entries;
|
||||
std::uint64_t nextId = 1;
|
||||
std::uint32_t revisionCounter = 0;
|
||||
};
|
||||
|
||||
EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
|
||||
initializeHardware();
|
||||
|
||||
@@ -138,6 +216,7 @@ EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
|
||||
filesystemService = std::make_unique<FilesystemService>();
|
||||
eventBus = std::make_unique<EventBus>();
|
||||
loopHooksService = std::make_unique<LoopHooksService>();
|
||||
notificationService = std::make_unique<NotificationService>();
|
||||
|
||||
services.buzzer = buzzerService.get();
|
||||
services.battery = batteryService.get();
|
||||
@@ -147,11 +226,16 @@ EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
|
||||
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; }
|
||||
|
||||
|
||||
@@ -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 <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <dirent.h>
|
||||
#include <esp_bt.h>
|
||||
#include <esp_err.h>
|
||||
@@ -37,6 +39,10 @@
|
||||
#include <sys/stat.h>
|
||||
#include <vector>
|
||||
|
||||
#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<float>(units) * 1.25f; }
|
||||
@@ -79,6 +85,8 @@ 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<PendingNotification> g_pendingNotifications;
|
||||
static std::vector<uint8_t> 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<std::uint64_t>(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<uint8_t>(uid & 0xFF);
|
||||
buffer[index++] = static_cast<uint8_t>((uid >> 8) & 0xFF);
|
||||
buffer[index++] = static_cast<uint8_t>((uid >> 16) & 0xFF);
|
||||
buffer[index++] = static_cast<uint8_t>((uid >> 24) & 0xFF);
|
||||
|
||||
buffer[index++] = 0x00; // App Identifier
|
||||
|
||||
buffer[index++] = 0x01; // Title
|
||||
buffer[index++] = static_cast<uint8_t>(kMaxTitle & 0xFF);
|
||||
buffer[index++] = static_cast<uint8_t>((kMaxTitle >> 8) & 0xFF);
|
||||
|
||||
buffer[index++] = 0x03; // Message
|
||||
buffer[index++] = static_cast<uint8_t>(kMaxMessage & 0xFF);
|
||||
buffer[index++] = static_cast<uint8_t>((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<uint32_t>(data[4]) | (static_cast<uint32_t>(data[5]) << 8) |
|
||||
(static_cast<uint32_t>(data[6]) << 16) | (static_cast<uint32_t>(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<unsigned>(g_dataSourceBuffer.size()));
|
||||
g_dataSourceBuffer.clear();
|
||||
return false;
|
||||
}
|
||||
const uint8_t* buffer = g_dataSourceBuffer.data();
|
||||
const uint16_t total = static_cast<uint16_t>(g_dataSourceBuffer.size());
|
||||
|
||||
if (total < 5)
|
||||
return false;
|
||||
|
||||
if (buffer[0] != 0x00)
|
||||
return false;
|
||||
|
||||
const uint32_t uid = static_cast<uint32_t>(buffer[1]) | (static_cast<uint32_t>(buffer[2]) << 8) |
|
||||
(static_cast<uint32_t>(buffer[3]) << 16) | (static_cast<uint32_t>(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<uint16_t>(buffer[offset + 1]) | (static_cast<uint16_t>(buffer[offset + 2]) << 8);
|
||||
offset += 3;
|
||||
if (offset + attrLen > total)
|
||||
return false;
|
||||
|
||||
const char* valuePtr = reinterpret_cast<const char*>(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<int>(attrLen), valuePtr);
|
||||
break;
|
||||
case 0x01:
|
||||
pending->title = value;
|
||||
ESP_LOGD(kLogTag, "ANCS uid=%" PRIu32 " title=%.*s", uid, static_cast<int>(attrLen), valuePtr);
|
||||
break;
|
||||
case 0x03:
|
||||
pending->message = value;
|
||||
ESP_LOGD(kLogTag, "ANCS uid=%" PRIu32 " message=%.*s", uid, static_cast<int>(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<unsigned>(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<unsigned>(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();
|
||||
@@ -1085,6 +1385,8 @@ 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_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<unsigned>(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
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
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:
|
||||
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<cardboy::sdk::INotificationCenter::Notification> 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::size_t>(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<std::string> wrapText(std::string_view text, int maxWidth, int scale, int letterSpacing,
|
||||
int maxLines) {
|
||||
std::vector<std::string> 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<std::size_t>(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<std::size_t>(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<std::size_t>(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<std::size_t>(maxLines)) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushCurrent();
|
||||
if (lines.size() > static_cast<std::size_t>(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,11 +376,102 @@ 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<int>(bodyLines.size()) * textLineHeight;
|
||||
if (bodyLines.size() > 1)
|
||||
cardHeight += (static_cast<int>(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;
|
||||
@@ -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);
|
||||
|
||||
if (holdActive || holdProgressMs > 0) {
|
||||
const int barWidth = framebuffer.width() - 64;
|
||||
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 barX = (framebuffer.width() - barWidth) / 2;
|
||||
const int barY = framebuffer.height() - 32;
|
||||
const int innerWidth = barWidth - 2;
|
||||
const int innerHeight = barHeight - 2;
|
||||
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 innerWidth = barWidth - 2;
|
||||
const int innerHeight = barHeight - 2;
|
||||
const float ratio =
|
||||
std::clamp(holdProgressMs / static_cast<float>(kUnlockHoldMs), 0.0f, 1.0f);
|
||||
const int fillWidth = static_cast<int>(ratio * innerWidth + 0.5f);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
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<Notification> 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
|
||||
|
||||
@@ -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<Notification> 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<Notification> 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{};
|
||||
};
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <stdexcept>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
|
||||
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::uint64_t>(std::time(nullptr));
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> 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<std::mutex> lock(mutex);
|
||||
return revisionCounter;
|
||||
}
|
||||
|
||||
std::vector<DesktopNotificationCenter::Notification> DesktopNotificationCenter::recent(std::size_t limit) const {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
const std::size_t count = std::min<std::size_t>(limit, entries.size());
|
||||
std::vector<Notification> 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<std::mutex> 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<std::mutex> 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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||