Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Actionable Notifications #916

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "d57a5aecf24a25b32ec4a74be2f5d0a995a47c4b",
"version" : "1.27.0"
"revision" : "edb6ed4919f7756157fe02f2552b7e3850a538e5",
"version" : "1.28.1"
}
}
],
Expand Down
12 changes: 6 additions & 6 deletions Meshtastic/Helpers/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3299,7 +3299,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate

// MARK: - CB Central Manager implmentation
extension BLEManager: CBCentralManagerDelegate {

// MARK: Bluetooth enabled/disabled
func centralManagerDidUpdateState(_ central: CBCentralManager) {
if central.state == CBManagerState.poweredOn {
Expand All @@ -3309,9 +3309,9 @@ extension BLEManager: CBCentralManagerDelegate {
} else {
isSwitchedOn = false
}

var status = ""

garthvh marked this conversation as resolved.
Show resolved Hide resolved
switch central.state {
case .poweredOff:
status = "BLE is powered off"
Expand All @@ -3330,18 +3330,18 @@ extension BLEManager: CBCentralManagerDelegate {
}
Logger.services.info("📜 [BLE] Bluetooth status: \(status)")
}

// Called each time a peripheral is discovered
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {

if self.automaticallyReconnect && peripheral.identifier.uuidString == UserDefaults.standard.object(forKey: "preferredPeripheralId") as? String ?? "" {
self.connectTo(peripheral: peripheral)
Logger.services.info("✅ [BLE] Reconnecting to prefered peripheral: \(peripheral.name ?? "Unknown", privacy: .public)")
}
let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String
let device = Peripheral(id: peripheral.identifier.uuidString, num: 0, name: name ?? "Unknown", shortName: "?", longName: name ?? "Unknown", firmwareVersion: "Unknown", rssi: RSSI.intValue, lastUpdate: Date(), peripheral: peripheral)
let index = peripherals.map { $0.peripheral }.firstIndex(of: peripheral)

if let peripheralIndex = index {
peripherals[peripheralIndex] = device
} else {
Expand Down
33 changes: 33 additions & 0 deletions Meshtastic/Helpers/LocalNotificationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import OSLog
class LocalNotificationManager {

var notifications = [Notification]()

let thumbsUpAction = UNNotificationAction(identifier: "messageNotification.thumbsUpAction", title:
"👍 \(Tapbacks.thumbsUp.description)", options: [])
let thumbsDownAction = UNNotificationAction(identifier: "messageNotification.thumbsDownAction", title:
"👎 \(Tapbacks.thumbsDown.description)", options: [])
let replyInputAction = UNTextInputNotificationAction(
identifier: "messageNotification.replyInputAction",
title: "reply".localized,
options: [])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indentation in this diff off. Can we fix this by running the linter?



// Step 1 Request Permissions for notifications
private func requestAuthorization() {
Expand All @@ -31,6 +41,15 @@ class LocalNotificationManager {

// This function iterates over the Notification objects in the notifications array and schedules them for delivery in the future
private func scheduleNotifications() {
let messageNotificationCategory = UNNotificationCategory(
identifier: "messageNotificationCategory",
actions: [thumbsUpAction, thumbsDownAction,replyInputAction],
intentIdentifiers: [],
options: .customDismissAction
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

++

)

UNUserNotificationCenter.current().setNotificationCategories([messageNotificationCategory])

for notification in notifications {
let content = UNMutableNotificationContent()
content.subtitle = notification.subtitle
Expand All @@ -45,6 +64,17 @@ class LocalNotificationManager {
if notification.path != nil {
content.userInfo["path"] = notification.path
}
if notification.messageId != nil {
content.categoryIdentifier = "messageNotificationCategory"
content.userInfo["messageId"] = notification.messageId
}
if notification.channel != nil {
content.userInfo["channel"] = notification.channel
}
if notification.userNum != nil {
content.userInfo["userNum"] = notification.userNum
}


let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger)
Expand Down Expand Up @@ -76,4 +106,7 @@ struct Notification {
var content: String
var target: String?
var path: String?
var messageId: Int64?
var channel: Int32?
var userNum: Int64?
}
44 changes: 28 additions & 16 deletions Meshtastic/Helpers/MeshPackets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -815,20 +815,18 @@ func textMessageAppPacket(
context: NSManagedObjectContext,
appState: AppState
) {

var messageText = String(bytes: packet.decoded.payload, encoding: .utf8)
let rangeRef = Reference(Int.self)
let rangeTestRegex = Regex {
"seq "

TryCapture(as: rangeRef) {
OneOrMore(.digit)
} transform: { match in
Int(match)
}
}
let rangeTest = messageText?.contains(rangeTestRegex) ?? false && messageText?.starts(with: "seq ") ?? false

if !wantRangeTestPackets && rangeTest {
return
}
Expand All @@ -841,15 +839,16 @@ func textMessageAppPacket(
}
}
}

if messageText?.count ?? 0 > 0 {

MeshLogger.log("💬 \("mesh.log.textmessage.received".localized)")

let messageUsers = UserEntity.fetchRequest()
messageUsers.predicate = NSPredicate(format: "num IN %@", [packet.to, packet.from])

do {
let fetchedUsers = try context.fetch(messageUsers)

let newMessage = MessageEntity(context: context)
newMessage.messageId = Int64(packet.id)
if packet.rxTime > 0 {
Expand All @@ -865,54 +864,60 @@ func textMessageAppPacket(
newMessage.portNum = Int32(packet.decoded.portnum.rawValue)
newMessage.publicKey = packet.publicKey
newMessage.pkiEncrypted = packet.pkiEncrypted

if packet.decoded.portnum == PortNum.detectionSensorApp {
if !UserDefaults.enableDetectionNotifications {
newMessage.read = true
}
}

if packet.decoded.replyID > 0 {
newMessage.replyID = Int64(packet.decoded.replyID)
}

if fetchedUsers.first(where: { $0.num == packet.to }) != nil && packet.to != Constants.maximumNodeNum {
if !storeForwardBroadcast {
newMessage.toUser = fetchedUsers.first(where: { $0.num == packet.to })
}
}

if fetchedUsers.first(where: { $0.num == packet.from }) != nil {
newMessage.fromUser = fetchedUsers.first(where: { $0.num == packet.from })

if !(newMessage.fromUser?.publicKey?.isEmpty ?? true) {
/// We have a key, check if it matches
// We have a key, check if it matches
if newMessage.fromUser?.publicKey != newMessage.publicKey {
newMessage.fromUser?.keyMatch = false
newMessage.fromUser?.newPublicKey = newMessage.publicKey
}
} else {
/// We have no key, set it
// We have no key, set it
newMessage.fromUser?.publicKey = packet.publicKey
newMessage.fromUser?.pkiEncrypted = packet.pkiEncrypted
}

if packet.rxTime > 0 {
newMessage.fromUser?.userNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime)))
} else {
newMessage.fromUser?.userNode?.lastHeard = Date()
}
}

newMessage.messagePayload = messageText
newMessage.messagePayloadMarkdown = generateMessageMarkdown(message: messageText!)

if packet.to != Constants.maximumNodeNum && newMessage.fromUser != nil {
newMessage.fromUser?.lastMessage = Date()
}

var messageSaved = false

do {

try context.save()
Logger.data.info("💾 Saved a new message for \(newMessage.messageId)")
messageSaved = true

if messageSaved {

if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications {
return
}
Expand All @@ -931,14 +936,16 @@ func textMessageAppPacket(
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
content: messageText!,
target: "messages",
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)"
path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)",
messageId: newMessage.messageId,
channel: newMessage.channel,
userNum: Int64(packet.from)
)
]
manager.schedule()
Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "unknown".localized)")
}
} else if newMessage.fromUser != nil && newMessage.toUser == nil {

let fetchMyInfoRequest = MyInfoEntity.fetchRequest()
fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedNode))

Expand All @@ -961,15 +968,19 @@ func textMessageAppPacket(
subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")",
content: messageText!,
target: "messages",
path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)")
path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)",
messageId: newMessage.messageId,
channel: newMessage.channel,
userNum: Int64(newMessage.fromUser?.userId ?? "0")
)
]
manager.schedule()
Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "unknown".localized)")
}
}
}
} catch {

// Handle error
}
}
}
Expand All @@ -984,6 +995,7 @@ func textMessageAppPacket(
}
}


func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) {

let logString = String.localizedStringWithFormat("mesh.log.waypoint.received %@".localized, String(packet.from))
Expand Down
68 changes: 63 additions & 5 deletions Meshtastic/MeshtasticAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import SwiftUI
import OSLog

class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, ObservableObject {

var router: Router?

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
Logger.services.info("🚀 [App] Meshtstic Apple App launched!")
// Default User Default Values
Expand All @@ -22,7 +22,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
if #available(iOS 17.0, macOS 14.0, *) {
let locationsHandler = LocationsHandler.shared
locationsHandler.startLocationUpdates()

// If a background activity session was previously active, reinstantiate it after the background launch.
if locationsHandler.backgroundActivity {
locationsHandler.backgroundActivity = true
Expand All @@ -38,14 +38,72 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
) {
completionHandler([.list, .banner, .sound])
}

// This method is called when a user clicks on the notification
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo

switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier:
break

case "messageNotification.thumbsUpAction":
if let channel = userInfo["channel"] as? Int32,
let replyID = userInfo["messageId"] as? Int64 {
let tapbackResponse = !BLEManager.shared.sendMessage(
message: Tapbacks.thumbsUp.emojiString,
toUserNum: userInfo["userNum"] as? Int64 ?? 0,
channel: channel,
isEmoji: true,
replyID: replyID
)
Logger.services.info("Tapback response sent")
} else {
Logger.services.error("Failed to retrieve channel or messageId from userInfo")
}
break

case "messageNotification.thumbsDownAction":
if let channel = userInfo["channel"] as? Int32,
let replyID = userInfo["messageId"] as? Int64 {
let tapbackResponse = !BLEManager.shared.sendMessage(
message: Tapbacks.thumbsDown.emojiString,
toUserNum: userInfo["userNum"] as? Int64 ?? 0,
channel: channel,
isEmoji: true,
replyID: replyID
)
Logger.services.info("Tapback response sent")
} else {
Logger.services.error("Failed to retrieve channel or messageId from userInfo")
}
break

case "messageNotification.replyInputAction":
if let userInput = (response as? UNTextInputNotificationResponse)?.userText,
let channel = userInfo["channel"] as? Int32,
let replyID = userInfo["messageId"] as? Int64 {
let tapbackResponse = !BLEManager.shared.sendMessage(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two questions here:

  • It looks like for each of these the variable tapbackResponse is never used. Can they be removed?
  • Do we need to consider any edge-cases that could cause this to crash? (ie: BLE is disconnected)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendMessage returns a boolean if it fails or goes through. For logging purposes we could use it.

message: userInput,
toUserNum: userInfo["userNum"] as? Int64 ?? 0,
channel: channel,
isEmoji: false,
replyID: replyID
)
Logger.services.info("Actionable notification reply sent")
} else {
Logger.services.error("Failed to retrieve user input, channel, or messageId from userInfo")
}
break

default:
break
}

if let targetValue = userInfo["target"] as? String,
let deepLink = userInfo["path"] as? String,
let url = URL(string: deepLink) {
Expand All @@ -54,7 +112,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
} else {
Logger.services.error("Failed to handle notification response: \(userInfo)")
}

completionHandler()
}
}