No subscriptions view; write down TODO; add default notification title
This commit is contained in:
parent
3a0138b346
commit
8765a263c5
12 changed files with 98 additions and 64 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,4 +64,3 @@ class ApiService {
|
|||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
//
|
||||
// Log.swift
|
||||
// ntfy
|
||||
//
|
||||
// Created by Philipp Heckel on 5/18/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Log {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue