Skip to content

Commit ed868c4

Browse files
committed
Merge remote-tracking branch 'upstream/main' into chore/convert-main-tree-buildable
2 parents ca6e86f + e531f15 commit ed868c4

22 files changed

Lines changed: 601 additions & 157 deletions

Localizable.xcstrings

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,9 @@
579579
}
580580
}
581581
},
582+
"%1$@ reacted %2$@ to \"%3$@\"" : {
583+
"comment" : "Tapback/reaction notification body. %1$@ is the sender name, %2$@ is the reaction emoji, %3$@ is the original message text that was reacted to."
584+
},
582585
"%@" : {
583586
"localizations" : {
584587
"da" : {

Meshtastic/API/MeshtasticAPI.swift

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,14 @@ private struct DeviceHardware: Codable {
5050
}
5151

5252
/// Firmware Release Lists
53-
private struct FirmwareReleases: Codable {
53+
struct FirmwareReleases: Codable {
5454
let releases: Releases
5555
let pullRequests: [FirmwareRelease]
5656
}
57-
private struct Releases: Codable {
57+
struct Releases: Codable {
5858
let stable, alpha: [FirmwareRelease]
5959
}
60-
private struct FirmwareRelease: Codable {
60+
struct FirmwareRelease: Codable {
6161
let id, title: String
6262
let pageURL: String
6363
let zipURL: String
@@ -71,6 +71,59 @@ private struct FirmwareRelease: Codable {
7171
}
7272
}
7373

74+
private struct GitHubFirmwareRelease: Decodable {
75+
let tagName: String
76+
let name: String?
77+
let htmlURL: String
78+
let zipballURL: String
79+
let body: String?
80+
let prerelease: Bool
81+
let draft: Bool
82+
83+
enum CodingKeys: String, CodingKey {
84+
case tagName = "tag_name"
85+
case name
86+
case htmlURL = "html_url"
87+
case zipballURL = "zipball_url"
88+
case body
89+
case prerelease
90+
case draft
91+
}
92+
}
93+
94+
enum FirmwareReleaseCatalog {
95+
static func decode(_ data: Data) throws -> FirmwareReleases {
96+
let decoder = JSONDecoder()
97+
if let meshtasticReleases = try? decoder.decode(FirmwareReleases.self, from: data) {
98+
return meshtasticReleases
99+
}
100+
101+
let githubReleases = try decoder.decode([GitHubFirmwareRelease].self, from: data)
102+
let publishedReleases = githubReleases.filter { !$0.draft }
103+
let stable = publishedReleases
104+
.filter { !$0.prerelease }
105+
.map { FirmwareRelease(gitHubRelease: $0) }
106+
let alpha = publishedReleases
107+
.filter(\.prerelease)
108+
.map { FirmwareRelease(gitHubRelease: $0) }
109+
110+
return FirmwareReleases(
111+
releases: Releases(stable: stable, alpha: alpha),
112+
pullRequests: []
113+
)
114+
}
115+
}
116+
117+
private extension FirmwareRelease {
118+
init(gitHubRelease: GitHubFirmwareRelease) {
119+
id = gitHubRelease.tagName
120+
title = gitHubRelease.name ?? gitHubRelease.tagName
121+
pageURL = gitHubRelease.htmlURL
122+
zipURL = gitHubRelease.zipballURL
123+
releaseNotes = gitHubRelease.body
124+
}
125+
}
126+
74127
extension MeshtasticAPI {
75128
enum MeshtasticAPIError: Error, LocalizedError {
76129
case timedOut(TimeInterval)
@@ -116,6 +169,7 @@ class MeshtasticAPI: ObservableObject, @unchecked Sendable {
116169
static let deviceURLEndpoint = URL(string: "https://api.meshtastic.org/resource/deviceHardware")!
117170
static let imageURLPrefix = URL(string: "https://flasher.meshtastic.org/img/devices/")!
118171
static let firmwareURLEndpoint = URL(string: "https://api.meshtastic.org/github/firmware/list")!
172+
static let firmwareGitHubURLEndpoint = URL(string: "https://api.github.com/repos/meshtastic/firmware/releases?per_page=100")!
119173
static let eventFirmwareURLEndpoint = URL(string: "https://api.meshtastic.org/resource/eventFirmware")!
120174

121175
// MARK: - Private properties
@@ -154,10 +208,19 @@ class MeshtasticAPI: ObservableObject, @unchecked Sendable {
154208
await MainActor.run {
155209
self.isLoadingFirmwareList = true
156210
}
157-
158-
let apiData = try await Self.firmwareURLEndpoint.data(timeout: 5.0)
159-
160-
let decodedFirmware = try decoder.decode(FirmwareReleases.self, from: apiData)
211+
defer {
212+
Task { @MainActor in self.isLoadingFirmwareList = false }
213+
}
214+
215+
let decodedFirmware: FirmwareReleases
216+
do {
217+
let apiData = try await Self.firmwareURLEndpoint.data(timeout: 5.0)
218+
decodedFirmware = try FirmwareReleaseCatalog.decode(apiData)
219+
} catch {
220+
Logger.services.warning("Firmware API request failed; falling back to GitHub releases: \(error.localizedDescription, privacy: .public)")
221+
let githubData = try await Self.firmwareGitHubURLEndpoint.data(timeout: 5.0)
222+
decodedFirmware = try FirmwareReleaseCatalog.decode(githubData)
223+
}
161224
let stableVersions = Set(decodedFirmware.releases.stable.map { $0.id })
162225
let alphaVersions = Set(decodedFirmware.releases.alpha.map { $0.id })
163226

@@ -196,10 +259,6 @@ class MeshtasticAPI: ObservableObject, @unchecked Sendable {
196259

197260
// Save the last update date for the firmware
198261
UserDefaults.lastFirmwareAPIUpdate = Date()
199-
200-
await MainActor.run {
201-
self.isLoadingFirmwareList = false
202-
}
203262
}
204263

205264
func refreshDevicesAPIData() async throws {

Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,8 +379,9 @@ extension AccessoryManager {
379379
try context.save()
380380
Logger.data.info("💾 Saved a new sent message from \(self.activeDeviceNum?.toHex() ?? "0", privacy: .public) to \(toUserNum.toHex(), privacy: .public)")
381381
// Donate outgoing message to SiriKit for CarPlay
382+
// (CarPlay is iPhone-only, so skip on Mac Catalyst).
382383
if !isEmoji {
383-
#if os(iOS)
384+
#if os(iOS) && !targetEnvironment(macCatalyst)
384385
CarPlayIntentDonation.donateOutgoingMessage(content: message, toUserNum: toUserNum, channel: channel)
385386
#endif
386387
}

Meshtastic/Helpers/LocalNotificationManager.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ class LocalNotificationManager {
6767
if notification.messageId != nil {
6868
content.categoryIdentifier = "messageNotificationCategory"
6969
content.userInfo["messageId"] = notification.messageId
70+
// `messageId` identifies this notification for cancellation (keyed on the packet's
71+
// own id). Tapback/reply actions must instead target the message being replied to,
72+
// which differs for reactions — carry it separately, defaulting to `messageId`.
73+
content.userInfo["replyMessageId"] = notification.replyMessageId ?? notification.messageId
7074
}
7175
if notification.channel != nil {
7276
content.userInfo["channel"] = notification.channel
@@ -146,6 +150,10 @@ struct Notification {
146150
var target: String?
147151
var path: String?
148152
var messageId: Int64?
153+
/// Target message id for tapback/reply actions. For a reaction notification this is the
154+
/// original (reacted-to) message rather than the reaction packet in `messageId`. `nil` means
155+
/// "same as `messageId`" (regular messages).
156+
var replyMessageId: Int64?
149157
var channel: Int32?
150158
var userNum: Int64?
151159
var critical: Bool = false

Meshtastic/Helpers/MeshPackets.swift

Lines changed: 116 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,62 @@ actor MeshPackets {
11431143
}
11441144
}
11451145

1146+
/// Builds the notification body for a received tapback/reaction, or returns `nil` when the
1147+
/// reacted-to message isn't known locally (a "phantom" tapback). The reaction is still stored
1148+
/// as it is today — we just don't surface a notification for something there's nothing to show
1149+
/// for, matching Android's guard (`MeshDataHandlerImpl.rememberReaction`).
1150+
///
1151+
/// Made `static` and context-injected (rather than reading `self.modelContext`) so the
1152+
/// phantom-tapback guard and body formatting can be exercised directly in unit tests.
1153+
static func reactionNotificationBody(replyID: Int64, emoji: String?, senderName: String, context: ModelContext) -> String? {
1154+
guard replyID > 0 else { return nil }
1155+
let descriptor = FetchDescriptor<MessageEntity>(predicate: #Predicate { $0.messageId == replyID })
1156+
guard let original = try? context.fetch(descriptor).first else { return nil }
1157+
let originalText = original.messagePayload ?? ""
1158+
let reactionEmoji = (emoji?.isEmpty == false) ? emoji! : "❤️"
1159+
// Mirrors how iMessage phrases a tapback notification ("Name liked …").
1160+
return String.localizedStringWithFormat(
1161+
"%1$@ reacted %2$@ to \"%3$@\"".localized,
1162+
senderName, reactionEmoji, originalText
1163+
)
1164+
}
1165+
1166+
/// Builds a message/reaction local notification from a (context-bound) `MessageEntity`,
1167+
/// synchronously, so callers can hand the resulting value to a deferred `@MainActor` Task
1168+
/// without capturing the entity itself. Collapses the DM/channel × regular/reaction variants
1169+
/// that differ only in `content`, deep-link `path`, and `userNum`.
1170+
///
1171+
/// `replyMessageId` is the message that tapback/reply notification actions should target. It
1172+
/// defaults to `message.messageId`, but for a reaction notification the caller passes the
1173+
/// original (reacted-to) message id so actions act on that message rather than the reaction
1174+
/// packet (which `messageId` — used for notification cancellation — must keep referencing).
1175+
private func makeMessageNotification(
1176+
message: MessageEntity,
1177+
content: String,
1178+
path: String,
1179+
userNum: Int64?,
1180+
critical: Bool,
1181+
replyMessageId: Int64? = nil
1182+
) -> Notification {
1183+
var notification = Notification(
1184+
id: ("notification.id.\(message.messageId)"),
1185+
title: "\(message.fromUser?.longName ?? "Unknown".localized)",
1186+
subtitle: "AKA \(message.fromUser?.shortName ?? "?")",
1187+
content: content,
1188+
target: "messages",
1189+
path: path,
1190+
messageId: message.messageId,
1191+
replyMessageId: replyMessageId ?? message.messageId,
1192+
channel: message.channel,
1193+
userNum: userNum,
1194+
critical: critical
1195+
)
1196+
#if os(iOS) && !targetEnvironment(macCatalyst)
1197+
notification.senderIntent = CarPlayIntentDonation.incomingMessageIntent(from: message)
1198+
#endif
1199+
return notification
1200+
}
1201+
11461202
func textMessageAppPacket(
11471203
packet: MeshPacket,
11481204
wantRangeTestPackets: Bool,
@@ -1347,7 +1403,8 @@ actor MeshPackets {
13471403
// Send notifications if the message saved properly to core data
13481404
if messageSaved {
13491405
// Donate to SiriKit so the message appears in CarPlay Messages
1350-
#if os(iOS)
1406+
// (CarPlay is iPhone-only, so skip on Mac Catalyst).
1407+
#if os(iOS) && !targetEnvironment(macCatalyst)
13511408
CarPlayIntentDonation.donateReceivedMessage(newMessage)
13521409
#endif
13531410

@@ -1362,33 +1419,42 @@ actor MeshPackets {
13621419
appState?.unreadDirectMessages = unreadCount
13631420
}
13641421
}
1365-
if !(newMessage.fromUser?.mute ?? false) && newMessage.isEmoji == false {
1422+
if !(newMessage.fromUser?.mute ?? false) {
13661423
// Build the notification from the model objects now, while this context is valid,
13671424
// and capture only the resulting value — a deferred Task must not hold `newMessage`
13681425
// (a context-bound model), since a device switch can reset this context first
13691426
// ("destroyed by ModelContext.reset").
13701427
let senderName = newMessage.fromUser?.longName ?? "Unknown".localized
1371-
var dmNotification = Notification(
1372-
id: ("notification.id.\(newMessage.messageId)"),
1373-
title: "\(senderName)",
1374-
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
1375-
content: messageText!,
1376-
target: "messages",
1377-
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)",
1378-
messageId: newMessage.messageId,
1379-
channel: newMessage.channel,
1380-
userNum: Int64(packet.from),
1381-
critical: critical
1382-
)
1383-
#if os(iOS)
1384-
dmNotification.senderIntent = CarPlayIntentDonation.incomingMessageIntent(from: newMessage)
1385-
#endif
1386-
let notification = dmNotification
1387-
Task {@MainActor in
1388-
let manager = LocalNotificationManager()
1389-
manager.notifications = [notification]
1390-
manager.schedule()
1391-
Logger.services.debug("iOS Notification Scheduled for text message from \(senderName, privacy: .public)")
1428+
let dmUserNum = Int64(packet.from)
1429+
var dmNotification: Notification?
1430+
if newMessage.isEmoji == false {
1431+
dmNotification = makeMessageNotification(
1432+
message: newMessage,
1433+
content: messageText!,
1434+
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)",
1435+
userNum: dmUserNum,
1436+
critical: critical
1437+
)
1438+
} else if let reactionBody = MeshPackets.reactionNotificationBody(replyID: newMessage.replyID, emoji: messageText, senderName: senderName, context: modelContext) {
1439+
// Tapback/reaction: only notify when the reacted-to message is known locally.
1440+
// A "phantom" tapback (replyID with no matching local message) is stored but not
1441+
// surfaced — reactionNotificationBody returns nil in that case.
1442+
dmNotification = makeMessageNotification(
1443+
message: newMessage,
1444+
content: reactionBody,
1445+
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.replyID)",
1446+
userNum: dmUserNum,
1447+
critical: critical,
1448+
replyMessageId: newMessage.replyID
1449+
)
1450+
}
1451+
if let notification = dmNotification {
1452+
Task {@MainActor in
1453+
let manager = LocalNotificationManager()
1454+
manager.notifications = [notification]
1455+
manager.schedule()
1456+
Logger.services.debug("iOS Notification Scheduled for direct message from \(senderName, privacy: .public)")
1457+
}
13921458
}
13931459
}
13941460
} else if newMessage.fromUser != nil && newMessage.toUser == nil {
@@ -1406,25 +1472,34 @@ actor MeshPackets {
14061472
// to ~1/sec — the badge tolerates brief lag and resyncs on app-active and on read.
14071473
let recountUnread = shouldRecomputeChannelUnread()
14081474
let connectedNodeNum = connectedNode
1409-
let shouldNotify = UserDefaults.channelMessageNotifications && newMessage.isEmoji == false && myInfo.channels.contains(where: { $0.index == newMessage.channel && !$0.mute })
1475+
let channelNotificationsEnabled = UserDefaults.channelMessageNotifications
1476+
&& !(newMessage.fromUser?.mute ?? false)
1477+
&& myInfo.channels.contains(where: { $0.index == newMessage.channel && !$0.mute })
1478+
let senderName = newMessage.fromUser?.longName ?? "Unknown".localized
1479+
let channelUserNum = Int64(newMessage.fromUser?.userId ?? "0")
14101480
var channelNotification: Notification?
1411-
if shouldNotify {
1412-
var chNotification = Notification(
1413-
id: ("notification.id.\(newMessage.messageId)"),
1414-
title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)",
1415-
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
1416-
content: messageText!,
1417-
target: "messages",
1418-
path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)",
1419-
messageId: newMessage.messageId,
1420-
channel: newMessage.channel,
1421-
userNum: Int64(newMessage.fromUser?.userId ?? "0"),
1422-
critical: critical
1423-
)
1424-
#if os(iOS)
1425-
chNotification.senderIntent = CarPlayIntentDonation.incomingMessageIntent(from: newMessage)
1426-
#endif
1427-
channelNotification = chNotification
1481+
if channelNotificationsEnabled {
1482+
if newMessage.isEmoji == false {
1483+
channelNotification = makeMessageNotification(
1484+
message: newMessage,
1485+
content: messageText!,
1486+
path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)",
1487+
userNum: channelUserNum,
1488+
critical: critical
1489+
)
1490+
} else if let reactionBody = MeshPackets.reactionNotificationBody(replyID: newMessage.replyID, emoji: messageText, senderName: senderName, context: modelContext) {
1491+
// Tapback/reaction: only notify when the reacted-to message is known
1492+
// locally. A "phantom" tapback is stored but not surfaced — the helper
1493+
// returns nil in that case, per Android's guard.
1494+
channelNotification = makeMessageNotification(
1495+
message: newMessage,
1496+
content: reactionBody,
1497+
path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.replyID)",
1498+
userNum: channelUserNum,
1499+
critical: critical,
1500+
replyMessageId: newMessage.replyID
1501+
)
1502+
}
14281503
}
14291504
let notification = channelNotification
14301505
Task {@MainActor in

Meshtastic/MeshtasticAppDelegate.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
124124
}
125125
case "messageNotification.thumbsUpAction":
126126
if let channel = userInfo["channel"] as? Int32,
127-
let replyID = userInfo["messageId"] as? Int64 {
127+
let replyID = userInfo["replyMessageId"] as? Int64 ?? userInfo["messageId"] as? Int64 {
128128
Task {
129129
do {
130130
try await AccessoryManager.shared.sendMessage(
@@ -142,7 +142,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
142142
}
143143
case "messageNotification.thumbsDownAction":
144144
if let channel = userInfo["channel"] as? Int32,
145-
let replyID = userInfo["messageId"] as? Int64 {
145+
let replyID = userInfo["replyMessageId"] as? Int64 ?? userInfo["messageId"] as? Int64 {
146146
Task {
147147
do {
148148
try await AccessoryManager.shared.sendMessage(
@@ -161,7 +161,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
161161
case "messageNotification.replyInputAction":
162162
if let userInput = (response as? UNTextInputNotificationResponse)?.userText,
163163
let channel = userInfo["channel"] as? Int32,
164-
let replyID = userInfo["messageId"] as? Int64 {
164+
let replyID = userInfo["replyMessageId"] as? Int64 ?? userInfo["messageId"] as? Int64 {
165165
Task {
166166
do {
167167
try await AccessoryManager.shared.sendMessage(

Meshtastic/Model/Firmware/FirmwareUpdateNotifier.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ struct FirmwareUpdateNotificationSource {
2424
}
2525

2626
struct FirmwareUpdateNotice: Equatable {
27+
static let symbolName = "arrow.triangle.2.circlepath"
28+
2729
let notificationKey: String
2830
let deviceName: String
2931
let currentVersion: String

0 commit comments

Comments
 (0)