diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index b17a0a0..7e361c9 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -46,3 +46,11 @@ Note: these requirements are strictly based off of my development on this app. T 1. Follow step 4 of [https://firebase.google.com/docs/ios/setup](Add Firebase to your Apple project) to install the firebase-ios-sdk in XCode, if it's not already present - you can select any packages in addition to Firebase Core / Firebase Messaging 1. Similarly, install the SQLite.swift package dependency in XCode 1. When running the debug build, ensure XCode is pointed to the connected iOS device - registering for push notifications does not work in the iOS simulators + +## Useful resources + +- https://www.raywenderlich.com/14958063-modern-efficient-core-data +- https://www.hackingwithswift.com/books/ios-swiftui/how-to-combine-core-data-and-swiftui +- https://stackoverflow.com/a/41783666/1440785 +- https://stackoverflow.com/questions/47374903/viewing-core-data-data-from-your-app-on-a-device +- https://debashishdas3100.medium.com/save-push-notifications-to-coredata-userdefaults-ios-swift-5-ea074390b57 diff --git a/ntfy.xcodeproj/xcuserdata/pheckel.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/ntfy.xcodeproj/xcuserdata/pheckel.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 3092e99..b594fc6 100644 --- a/ntfy.xcodeproj/xcuserdata/pheckel.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/ntfy.xcodeproj/xcuserdata/pheckel.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -7,15 +7,31 @@ + + + + diff --git a/ntfy/App/AppDelegate.swift b/ntfy/App/AppDelegate.swift index 9781391..714bcf5 100644 --- a/ntfy/App/AppDelegate.swift +++ b/ntfy/App/AppDelegate.swift @@ -5,9 +5,6 @@ import Firebase import FirebaseCore import CoreData -// https://stackoverflow.com/a/41783666/1440785 -// https://stackoverflow.com/questions/47374903/viewing-core-data-data-from-your-app-on-a-device - class AppDelegate: UIResponder, UIApplicationDelegate { let tag = "AppDelegate" @@ -25,22 +22,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } - func application( - _ application: UIApplication, - didReceiveRemoteNotification userInfo: [AnyHashable : Any], - fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void - ) { - Log.d(tag, "Called didReceiveRemoteNotification (with completionHandler). This is a no-op.", userInfo) - } - - - func application( - _ application: UIApplication, - didReceiveRemoteNotification userInfo: [AnyHashable: Any] - ) { - Log.d(tag, "Called didReceiveRemoteNotification (without completionHandler). This is a no-op.", userInfo) - } - func application( _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error @@ -50,7 +31,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application( _ application: UIApplication, - didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { let token = deviceToken .map { data in String(format: "%02.2hhx", data) } diff --git a/ntfy/App/AppMain.swift b/ntfy/App/AppMain.swift index 1610154..57e7664 100644 --- a/ntfy/App/AppMain.swift +++ b/ntfy/App/AppMain.swift @@ -1,6 +1,19 @@ import SwiftUI import Firebase +// Must have before release: +// TODO: Verify whether model version needs to be specified +// TODO: Add known future fields to model +// TODO: Store last notification ID in Subscription +// TODO: Make AppDelegate prettier +// TODO: appBaseUrl from config +// TODO: Remove duplicate code for poll() + +// Nice to have +// TODO: Make notification click open detail view +// TODO: Slide up dialog for "add topic" +// TODO: Pull down "refresh all" + @main struct AppMain: App { let tag = "main" @@ -9,6 +22,8 @@ struct AppMain: App { @StateObject private var store = Store.shared init() { + Log.d(tag, "Launching ntfy 🥳. Welcome!") + // We must configure Firebase here, and not in the AppDelegate. For some reason // configuring it there did not work. FirebaseApp.configure() diff --git a/ntfy/Persistence/Store.swift b/ntfy/Persistence/Store.swift index aa6ed42..e0abe0a 100644 --- a/ntfy/Persistence/Store.swift +++ b/ntfy/Persistence/Store.swift @@ -2,9 +2,13 @@ import Foundation import CoreData import Combine +/// Handles all persistence in the app by storing/loading subscriptions and notifications using Core Data. +/// There are sadly a lot of hacks in here, because I don't quite understand this fully. class Store: ObservableObject { static let shared = Store() static let tag = "Store" + static let appGroup = "group.io.heckel.ntfy" // Must match app group of ntfy = ntfyNSE targets + static let modelName = "ntfy" // Must match .xdatamodeld folder private let container: NSPersistentContainer var context: NSManagedObjectContext { @@ -14,13 +18,14 @@ class Store: ObservableObject { init(inMemory: Bool = false) { let storeUrl = (inMemory) ? URL(fileURLWithPath: "/dev/null") : FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: "group.io.heckel.ntfy")! + .containerURL(forSecurityApplicationGroupIdentifier: Store.appGroup)! .appendingPathComponent("ntfy.sqlite") + print(storeUrl) let description = NSPersistentStoreDescription(url: storeUrl) description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) // Set up container and observe changes from app extension - container = NSPersistentContainer(name: "ntfy") // See .xdatamodeld folder + container = NSPersistentContainer(name: Store.modelName) container.persistentStoreDescriptions = [description] container.loadPersistentStores { description, error in if let error = error { @@ -164,14 +169,12 @@ class Store: ObservableObject { } } - extension Store { static let sampleData = [ "stats": [ Message(id: "1", time: 1653048956, message: "In the last 24 hours, hyou had 5,000 users across 13 countries visit your website", title: "Record visitor numbers"), Message(id: "2", time: 1653058956, message: "201 users/h\n80 IPs", title: "This is a title"), Message(id: "3", time: 1643058956, message: "This message does not have a title, but is instead super long. Like really really long. It can't be any longer I think. I mean, there is s 4,000 byte limit of the message, so I guess I have to make this 4,000 bytes long. Or do I? 😁 I don't know. It's quite tedious to come up with something so long, so I'll stop now. Bye!", title: nil) - ], "backups": [], "announcements": [], @@ -189,6 +192,10 @@ extension Store { return store }() + static var previewEmpty: Store = { + return Store(inMemory: true) + }() + @discardableResult func makeSubscription(_ context: NSManagedObjectContext, _ topic: String, _ messages: [Message]) -> Subscription { let notifications = messages.map { makeNotification(context, $0) } diff --git a/ntfy/Persistence/Subscription.swift b/ntfy/Persistence/Subscription.swift index 3ad5f2e..d1afa52 100644 --- a/ntfy/Persistence/Subscription.swift +++ b/ntfy/Persistence/Subscription.swift @@ -1,14 +1,12 @@ import Foundation -// FIXME: Store last notification ID in Subscription - extension Subscription { func urlString() -> String { return topicUrl(baseUrl: baseUrl!, topic: topic!) } func displayName() -> String { - return topic ?? "" + return topicShortUrl(baseUrl: baseUrl!, topic: topic!) } func topicName() -> String { @@ -25,7 +23,7 @@ extension Subscription { func notificationsSorted() -> [Notification] { if let notifications = notifications { - return notifications.sortedArray(using: [NSSortDescriptor(key: "time", ascending: false)]) as! [Notification] + return notifications.sortedArray(using: [NSSortDescriptor(keyPath: \Notification.time, ascending: false)]) as! [Notification] } return [] } diff --git a/ntfy/Utils/ApiService.swift b/ntfy/Utils/ApiService.swift index 7f93e71..02f1209 100644 --- a/ntfy/Utils/ApiService.swift +++ b/ntfy/Utils/ApiService.swift @@ -64,4 +64,3 @@ class ApiService { }.resume() } } - diff --git a/ntfy/Utils/Log.swift b/ntfy/Utils/Log.swift index 394e32d..9e340fa 100644 --- a/ntfy/Utils/Log.swift +++ b/ntfy/Utils/Log.swift @@ -1,10 +1,3 @@ -// -// Log.swift -// ntfy -// -// Created by Philipp Heckel on 5/18/22. -// - import Foundation struct Log { diff --git a/ntfy/Views/NotificationListView.swift b/ntfy/Views/NotificationListView.swift index 459cccc..66bb65f 100644 --- a/ntfy/Views/NotificationListView.swift +++ b/ntfy/Views/NotificationListView.swift @@ -108,12 +108,13 @@ struct NotificationListView: View { .overlay(Group { if subscription.notificationCount() == 0 { VStack { - Text("You haven't received any notifications for this topic yet") + Text("You haven't received any notifications for this topic yet.") .font(.title2) .foregroundColor(.gray) .multilineTextAlignment(.center) .padding(.bottom) Text("To send notifications to this topic, simply PUT or POST to the topic URL.\n\nExample:\n`$ curl -d \"hi\" ntfy.sh/\(subscription.topicName())`\n\nDetailed instructions are available on [ntfy.sh](https;//ntfy.sh) and [in the docs](https:ntfy.sh/docs).") + .foregroundColor(.gray) } .padding(40) } @@ -229,7 +230,6 @@ struct NotificationListView_Previews: PreviewProvider { Group { let subscriptionWithNotifications = store.makeSubscription(store.context, "stats", Store.sampleData["stats"]!) let subscriptionWithoutNotifications = store.makeSubscription(store.context, "announcements", Store.sampleData["announcements"]!) - NotificationListView(subscription: subscriptionWithNotifications) .environment(\.managedObjectContext, store.context) .environmentObject(store) @@ -237,6 +237,5 @@ struct NotificationListView_Previews: PreviewProvider { .environment(\.managedObjectContext, store.context) .environmentObject(store) } - } } diff --git a/ntfy/Views/SubscriptionAddView.swift b/ntfy/Views/SubscriptionAddView.swift index def00b9..856458f 100644 --- a/ntfy/Views/SubscriptionAddView.swift +++ b/ntfy/Views/SubscriptionAddView.swift @@ -29,7 +29,7 @@ struct SubscriptionAddView: View { Button(action: subscribeAction) { Text("Subscribe") } - .disabled(!isValid(topic: sanitize(topic: topic))) + .disabled(!isValid(topic: topic)) } } } @@ -40,7 +40,8 @@ struct SubscriptionAddView: View { } private func isValid(topic: String) -> Bool { - return !topic.isEmpty && (topic.range(of: "^[-_A-Za-z0-9]{1,64}$", options: .regularExpression, range: nil, locale: nil) != nil) + let sanitizedTopic = sanitize(topic: topic) + return !sanitizedTopic.isEmpty && (sanitizedTopic.range(of: "^[-_A-Za-z0-9]{1,64}$", options: .regularExpression, range: nil, locale: nil) != nil) } private func subscribeAction() { diff --git a/ntfy/Views/SubscriptionListView.swift b/ntfy/Views/SubscriptionListView.swift index 37bea01..b9045e9 100644 --- a/ntfy/Views/SubscriptionListView.swift +++ b/ntfy/Views/SubscriptionListView.swift @@ -8,7 +8,7 @@ struct SubscriptionListView: View { @EnvironmentObject private var store: Store @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Subscription.topic, ascending: true)]) var subscriptions: FetchedResults - + private var subscriptionManager: SubscriptionManager { return SubscriptionManager(store: store) } @@ -31,9 +31,16 @@ struct SubscriptionListView: View { } .overlay(Group { if subscriptions.isEmpty { - Text("No topics") - .font(.headline) - .foregroundColor(.secondary) + VStack { + Text("It looks like you don't have any subscriptions yet") + .font(.title2) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding(.bottom) + Text("Click the + to create or subscribe to a topic. Afterwards, you receive notifications on your device when sending messages via PUT or POST.\n\nDetailed instructions are available on [ntfy.sh](https;//ntfy.sh) and [in the docs](https:ntfy.sh/docs).") + .foregroundColor(.gray) + } + .padding(40) } }) } @@ -45,7 +52,7 @@ struct SubscriptionItemNavView: View { @EnvironmentObject private var store: Store @ObservedObject var subscription: Subscription @State private var unsubscribeAlert = false - + private var subscriptionManager: SubscriptionManager { return SubscriptionManager(store: store) } @@ -57,7 +64,7 @@ struct SubscriptionItemNavView: View { } .opacity(0.0) .buttonStyle(PlainButtonStyle()) - + SubscriptionItemRowView(subscription: subscription) } .swipeActions(edge: .trailing) { @@ -84,10 +91,9 @@ struct SubscriptionItemNavView: View { } } - struct SubscriptionItemRowView: View { @ObservedObject var subscription: Subscription - + var body: some View { let totalNotificationCount = subscription.notificationCount() VStack(alignment: .leading, spacing: 0) { @@ -114,9 +120,10 @@ struct SubscriptionItemRowView: View { } struct SubscriptionsListView_Previews: PreviewProvider { - static var previews: some View { - SubscriptionListView() - .environment(\.managedObjectContext, Store.preview.context) - .environmentObject(Store.preview) - } + static var previews: some View { + let store = Store.preview // Store.previewEmpty + SubscriptionListView() + .environment(\.managedObjectContext, store.context) + .environmentObject(store) + } } diff --git a/ntfyNSE/NotificationService.swift b/ntfyNSE/NotificationService.swift index 65756cf..fcf620b 100644 --- a/ntfyNSE/NotificationService.swift +++ b/ntfyNSE/NotificationService.swift @@ -1,8 +1,11 @@ import UserNotifications import CoreData -// https://debashishdas3100.medium.com/save-push-notifications-to-coredata-userdefaults-ios-swift-5-ea074390b57 - +/// This app extension is responsible for persisting the incoming notification to the data store (Core Data). It will eventually be the entity that +/// fetches notification content from selfhosted servers (when a "poll request" is received). This is not implemented yet. +/// +/// Note that the app extension does not run as part of the main app, so log messages are not printed in the main Xcode window. To debug, +/// select Debug -> Attach to Process by PID or Name, and select the extension. Don't forget to set a breakpoint, or you're not gonna have a good time. class NotificationService: UNNotificationServiceExtension { let tag = "NotificationService" @@ -16,11 +19,19 @@ class NotificationService: UNNotificationServiceExtension { Log.d(tag, "Notification received (in service)") // Logs from extensions are not printed in Xcode! if let bestAttemptContent = bestAttemptContent { - // bestAttemptContent.title = "\(bestAttemptContent.title) [modified]" - let userInfo = bestAttemptContent.userInfo - Store.shared.save(notificationFromUserInfo: userInfo) + // Set notification title to short URL if there is no title. The title is always set + // by the server, but it may be empty. + if let topic = userInfo["topic"] as? String, + let title = userInfo["title"] as? String { + if title == "" { + bestAttemptContent.title = topicShortUrl(baseUrl: appBaseUrl, topic: topic) + } + } + + // Save notification to store, and display it + Store.shared.save(notificationFromUserInfo: userInfo) contentHandler(bestAttemptContent) } } @@ -34,5 +45,4 @@ class NotificationService: UNNotificationServiceExtension { contentHandler(bestAttemptContent) } } - }