From 545f0414ab7b44ae7f95509bb3f23beadff414f6 Mon Sep 17 00:00:00 2001 From: Andrew Cope Date: Sun, 20 Feb 2022 12:18:40 -0500 Subject: [PATCH] Initial attachments, tested with picture only so far --- NotificationService/NotificationService.swift | 26 +++++- ntfy-ios/Models/NtfyAttachment.swift | 85 +++++++++++++++++++ ntfy-ios/Models/NtfyNotification.swift | 4 +- ntfy-ios/Utils/ApiService.swift | 16 +++- ntfy-ios/Utils/Database.swift | 75 +++++++++++++++- .../Views/NotificationAttachmentView.swift | 62 ++++++++++++++ ntfy-ios/Views/NotificationRow.swift | 11 +++ ntfy.sh.xcodeproj/project.pbxproj | 10 +++ 8 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 ntfy-ios/Models/NtfyAttachment.swift create mode 100644 ntfy-ios/Views/NotificationAttachmentView.swift diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 2bfb54e..35aa47c 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -31,12 +31,33 @@ class NotificationService: UNNotificationServiceExtension { let notificationTopic = userInfo["topic"] as? String, let notificationTimestamp = userInfo["time"] as? String, let notiticationTimestampInt = Int64(notificationTimestamp), - let notificationTitle = userInfo["title"] as? String, let notificationMessage = userInfo["message"] as? String { + print("Attempting to create notification") + let notificationTitle = userInfo["title"] as? String ?? "" let notificationPriority = Int(userInfo["priority"] as? String ?? "") let notificationTags = userInfo["tags"] as? String ?? "" + + let attachmentName = userInfo["attachment_name"] as? String ?? "attachment.bin" + let attachmentType = userInfo["attachment_type"] as? String ?? "" + let attachmentSize = Int64(userInfo["attachment_size"] as? String ?? "0") ?? 0 + let attachmentUrl = userInfo["attachment_url"] as? String ?? "" + let attachmentExpires = Int64(userInfo["attachment_expires"] as? String ?? "0") ?? 0 + if let subscription = Database.current.getSubscription(topic: notificationTopic) { + var attachment: NtfyAttachment? = nil + if !attachmentUrl.isEmpty { + attachment = NtfyAttachment( + id: 0, + name: attachmentName, + type: attachmentType, + size: attachmentSize, + expires: attachmentExpires, + url: attachmentUrl, + contentUrl: "" + ) + } + let ntfyNotification = NtfyNotification( id: notificationId, subscriptionId: subscription.id, @@ -44,7 +65,8 @@ class NotificationService: UNNotificationServiceExtension { title: notificationTitle, message: notificationMessage, priority: Int(notificationPriority ?? 3), - tags: notificationTags.components(separatedBy: ",") + tags: notificationTags.components(separatedBy: ","), + attachment: attachment ) ntfyNotification.save() print("Created notification") diff --git a/ntfy-ios/Models/NtfyAttachment.swift b/ntfy-ios/Models/NtfyAttachment.swift new file mode 100644 index 0000000..73bea29 --- /dev/null +++ b/ntfy-ios/Models/NtfyAttachment.swift @@ -0,0 +1,85 @@ +// +// NtfyAttachment.swift +// ntfy.sh +// +// Created by Andrew Cope on 2/20/22. +// + +import Foundation + +class NtfyAttachment { + var id: Int64! + var name: String + var type: String + var size: Int64 + var expires: Int64 + var url: String + var contentUrl: String + + init(id: Int64, name: String, type: String = "", size: Int64 = 0, expires: Int64 = 0, url: String = "", contentUrl: String = "") { + self.id = id + self.name = name + self.type = type + self.size = size + self.expires = expires + self.url = url + self.contentUrl = contentUrl + } + + func save() { + Database.current.updateAttachment(attachment: self) + } + + func sizeString() -> String { + if (self.size < 1000) { return "\(self.size) B" } + let exp = Int(log2(Double(self.size)) / log2(1000.0)) + let unit = ["KB", "MB", "GB", "TB", "PB", "EB"][exp - 1] + let number = Double(self.size) / pow(1000, Double(exp)) + return String(format: "%.1f %@", number, unit) + } + + func isDownloaded() -> Bool { + return !contentUrl.isEmpty + } + + func expiresString() -> String { + return "Expires \(self.expires)" + } + + func download() { + print("Attempting attachment download") + guard let attachmentUrl = URL(string: self.url) else { return } + print("Attachment URL: \(attachmentUrl)") + URLSession.shared.downloadTask(with: attachmentUrl) { (data, response, error) in + print("Attachment download complete") + print("Response: \(response)") + print("Data: \(data)") + if let error = error { + print("Download attachment error: \(error)") + return + } + + if let response = response, + let data = data { + let fileManager = FileManager.default + // Get the App Group path, which is accessed by both the app and the notification service extension + if let path = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.ntfy") { + guard let fileUrl = URL(string: "\(path)/downloads/\(response.hash)") else { return } + do { + let parentPath = fileUrl.deletingLastPathComponent() + if !fileManager.fileExists(atPath: parentPath.path) { + try fileManager.createDirectory(atPath: parentPath.path, withIntermediateDirectories: true, attributes: nil) + } + try fileManager.moveItem(at: data.absoluteURL, to: fileUrl) + self.contentUrl = fileUrl.path + self.save() + print(self.contentUrl) + print("Attachment saved to \(fileUrl.path)") + } catch { + print("Error saving attachment: \(error)") + } + } + } + }.resume() + } +} diff --git a/ntfy-ios/Models/NtfyNotification.swift b/ntfy-ios/Models/NtfyNotification.swift index 1db7a38..959e14d 100644 --- a/ntfy-ios/Models/NtfyNotification.swift +++ b/ntfy-ios/Models/NtfyNotification.swift @@ -17,12 +17,13 @@ class NtfyNotification: Identifiable, Decodable { var message: String var priority: Int var tags: [String] + var attachment: NtfyAttachment? // Object Properties var emojiTags: [String] = [] var nonEmojiTags: [String] = [] - init(id: String, subscriptionId: Int64, timestamp: Int64, title: String, message: String, priority: Int = 3, tags: [String] = []) { + init(id: String, subscriptionId: Int64, timestamp: Int64, title: String, message: String, priority: Int = 3, tags: [String] = [], attachment: NtfyAttachment?) { // Initialize values self.id = id self.subscriptionId = subscriptionId @@ -31,6 +32,7 @@ class NtfyNotification: Identifiable, Decodable { self.message = message self.priority = priority self.tags = tags + self.attachment = attachment // Set notification tags self.setTags() diff --git a/ntfy-ios/Utils/ApiService.swift b/ntfy-ios/Utils/ApiService.swift index aff6dee..4230a93 100644 --- a/ntfy-ios/Utils/ApiService.swift +++ b/ntfy-ios/Utils/ApiService.swift @@ -7,7 +7,7 @@ import Foundation -class ApiService { +class ApiService: NSObject { static let shared = ApiService() func poll(subscription: NtfySubscription, completionHandler: @escaping ([NtfyNotification]?, Error?) -> Void) { @@ -32,6 +32,20 @@ class ApiService { }.resume() } + /*func checkAuth(baseUrl: String, topic: String, user: NtfyUser?) -> Bool { + guard let url = URL(string: "\(baseUrl)/\(topic)/auth") else { return false } + var request = URLRequest(url: url) + if user != nil { + let credential = URLCredential(user: user!.username, password: user!.password, persistence: URLCredential.Persistence.none) + request + } + URLSession.shared.dataTask(with: request) { (data, response, error) in + + } + + return false + }*/ + private func fetchJsonData(urlString: String, completionHandler: @escaping ([T]?, Error?) -> ()) { guard let url = URL(string: urlString) else { return } URLSession.shared.dataTask(with: url) { (data, response, error) in diff --git a/ntfy-ios/Utils/Database.swift b/ntfy-ios/Utils/Database.swift index 86cbd62..8e05817 100644 --- a/ntfy-ios/Utils/Database.swift +++ b/ntfy-ios/Utils/Database.swift @@ -35,6 +35,17 @@ class Database { let notification_message = Expression("message") let notification_priority = Expression("priority") let notification_tags = Expression("tags") + let notification_attachment_id = Expression("attachmentId") + + // Attachments Table + let attachments = Table("Attachments") + var attachment_id = Expression("id") + let attachment_name = Expression("name") + let attachment_type = Expression("type") + let attachment_size = Expression("size") + let attachment_expires = Expression("expires") + let attachment_url = Expression("url") + let attachment_content_url = Expression("contentUrl") // Initialize init() { @@ -61,6 +72,18 @@ class Database { table.column(notification_message) table.column(notification_priority) table.column(notification_tags) + table.column(notification_attachment_id) + }) + + // Initialize Attachments Table + try db?.run(attachments.create(ifNotExists: true) { table in + table.column(attachment_id, primaryKey: .autoincrement) + table.column(attachment_name) + table.column(attachment_type) + table.column(attachment_size) + table.column(attachment_expires) + table.column(attachment_url) + table.column(attachment_content_url) }) } } catch { @@ -149,6 +172,21 @@ class Database { } if let result = try db?.prepare(query) { for line in result { + var attachment: NtfyAttachment? = nil + let attachmentId = try line.get(notification_attachment_id) + if attachmentId != 0 { + if let attachmentResult = try db?.pluck(attachments.filter(attachment_id == attachmentId)) { + attachment = NtfyAttachment( + id: try attachmentResult.get(attachment_id), + name: try attachmentResult.get(attachment_name), + type: try attachmentResult.get(attachment_type), + size: try attachmentResult.get(attachment_size), + expires: try attachmentResult.get(attachment_expires), + url: try attachmentResult.get(attachment_url), + contentUrl: try attachmentResult.get(attachment_content_url) + ) + } + } list.append( NtfyNotification( id: try line.get(notification_id), @@ -157,7 +195,8 @@ class Database { title: try line.get(notification_title), message: try line.get(notification_message), priority: try line.get(notification_priority), - tags: try line.get(notification_tags).components(separatedBy: ",") + tags: try line.get(notification_tags).components(separatedBy: ","), + attachment: attachment ) ) } @@ -171,6 +210,10 @@ class Database { func addNotification(notification: NtfyNotification) -> NtfyNotification { do { + var attachmentId: Int64 = 0 + if notification.attachment != nil { + attachmentId = addAttachment(attachment: notification.attachment!) ?? 0 + } try db?.run(notifications.insert( notification_id <- notification.id, notification_subscription_id <- notification.subscriptionId, @@ -178,7 +221,8 @@ class Database { notification_title <- notification.title, notification_message <- notification.message, notification_priority <- notification.priority, - notification_tags <- notification.tags.joined(separator: ",") + notification_tags <- notification.tags.joined(separator: ","), + notification_attachment_id <- attachmentId )) } catch let Result.error(message, code, _) where code == SQLITE_CONSTRAINT { // Likely means that the notification already exists @@ -220,4 +264,31 @@ class Database { print(error.localizedDescription) } } + + func addAttachment(attachment: NtfyAttachment) -> Int64? { + do { + return try db?.run(attachments.insert( + attachment_name <- attachment.name, + attachment_type <- attachment.type, + attachment_size <- attachment.size, + attachment_expires <- attachment.expires, + attachment_url <- attachment.url, + attachment_content_url <- attachment.contentUrl + )) + } catch { + print("Error saving attachment: \(error)") + return nil + } + } + + func updateAttachment(attachment: NtfyAttachment) { + do { + let dbAttachment = attachments.filter(attachment_id == attachment.id) + try db?.run(dbAttachment.update( + attachment_content_url <- attachment.contentUrl + )) + } catch { + print("Error updating attachment: \(error)") + } + } } diff --git a/ntfy-ios/Views/NotificationAttachmentView.swift b/ntfy-ios/Views/NotificationAttachmentView.swift new file mode 100644 index 0000000..334fc7d --- /dev/null +++ b/ntfy-ios/Views/NotificationAttachmentView.swift @@ -0,0 +1,62 @@ +// +// NotificationAttachmentView.swift +// ntfy.sh +// +// Created by Andrew Cope on 2/20/22. +// + +import Foundation +import SwiftUI +import UIKit + +struct NotificationAttachmentView: View { + let attachment: NtfyAttachment + + @State private var isAttachmentOpen = false + + var body: some View { + if attachment.isDownloaded() { + if let imageUrl = UIImage(contentsOfFile: attachment.contentUrl) { + let image = Image(uiImage: imageUrl) + .resizable() + .scaledToFit() + ZStack { + image + } + .onTapGesture { + isAttachmentOpen.toggle() + } + .fullScreenCover(isPresented: $isAttachmentOpen, onDismiss: { + // Dismiss logic here + }, content: { + VStack { + image + } + .onTapGesture { + isAttachmentOpen.toggle() + } + }) + } + } else { + HStack { + // TODO: Replace paperclip here with mimetype icon + Image(systemName: "paperclip") + VStack(alignment: .leading) { + Text(attachment.name) + .font(.footnote) + HStack { + Text(attachment.sizeString()) + .font(.footnote) + .foregroundColor(.gray) + Text("Not downloaded") + .font(.footnote) + .foregroundColor(.gray) + Text(attachment.expiresString()) + .font(.footnote) + .foregroundColor(.gray) + } + } + } + } + } +} diff --git a/ntfy-ios/Views/NotificationRow.swift b/ntfy-ios/Views/NotificationRow.swift index 7e75c30..81e5e30 100644 --- a/ntfy-ios/Views/NotificationRow.swift +++ b/ntfy-ios/Views/NotificationRow.swift @@ -42,7 +42,18 @@ struct NotificationRow: View { .font(.subheadline) .foregroundColor(.gray) } + if let attachment = notification.attachment { + Spacer() + NotificationAttachmentView(attachment: attachment) + } } .padding(.all, 4) + .onTapGesture { + if let attachment = notification.attachment { + if !attachment.isDownloaded() { + attachment.download() + } + } + } } } diff --git a/ntfy.sh.xcodeproj/project.pbxproj b/ntfy.sh.xcodeproj/project.pbxproj index b409a05..01b086e 100644 --- a/ntfy.sh.xcodeproj/project.pbxproj +++ b/ntfy.sh.xcodeproj/project.pbxproj @@ -23,6 +23,9 @@ 80386E9927935CC9009B0480 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 80386E9827935CC9009B0480 /* FirebaseMessaging */; }; 80386E9F27936087009B0480 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80386E9E27936087009B0480 /* AppDelegate.swift */; }; 80386EA92793A7AC009B0480 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 80386EA82793A7AC009B0480 /* SQLite */; }; + 8079FF4C27C2874A00FB3D18 /* NtfyAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8079FF4B27C2874A00FB3D18 /* NtfyAttachment.swift */; }; + 8079FF4D27C2874A00FB3D18 /* NtfyAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8079FF4B27C2874A00FB3D18 /* NtfyAttachment.swift */; }; + 8079FF4F27C29D3300FB3D18 /* NotificationAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8079FF4E27C29D3300FB3D18 /* NotificationAttachmentView.swift */; }; 80856C7F27BDE0A7008AC8B8 /* ApiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80856C7E27BDE0A7008AC8B8 /* ApiService.swift */; }; 80856C8027BDE0A7008AC8B8 /* ApiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80856C7E27BDE0A7008AC8B8 /* ApiService.swift */; }; 8086EB242794630800C3628A /* AddSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8086EB232794630800C3628A /* AddSubscriptionView.swift */; }; @@ -80,6 +83,8 @@ 80386E9C27935EE9009B0480 /* ntfy-sh-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "ntfy-sh-Info.plist"; sourceTree = ""; }; 80386E9E27936087009B0480 /* AppDelegate.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; tabWidth = 4; usesTabs = 0; }; 80386EA0279363A2009B0480 /* ntfy.sh.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ntfy.sh.entitlements; sourceTree = ""; }; + 8079FF4B27C2874A00FB3D18 /* NtfyAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfyAttachment.swift; sourceTree = ""; }; + 8079FF4E27C29D3300FB3D18 /* NotificationAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAttachmentView.swift; sourceTree = ""; }; 8081A7F427B4CB67004A8986 /* FEATURE_PARITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = FEATURE_PARITY.md; sourceTree = ""; }; 80856C7E27BDE0A7008AC8B8 /* ApiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiService.swift; sourceTree = ""; }; 8086EB232794630800C3628A /* AddSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSubscriptionView.swift; sourceTree = ""; }; @@ -216,6 +221,7 @@ 80A313FC2793C42000F1A639 /* NotificationRow.swift */, 8086EB232794630800C3628A /* AddSubscriptionView.swift */, 8086EB2527946FCE00C3628A /* ContentView.swift */, + 8079FF4E27C29D3300FB3D18 /* NotificationAttachmentView.swift */, ); path = Views; sourceTree = ""; @@ -233,6 +239,7 @@ children = ( 80A313F42793B1CF00F1A639 /* NtfySubscription.swift */, 80A313F62793B56800F1A639 /* NtfyNotification.swift */, + 8079FF4B27C2874A00FB3D18 /* NtfyAttachment.swift */, ); path = Models; sourceTree = ""; @@ -353,6 +360,7 @@ buildActionMask = 2147483647; files = ( 802D626C27B1A37700DDD3AF /* Database.swift in Sources */, + 8079FF4D27C2874A00FB3D18 /* NtfyAttachment.swift in Sources */, 80ED61BF27BCA36A00FCEA36 /* Configuration.swift in Sources */, 802D626D27B1A37900DDD3AF /* NtfyNotification.swift in Sources */, 800FA49627B19CA0005D05B9 /* NotificationService.swift in Sources */, @@ -375,8 +383,10 @@ 8086EB242794630800C3628A /* AddSubscriptionView.swift in Sources */, 80A313FD2793C42000F1A639 /* NotificationRow.swift in Sources */, 80A313FB2793C2EA00F1A639 /* SubscriptionDetail.swift in Sources */, + 8079FF4C27C2874A00FB3D18 /* NtfyAttachment.swift in Sources */, 80A313F92793C0D800F1A639 /* SubscriptionRow.swift in Sources */, 80386E802793585B009B0480 /* AppMain.swift in Sources */, + 8079FF4F27C29D3300FB3D18 /* NotificationAttachmentView.swift in Sources */, 80A313F72793B56800F1A639 /* NtfyNotification.swift in Sources */, 80856C7F27BDE0A7008AC8B8 /* ApiService.swift in Sources */, 80A313F52793B1CF00F1A639 /* NtfySubscription.swift in Sources */,