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)
}
}
-
}