No subscriptions view; write down TODO; add default notification title

This commit is contained in:
Philipp Heckel 2022-05-20 20:28:02 -04:00
parent 3a0138b346
commit 8765a263c5
12 changed files with 98 additions and 64 deletions

View file

@ -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

View file

@ -7,15 +7,31 @@
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "57A50049-60E1-472C-ADB5-E4648E442C07"
shouldBeEnabled = "Yes"
uuid = "5A2511D7-DFC6-4574-ACD4-547E7B4EABA5"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "ntfyNSE/NotificationService.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "23"
endingLineNumber = "23"
startingLineNumber = "19"
endingLineNumber = "19"
landmarkName = "didReceive(_:withContentHandler:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "CD94B176-D6A8-4BCC-B48A-FDEB8DF8ECC7"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "ntfyNSE/NotificationService.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "34"
endingLineNumber = "34"
landmarkName = "didReceive(_:withContentHandler:)"
landmarkType = "7">
</BreakpointContent>

View file

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

View file

@ -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()

View file

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

View file

@ -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 ?? "<unknown>"
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 []
}

View file

@ -64,4 +64,3 @@ class ApiService {
}.resume()
}
}

View file

@ -1,10 +1,3 @@
//
// Log.swift
// ntfy
//
// Created by Philipp Heckel on 5/18/22.
//
import Foundation
struct Log {

View file

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

View file

@ -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() {

View file

@ -8,7 +8,7 @@ struct SubscriptionListView: View {
@EnvironmentObject private var store: Store
@FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Subscription.topic, ascending: true)]) var subscriptions: FetchedResults<Subscription>
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)
}
}

View file

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