Merge branch 'main' of github.com:binwiederhier/ntfy-ios
|
|
@ -1,10 +1,29 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21E258" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Notification" representedClassName="Notification" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="message" attributeType="String"/>
|
||||
<attribute name="time" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="title" optional="YES" attributeType="String"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="Subscription" representedClassName="Subscription" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="baseUrl" optional="YES" attributeType="String"/>
|
||||
<attribute name="topic" optional="YES" attributeType="String"/>
|
||||
<attribute name="baseUrl" attributeType="String"/>
|
||||
<attribute name="topic" attributeType="String" minValueString="1" maxValueString="64" regularExpressionString="^[-_A-Za-z0-9]{1,64}$"/>
|
||||
<relationship name="notifications" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Notification"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="baseUrl"/>
|
||||
<constraint value="topic"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Subscription" positionX="-63" positionY="-18" width="128" height="59"/>
|
||||
<element name="Notification" positionX="-54" positionY="9" width="128" height="103"/>
|
||||
<element name="Subscription" positionX="-262.4760131835938" positionY="11.46405029296875" width="128" height="88"/>
|
||||
</elements>
|
||||
</model>
|
||||
23
README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# ntfy iOS App
|
||||
This is the iOS app for [ntfy](https://github.com/binwiederhier/ntfy) ([ntfy.sh](https://ntfy.sh)).
|
||||
|
||||
**Project status: The app is under HEAVY DEVELOPMENT.**
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Android App - Feature Parity](docs/FEATURE_PARITY.md)
|
||||
- [Getting Started - Development](docs/GETTING_STARTED.md)
|
||||
- [Technical Limitations](docs/TECHNICAL_LIMITATIONS.md)
|
||||
|
||||
## Contact me
|
||||
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||
(bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information
|
||||
[on my website](https://heckel.io/about).
|
||||
|
||||
## License
|
||||
Originally developed by [@Copephobia](https://github.com/Copephobia). He did the bulk of the work, and deserves most
|
||||
of the credit. Thank you @Copephobia!
|
||||
|
||||
The app is now maintained with ❤️ by [Philipp C. Heckel](https://heckel.io).
|
||||
The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2).
|
||||
|
||||
23
docs/FEATURE_PARITY.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# ntfy.sh iOS - Android Feature Parity
|
||||
|
||||
This document is to keep track of the feature parity between the iOS and Android ntfy.sh apps.
|
||||
|
||||
**Last Updated: 2021-02-26**
|
||||
|
||||
| Feature | iOS | Android | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Subscribe to default server topic | :white_check_mark: | :white_check_mark: |
|
||||
| Subscribe to self-hosted server topic | :x: | :white_check_mark: | Not yet implemented |
|
||||
| Instant delivery | :x: | :white_check_mark: | Foreground services not possible in iOS |
|
||||
| Pause notifications | :x: | :white_check_mark: | Will likely require [Filtering](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_developer_usernotifications_filtering) to prevent displaying notifications while still receiving them |
|
||||
| Send test notification | :white_check_mark: | :white_check_mark: | Not fully implemented |
|
||||
| Unsubscribe from topic | :white_check_mark: | :white_check_mark: |
|
||||
| Delete notifications | :white_check_mark: | :white_check_mark: |
|
||||
| Notification priority | :warning: | :white_check_mark: | Displays an exclamation mark in notification row for high.max priority, no changes to the actual push notification (sounds, vibrations), no prioirty filtering |
|
||||
| Tags and emojis | :white_check_mark: | :white_check_mark: |
|
||||
| Click action | :x: | :white_check_mark: | Not yet implemented |
|
||||
| Attachments | :warning: | :white_check_mark: | Not fully implemented |
|
||||
| User Authentication | :warning: | :white_check_mark: | Not fully implemented |
|
||||
| Dark mode | :white_check_mark: | :white_check_mark: | Dependent on iOS dark mode, may add override in |
|
||||
| Logging | :x: | :white_check_mark: | Not yet implemented |
|
||||
| Share to topic | :x: | :white_check_mark: | Not yet implemented |
|
||||
48
docs/GETTING_STARTED.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# ntfy.sh iOS - Getting Started with Development
|
||||
|
||||
## Requirements
|
||||
Note: these requirements are strictly based off of my development on this app. There may be other versions of macOS / XCode that work. Feel free to test on other versions!
|
||||
|
||||
1. macOS Monterey or later
|
||||
1. XCode 13.2+
|
||||
1. A physical iOS device (for push notifications, I could not get them to work in the XCode simulator)
|
||||
1. The [macOS development branch for ntfy](https://github.com/Copephobia/ntfy/tree/macos-development) (for APNS configuration)
|
||||
1. Firebase account
|
||||
1. Apple Developer license? (I forget if its possible to do testing without purchasing the license)
|
||||
|
||||
## Setup - Apple Developer
|
||||
|
||||
1. [Create a new key in Apple Developer Member Center](https://developer.apple.com/account/resources/authkeys/add)
|
||||
1. Select "Apple Push Notifications service (APNs)"
|
||||
1. Download the newly created key (should have a file name similar to `AuthKey_ZZZZZZ.p8`, where `ZZZZZZ` is the **Key ID**)
|
||||
1. Record your **Team ID** - it can be seen in the top-right corner of the page, or on your Account > Membership page
|
||||
|
||||
## Setup - Firebase
|
||||
|
||||
1. If you haven't already, create a Google / Firebase account
|
||||
1. Visit the [Firebase console](https://console.firebase.google.com)
|
||||
1. Create a new Firebase project:
|
||||
1. Enter a project name
|
||||
1. Disable Google Analytics (currently iOS app does not support analytics)
|
||||
1. On the "Project settings" page, add an iOS app
|
||||
1. Apple bundle ID - "com.copephobia.ntfy-ios" (this can be changed to match XCode's ntfy.sh target > "Bundle Identifier" value)
|
||||
1. Register the app
|
||||
1. Download the config file - GoogleInfo.plist (this will need to be included in the ntfy-ios repository / XCode)
|
||||
1. Generate a new service account private key for the ntfy server
|
||||
1. Go to "Project settings" > "Service accounts"
|
||||
1. Click "Generate new private key" to generate and download a private key to use for sending messages via the ntfy server
|
||||
|
||||
## Setup - ntfy server
|
||||
|
||||
1. Checkout the [macOS development branch for ntfy](https://github.com/Copephobia/ntfy/tree/macos-development)
|
||||
1. If not already made, make the `/etc/ntfy/` directory and move the service account private key to that folder
|
||||
1. Copy the `server/server.yml` file from the ntfy repository to `/etc/ntfy/`
|
||||
1. Modify the `/etc/ntfy/server.yml` file `firebase-key-file` value to the path of the private key
|
||||
1. Install go: `brew install go`
|
||||
1. In the ntfy repository, run `make build-simple`
|
||||
|
||||
## Setup - XCode
|
||||
|
||||
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
|
||||
27
docs/TECHNICAL_LIMITATIONS.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# ntfy.sh iOS - Technical Limitations
|
||||
|
||||
### No Foreground Services
|
||||
|
||||
Android can utilize foreground services to maintain a connection to the ntfy.sh / self-hosted server.
|
||||
|
||||
iOS doe not have any such feature, so the app can currently only rely on Firebase and background tasks.
|
||||
|
||||
### Background Tasks
|
||||
|
||||
iOS "intelligently" decides when to run background tasks, NOT when you schedule / request them.
|
||||
|
||||
Taken from [Background execution demystified](https://developer.apple.com/videos/play/wwdc2020/10063/), you would expect a "periodic" background task to be executed every 2 hours. In reality, iOS may decide to execute the background task at an irregular interval, sometimes not executing for hours at a time.
|
||||
|
||||
In my limited testing, I created a background app refresh task to gather new notifications periodically (every 15 minutes) poll the topics for new notifications. The end result was that my background task was executed only once in the day that I let it run.
|
||||
|
||||
This made me realize that background tasks are very unreliable in the context of ntfy.sh, where it would be best to periodically poll the topics for notifications. If the background task were to not execute for a longer period than notifications are cached, then it's possible that notifications would never make it to the app.
|
||||
|
||||
### Self-hosted Servers
|
||||
|
||||
Self-hosted servers are a tricky problem to solve.
|
||||
|
||||
Because the iOS app heavily almost exclusively on Firebase (unless you want to manually refresh every topic on your own to get the latest notifications), the self-hosted server would need to be running Firebase.
|
||||
|
||||
In addition to running firebase, the iOS users would need to build their own iOS app with their firebase credentials packaged in.
|
||||
|
||||
If we want to stick with the default (official) iOS app, and allow self-hosted servers to be used / subscribed to, the self-hosted server would need to relay any notifications to ntfy.sh, so that it may use the Firebase credentials/configuration there to properly send the notification to the iOS device.
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
9474F1C1282F2AA700CDE4DD /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1C0282F2AA700CDE4DD /* App.swift */; };
|
||||
9474F1C1282F2AA700CDE4DD /* AppMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1C0282F2AA700CDE4DD /* AppMain.swift */; };
|
||||
9474F1C3282F2AA700CDE4DD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1C2282F2AA700CDE4DD /* ContentView.swift */; };
|
||||
9474F1C5282F2AA800CDE4DD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9474F1C4282F2AA800CDE4DD /* Assets.xcassets */; };
|
||||
9474F1C8282F2AA800CDE4DD /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9474F1C7282F2AA800CDE4DD /* Preview Assets.xcassets */; };
|
||||
|
|
@ -16,9 +16,24 @@
|
|||
9474F1DC282F30B500CDE4DD /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9474F1DB282F30B500CDE4DD /* GoogleService-Info.plist */; };
|
||||
9474F1E7282F3FFD00CDE4DD /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1E6282F3FFD00CDE4DD /* NotificationService.swift */; };
|
||||
9474F1EB282F3FFD00CDE4DD /* ntfyNSE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9474F1E4282F3FFD00CDE4DD /* ntfyNSE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
9474F1F22830825600CDE4DD /* SubscriptionsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1F12830825600CDE4DD /* SubscriptionsList.swift */; };
|
||||
9474F1F22830825600CDE4DD /* SubscriptionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1F12830825600CDE4DD /* SubscriptionsListView.swift */; };
|
||||
9474F1F72830830700CDE4DD /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1F52830830700CDE4DD /* Model.xcdatamodeld */; };
|
||||
9474F1F92830835400CDE4DD /* DataController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1F82830835400CDE4DD /* DataController.swift */; };
|
||||
9474F1F92830835400CDE4DD /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1F82830835400CDE4DD /* Store.swift */; };
|
||||
9474F1FB28308A2B00CDE4DD /* SubscriptionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1FA28308A2B00CDE4DD /* SubscriptionRowView.swift */; };
|
||||
9474F1FD2831311A00CDE4DD /* SubscriptionAddView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1FC2831311A00CDE4DD /* SubscriptionAddView.swift */; };
|
||||
9474F1FF28316ACE00CDE4DD /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1FE28316ACE00CDE4DD /* Subscription.swift */; };
|
||||
9474F2052831D51500CDE4DD /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1F82830835400CDE4DD /* Store.swift */; };
|
||||
9474F2062831D73C00CDE4DD /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1F52830830700CDE4DD /* Model.xcdatamodeld */; };
|
||||
9474F20928331F3A00CDE4DD /* NotificationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F20728331F3900CDE4DD /* NotificationListView.swift */; };
|
||||
9474F20A28331F3A00CDE4DD /* NotificationRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F20828331F3A00CDE4DD /* NotificationRowView.swift */; };
|
||||
9474F20C283321C300CDE4DD /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F20B283321C300CDE4DD /* Notification.swift */; };
|
||||
9474F20F283326C500CDE4DD /* ApiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F20E283326C500CDE4DD /* ApiService.swift */; };
|
||||
9474F212283327C200CDE4DD /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F211283327C200CDE4DD /* Helpers.swift */; };
|
||||
9474F2132834755A00CDE4DD /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F20B283321C300CDE4DD /* Notification.swift */; };
|
||||
9474F2142834755E00CDE4DD /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F1FE28316ACE00CDE4DD /* Subscription.swift */; };
|
||||
9474F2152834758700CDE4DD /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F211283327C200CDE4DD /* Helpers.swift */; };
|
||||
9474F217283531A300CDE4DD /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F216283531A200CDE4DD /* Log.swift */; };
|
||||
94E9196C28353E0100F30170 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9474F216283531A200CDE4DD /* Log.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
|
|
@ -47,7 +62,7 @@
|
|||
|
||||
/* Begin PBXFileReference section */
|
||||
9474F1BD282F2AA700CDE4DD /* ntfy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ntfy.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9474F1C0282F2AA700CDE4DD /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
|
||||
9474F1C0282F2AA700CDE4DD /* AppMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMain.swift; sourceTree = "<group>"; };
|
||||
9474F1C2282F2AA700CDE4DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
9474F1C4282F2AA800CDE4DD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
9474F1C7282F2AA800CDE4DD /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
|
|
@ -58,9 +73,19 @@
|
|||
9474F1E4282F3FFD00CDE4DD /* ntfyNSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ntfyNSE.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9474F1E6282F3FFD00CDE4DD /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
9474F1E8282F3FFD00CDE4DD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
9474F1F12830825600CDE4DD /* SubscriptionsList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionsList.swift; sourceTree = "<group>"; };
|
||||
9474F1F12830825600CDE4DD /* SubscriptionsListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionsListView.swift; sourceTree = "<group>"; };
|
||||
9474F1F62830830700CDE4DD /* Model.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model.xcdatamodel; sourceTree = "<group>"; };
|
||||
9474F1F82830835400CDE4DD /* DataController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataController.swift; sourceTree = "<group>"; };
|
||||
9474F1F82830835400CDE4DD /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
|
||||
9474F1FA28308A2B00CDE4DD /* SubscriptionRowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionRowView.swift; sourceTree = "<group>"; };
|
||||
9474F1FC2831311A00CDE4DD /* SubscriptionAddView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAddView.swift; sourceTree = "<group>"; };
|
||||
9474F1FE28316ACE00CDE4DD /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = "<group>"; };
|
||||
9474F2042831CDBF00CDE4DD /* ntfyNSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ntfyNSE.entitlements; sourceTree = "<group>"; };
|
||||
9474F20728331F3900CDE4DD /* NotificationListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationListView.swift; sourceTree = "<group>"; };
|
||||
9474F20828331F3A00CDE4DD /* NotificationRowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationRowView.swift; sourceTree = "<group>"; };
|
||||
9474F20B283321C300CDE4DD /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = "<group>"; };
|
||||
9474F20E283326C500CDE4DD /* ApiService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApiService.swift; sourceTree = "<group>"; };
|
||||
9474F211283327C200CDE4DD /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = "<group>"; };
|
||||
9474F216283531A200CDE4DD /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -105,16 +130,15 @@
|
|||
9474F1BF282F2AA700CDE4DD /* ntfy */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9474F1C2282F2AA700CDE4DD /* ContentView.swift */,
|
||||
9474F1F12830825600CDE4DD /* SubscriptionsList.swift */,
|
||||
9474F210283326E000CDE4DD /* Utils */,
|
||||
9474F20D2833264F00CDE4DD /* App */,
|
||||
9474F1C4282F2AA800CDE4DD /* Assets.xcassets */,
|
||||
9474F1DB282F30B500CDE4DD /* GoogleService-Info.plist */,
|
||||
9474F1D6282F2FF700CDE4DD /* Info.plist */,
|
||||
9474F1D5282F2FED00CDE4DD /* ntfy.entitlements */,
|
||||
9474F1C0282F2AA700CDE4DD /* App.swift */,
|
||||
9474F1DB282F30B500CDE4DD /* GoogleService-Info.plist */,
|
||||
9474F1C4282F2AA800CDE4DD /* Assets.xcassets */,
|
||||
9474F2032831725A00CDE4DD /* Persistence */,
|
||||
9474F1C6282F2AA800CDE4DD /* Preview Content */,
|
||||
9474F1D1282F2D2C00CDE4DD /* AppDelegate.swift */,
|
||||
9474F1F82830835400CDE4DD /* DataController.swift */,
|
||||
9474F202283170F000CDE4DD /* Views */,
|
||||
);
|
||||
path = ntfy;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -137,12 +161,55 @@
|
|||
9474F1E5282F3FFD00CDE4DD /* ntfyNSE */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9474F2042831CDBF00CDE4DD /* ntfyNSE.entitlements */,
|
||||
9474F1E6282F3FFD00CDE4DD /* NotificationService.swift */,
|
||||
9474F1E8282F3FFD00CDE4DD /* Info.plist */,
|
||||
);
|
||||
path = ntfyNSE;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9474F202283170F000CDE4DD /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9474F1C2282F2AA700CDE4DD /* ContentView.swift */,
|
||||
9474F1FC2831311A00CDE4DD /* SubscriptionAddView.swift */,
|
||||
9474F20828331F3A00CDE4DD /* NotificationRowView.swift */,
|
||||
9474F20728331F3900CDE4DD /* NotificationListView.swift */,
|
||||
9474F1FA28308A2B00CDE4DD /* SubscriptionRowView.swift */,
|
||||
9474F1F12830825600CDE4DD /* SubscriptionsListView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9474F2032831725A00CDE4DD /* Persistence */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9474F1FE28316ACE00CDE4DD /* Subscription.swift */,
|
||||
9474F1F82830835400CDE4DD /* Store.swift */,
|
||||
9474F20B283321C300CDE4DD /* Notification.swift */,
|
||||
);
|
||||
path = Persistence;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9474F20D2833264F00CDE4DD /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9474F1D1282F2D2C00CDE4DD /* AppDelegate.swift */,
|
||||
9474F1C0282F2AA700CDE4DD /* AppMain.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9474F210283326E000CDE4DD /* Utils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9474F20E283326C500CDE4DD /* ApiService.swift */,
|
||||
9474F211283327C200CDE4DD /* Helpers.swift */,
|
||||
9474F216283531A200CDE4DD /* Log.swift */,
|
||||
);
|
||||
path = Utils;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
|
@ -250,11 +317,20 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9474F1F92830835400CDE4DD /* DataController.swift in Sources */,
|
||||
9474F1F92830835400CDE4DD /* Store.swift in Sources */,
|
||||
9474F212283327C200CDE4DD /* Helpers.swift in Sources */,
|
||||
9474F217283531A300CDE4DD /* Log.swift in Sources */,
|
||||
9474F20928331F3A00CDE4DD /* NotificationListView.swift in Sources */,
|
||||
9474F20A28331F3A00CDE4DD /* NotificationRowView.swift in Sources */,
|
||||
9474F1D2282F2D2C00CDE4DD /* AppDelegate.swift in Sources */,
|
||||
9474F1C3282F2AA700CDE4DD /* ContentView.swift in Sources */,
|
||||
9474F1F22830825600CDE4DD /* SubscriptionsList.swift in Sources */,
|
||||
9474F1C1282F2AA700CDE4DD /* App.swift in Sources */,
|
||||
9474F1FB28308A2B00CDE4DD /* SubscriptionRowView.swift in Sources */,
|
||||
9474F20C283321C300CDE4DD /* Notification.swift in Sources */,
|
||||
9474F1F22830825600CDE4DD /* SubscriptionsListView.swift in Sources */,
|
||||
9474F1FD2831311A00CDE4DD /* SubscriptionAddView.swift in Sources */,
|
||||
9474F1FF28316ACE00CDE4DD /* Subscription.swift in Sources */,
|
||||
9474F1C1282F2AA700CDE4DD /* AppMain.swift in Sources */,
|
||||
9474F20F283326C500CDE4DD /* ApiService.swift in Sources */,
|
||||
9474F1F72830830700CDE4DD /* Model.xcdatamodeld in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -263,7 +339,13 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9474F2132834755A00CDE4DD /* Notification.swift in Sources */,
|
||||
94E9196C28353E0100F30170 /* Log.swift in Sources */,
|
||||
9474F2152834758700CDE4DD /* Helpers.swift in Sources */,
|
||||
9474F1E7282F3FFD00CDE4DD /* NotificationService.swift in Sources */,
|
||||
9474F2052831D51500CDE4DD /* Store.swift in Sources */,
|
||||
9474F2062831D73C00CDE4DD /* Model.xcdatamodeld in Sources */,
|
||||
9474F2142834755E00CDE4DD /* Subscription.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -328,7 +410,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
|
|
@ -382,7 +464,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
|
|
@ -459,6 +541,7 @@
|
|||
9474F1ED282F3FFD00CDE4DD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = ntfyNSE/ntfyNSE.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = YXQ4AMS4B4;
|
||||
|
|
@ -485,6 +568,7 @@
|
|||
9474F1EE282F3FFD00CDE4DD /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = ntfyNSE/ntfyNSE.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = YXQ4AMS4B4;
|
||||
|
|
|
|||
137
ntfy/App/AppDelegate.swift
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import UIKit
|
||||
import SafariServices
|
||||
import UserNotifications
|
||||
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"
|
||||
let store = Store.shared
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
Log.d(tag, "ApplicationDelegate didFinishLaunchingWithOptions.")
|
||||
// FirebaseApp.configure() DOES NOT WORK
|
||||
FirebaseConfiguration.shared.setLoggerLevel(.max)
|
||||
Messaging.messaging().delegate = self
|
||||
|
||||
registerForPushNotifications()
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
guard let aps = userInfo["aps"] as? [String: AnyObject] else {
|
||||
completionHandler(.failed)
|
||||
return
|
||||
}
|
||||
print("didReceiveRemoteNotification")
|
||||
print(userInfo)
|
||||
}
|
||||
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) {
|
||||
print("didReceiveRemoteNotification 2")
|
||||
|
||||
guard let aps = userInfo["aps"] as? [String: AnyObject] else {
|
||||
return
|
||||
}
|
||||
print(userInfo)
|
||||
}
|
||||
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error
|
||||
) {
|
||||
print("Failed to register: \(error)")
|
||||
}
|
||||
|
||||
|
||||
|
||||
// This function is added here only for debugging purposes, and can be removed if swizzling is enabled.
|
||||
// If swizzling is disabled then this function must be implemented so that the APNs token can be paired to
|
||||
// the FCM registration token.
|
||||
func application(_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
print("APNs token retrieved: \(deviceToken)")
|
||||
|
||||
// With swizzling disabled you must set the APNs token here.
|
||||
Messaging.messaging().apnsToken = deviceToken
|
||||
|
||||
let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) }
|
||||
let token = tokenParts.joined()
|
||||
print("Device Token: \(token)")
|
||||
}
|
||||
|
||||
func registerForPushNotifications() {
|
||||
UNUserNotificationCenter.current()
|
||||
.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, _ in
|
||||
print("granted: \(granted)")
|
||||
guard granted else { return }
|
||||
self?.getNotificationSettings()
|
||||
}
|
||||
}
|
||||
|
||||
func getNotificationSettings() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
print("Notification settings: \(settings)")
|
||||
guard settings.authorizationStatus == .authorized else { return }
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDelegate: UNUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
let userInfo = notification.request.content.userInfo
|
||||
Log.d(tag, "Notification received via userNotificationCenter(willPresent)", userInfo)
|
||||
store.saveNotification(fromUserInfo: userInfo)
|
||||
completionHandler([[.alert, .sound]])
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
Log.d(tag, "Notification received via userNotificationCenter(didReceive)", userInfo)
|
||||
store.saveNotification(fromUserInfo: userInfo)
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDelegate: MessagingDelegate {
|
||||
func messaging(
|
||||
_ messaging: Messaging,
|
||||
didReceiveRegistrationToken fcmToken: String?
|
||||
) {
|
||||
Log.d(tag, "Firebase token received: \(String(describing: fcmToken))")
|
||||
|
||||
// FIXME: Is this necessary?
|
||||
|
||||
let dataDict: [String: String] = ["token": fcmToken ?? ""]
|
||||
NotificationCenter.default.post(
|
||||
name: UserNotifications.Notification.Name("FCMToken"),
|
||||
object: nil,
|
||||
userInfo: dataDict
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -8,11 +8,10 @@
|
|||
import SwiftUI
|
||||
import Firebase
|
||||
|
||||
|
||||
@main
|
||||
struct ntfyApp: App {
|
||||
struct AppMain: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate: AppDelegate
|
||||
@StateObject private var dataController = DataController()
|
||||
@StateObject private var store = Store.shared
|
||||
|
||||
init() {
|
||||
FirebaseApp.configure()
|
||||
|
|
@ -21,7 +20,7 @@ struct ntfyApp: App {
|
|||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, dataController.container.viewContext)
|
||||
.environment(\.managedObjectContext, store.container.viewContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import UIKit
|
||||
import SafariServices
|
||||
import UserNotifications
|
||||
import Firebase
|
||||
import FirebaseCore
|
||||
|
||||
// https://stackoverflow.com/a/41783666/1440785
|
||||
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
let gcmMessageIDKey = "gcm.message_id"
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
print("ApplicationDelegate didFinishLaunchingWithOptions.")
|
||||
// FirebaseApp.configure() DOES NOT WORK
|
||||
FirebaseConfiguration.shared.setLoggerLevel(.max)
|
||||
Messaging.messaging().delegate = self
|
||||
|
||||
Messaging.messaging().subscribe(toTopic: "philtest")
|
||||
|
||||
registerForPushNotifications()
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
// Check if launched from notification
|
||||
let notificationOption = launchOptions?[.remoteNotification]
|
||||
|
||||
// 1
|
||||
if
|
||||
let notification = notificationOption as? [String: AnyObject],
|
||||
let aps = notification["aps"] as? [String: AnyObject] {
|
||||
print("there is a new item")
|
||||
// 2
|
||||
|
||||
// 3
|
||||
(window?.rootViewController as? UITabBarController)?.selectedIndex = 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
guard let aps = userInfo["aps"] as? [String: AnyObject] else {
|
||||
completionHandler(.failed)
|
||||
return
|
||||
}
|
||||
print("didReceiveRemoteNotification")
|
||||
print(userInfo)
|
||||
}
|
||||
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) {
|
||||
print("didReceiveRemoteNotification 2")
|
||||
|
||||
guard let aps = userInfo["aps"] as? [String: AnyObject] else {
|
||||
return
|
||||
}
|
||||
print(userInfo)
|
||||
}
|
||||
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error
|
||||
) {
|
||||
print("Failed to register: \(error)")
|
||||
}
|
||||
|
||||
|
||||
|
||||
// This function is added here only for debugging purposes, and can be removed if swizzling is enabled.
|
||||
// If swizzling is disabled then this function must be implemented so that the APNs token can be paired to
|
||||
// the FCM registration token.
|
||||
func application(_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
print("APNs token retrieved: \(deviceToken)")
|
||||
|
||||
// With swizzling disabled you must set the APNs token here.
|
||||
Messaging.messaging().apnsToken = deviceToken
|
||||
|
||||
let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) }
|
||||
let token = tokenParts.joined()
|
||||
print("Device Token: \(token)")
|
||||
}
|
||||
|
||||
func registerForPushNotifications() {
|
||||
UNUserNotificationCenter.current()
|
||||
.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, _ in
|
||||
print("granted: \(granted)")
|
||||
guard granted else { return }
|
||||
self?.getNotificationSettings()
|
||||
}
|
||||
}
|
||||
|
||||
func getNotificationSettings() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
print("Notification settings: \(settings)")
|
||||
guard settings.authorizationStatus == .authorized else { return }
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// [START ios_10_message_handling]
|
||||
//@available(iOS 10, *)
|
||||
extension AppDelegate: UNUserNotificationCenterDelegate {
|
||||
// Receive displayed notifications for iOS 10 devices.
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions)
|
||||
-> Void) {
|
||||
print("willPresent")
|
||||
let userInfo = notification.request.content.userInfo
|
||||
|
||||
// With swizzling disabled you must let Messaging know about the message, for Analytics
|
||||
// Messaging.messaging().appDidReceiveMessage(userInfo)
|
||||
// [START_EXCLUDE]
|
||||
// Print message ID.
|
||||
if let messageID = userInfo[gcmMessageIDKey] {
|
||||
print("Message ID: \(messageID)")
|
||||
}
|
||||
// [END_EXCLUDE]
|
||||
// Print full message.
|
||||
print(userInfo)
|
||||
|
||||
// Change this to your preferred presentation option
|
||||
completionHandler([[.alert, .sound]])
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
print("didReceive")
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
|
||||
// [START_EXCLUDE]
|
||||
// Print message ID.
|
||||
if let messageID = userInfo[gcmMessageIDKey] {
|
||||
print("Message ID: \(messageID)")
|
||||
}
|
||||
// [END_EXCLUDE]
|
||||
// With swizzling disabled you must let Messaging know about the message, for Analytics
|
||||
// Messaging.messaging().appDidReceiveMessage(userInfo)
|
||||
// Print full message.
|
||||
print(userInfo)
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
// [END ios_10_message_handling]
|
||||
extension AppDelegate: MessagingDelegate {
|
||||
// [START refresh_token]
|
||||
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
|
||||
print("Firebase registration token: \(String(describing: fcmToken))")
|
||||
|
||||
let dataDict: [String: String] = ["token": fcmToken ?? ""]
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.Name("FCMToken"),
|
||||
object: nil,
|
||||
userInfo: dataDict
|
||||
)
|
||||
// TODO: If necessary send token to application server.
|
||||
// Note: This callback is fired at each app startup and whenever a new token is generated.
|
||||
}
|
||||
|
||||
// [END refresh_token]
|
||||
}
|
||||
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 695 B |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 977 B |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
ntfy/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
|
|
@ -1,91 +1,109 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-20x20@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-20x20@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-29x29@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-29x29@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-40x40@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-40x40@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-60x60@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-60x60@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-20x20@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-20x20@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-29x29@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-29x29@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-40x40@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-40x40@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-76x76@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-76x76@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-83.5x83.5@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-1024x1024@1x.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
//
|
||||
// DataController.swift
|
||||
// ntfy
|
||||
//
|
||||
// Created by Philipp Heckel on 5/14/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
class DataController: ObservableObject {
|
||||
let container = NSPersistentContainer(name: "Model")
|
||||
|
||||
init() {
|
||||
container.loadPersistentStores { description, error in
|
||||
if let error = error {
|
||||
print("Core Data failed to load: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
ntfy/Persistence/Notification.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// Notification.swift
|
||||
// ntfy
|
||||
//
|
||||
// Created by Philipp Heckel on 5/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Notification {
|
||||
func shortDateTime() -> String {
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(self.time))
|
||||
let calendar = Calendar.current
|
||||
|
||||
if calendar.isDateInYesterday(date) {
|
||||
return "Yesterday"
|
||||
}
|
||||
|
||||
let dateFormatter = DateFormatter()
|
||||
|
||||
if calendar.isDateInToday(date) {
|
||||
dateFormatter.dateFormat = "h:mm a"
|
||||
dateFormatter.amSymbol = "AM"
|
||||
dateFormatter.pmSymbol = "PM"
|
||||
} else {
|
||||
dateFormatter.dateStyle = .medium
|
||||
dateFormatter.timeStyle = .short
|
||||
}
|
||||
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
struct Message: Decodable {
|
||||
var id: String
|
||||
var time: Int64
|
||||
var message: String?
|
||||
var title: String?
|
||||
}
|
||||
92
ntfy/Persistence/Store.swift
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//
|
||||
// DataController.swift
|
||||
// ntfy
|
||||
//
|
||||
// Created by Philipp Heckel on 5/14/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
class Store: ObservableObject {
|
||||
static let shared = Store()
|
||||
|
||||
let tag = "Store"
|
||||
let container: NSPersistentContainer
|
||||
var context: NSManagedObjectContext
|
||||
|
||||
init() {
|
||||
let directory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.io.heckel.ntfy")!
|
||||
let storeUrl = directory.appendingPathComponent("ntfy.sqlite")
|
||||
let description = NSPersistentStoreDescription(url: storeUrl)
|
||||
|
||||
container = NSPersistentContainer(name: "Model")
|
||||
container.persistentStoreDescriptions = [description]
|
||||
container.loadPersistentStores { description, error in
|
||||
if let error = error {
|
||||
print("Core Data failed to load: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
context = container.viewContext
|
||||
}
|
||||
|
||||
func saveSubscription(baseUrl: String, topic: String) {
|
||||
let subscription = Subscription(context: context)
|
||||
subscription.baseUrl = appBaseUrl
|
||||
subscription.topic = topic
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func getSubscription(baseUrl: String, topic: String) -> Subscription? {
|
||||
let fetchRequest = Subscription.fetchRequest()
|
||||
let baseUrlPredicate = NSPredicate(format: "baseUrl = %@", baseUrl)
|
||||
let topicPredicate = NSPredicate(format: "topic = %@", topic)
|
||||
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [baseUrlPredicate, topicPredicate])
|
||||
|
||||
return try? context.fetch(fetchRequest).first
|
||||
}
|
||||
|
||||
func saveNotification(fromUserInfo userInfo: [AnyHashable: Any]) {
|
||||
guard let id = userInfo["id"] as? String,
|
||||
let topic = userInfo["topic"] as? String, // FIXME: Notification should also contain baseUrl
|
||||
let time = userInfo["time"] as? String,
|
||||
let timeInt = Int64(time),
|
||||
let message = userInfo["message"] as? String else {
|
||||
print("Unknown or irrelevant message", userInfo)
|
||||
return
|
||||
}
|
||||
guard let subscription = getSubscription(baseUrl: appBaseUrl, topic: topic) else {
|
||||
print("Subscription for topic \(topic) unknown")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let notification = Notification(context: context)
|
||||
notification.id = id
|
||||
notification.time = timeInt
|
||||
notification.message = message
|
||||
notification.title = userInfo["title"] as? String ?? ""
|
||||
subscription.addToNotifications(notification)
|
||||
try context.save()
|
||||
} catch let error {
|
||||
Log.w(tag, "Cannot store notification", error)
|
||||
context.rollback()
|
||||
}
|
||||
}
|
||||
|
||||
func saveNotification(fromMessage message: Message, subscription: Subscription) {
|
||||
do {
|
||||
let notification = Notification(context: context)
|
||||
notification.id = message.id
|
||||
notification.time = message.time
|
||||
notification.message = message.message ?? ""
|
||||
notification.title = message.title ?? ""
|
||||
subscription.addToNotifications(notification)
|
||||
try context.save()
|
||||
} catch let error {
|
||||
print(error)
|
||||
context.rollback()
|
||||
}
|
||||
}
|
||||
}
|
||||
30
ntfy/Persistence/Subscription.swift
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// Subscription.swift
|
||||
// ntfy
|
||||
//
|
||||
// Created by Philipp Heckel on 5/15/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Subscription {
|
||||
func urlString() -> String {
|
||||
return topicUrl(baseUrl: baseUrl!, topic: topic!)
|
||||
}
|
||||
|
||||
func displayName() -> String {
|
||||
return topic ?? "<unknown>"
|
||||
}
|
||||
|
||||
func notificationCount() -> Int {
|
||||
return notifications?.count ?? 0
|
||||
}
|
||||
|
||||
func lastNotification() -> Notification? {
|
||||
return notificationsSorted().first
|
||||
}
|
||||
|
||||
func notificationsSorted() -> [Notification] {
|
||||
return notifications!.sortedArray(using: [NSSortDescriptor(key: "time", ascending: false)]) as! [Notification]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
//
|
||||
// ContentView.swift
|
||||
// ntfy-ios
|
||||
//
|
||||
// Created by Andrew Cope on 1/15/22.
|
||||
//
|
||||
|
||||
// https://www.hackingwithswift.com/books/ios-swiftui/how-to-combine-core-data-and-swiftui
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SubscriptionsList: View {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@FetchRequest(sortDescriptors: []) var subscriptions: FetchedResults<Subscription>
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List(subscriptions) { subscription in
|
||||
Text("\(subscription.topic ?? "")")
|
||||
}
|
||||
Button("Add") {
|
||||
let firstNames = ["Ginny", "Harry", "Hermione", "Luna", "Ron"]
|
||||
let chosenFirstName = firstNames.randomElement()!
|
||||
|
||||
let subscription = Subscription(context: context)
|
||||
subscription.baseUrl = "https://ntfy.sh"
|
||||
subscription.topic = chosenFirstName
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
struct SubscriptionsList_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SubscriptionsList(
|
||||
subscriptions: NtfySubscriptionList,
|
||||
currentView: (.subscriptionList)
|
||||
)
|
||||
}
|
||||
}
|
||||
*/
|
||||
67
ntfy/Utils/ApiService.swift
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import Foundation
|
||||
|
||||
class ApiService: NSObject {
|
||||
static let shared = ApiService()
|
||||
let tag = "ApiService"
|
||||
|
||||
func poll(subscription: Subscription, completionHandler: @escaping ([Message]?, Error?) -> Void) {
|
||||
guard let url = URL(string: subscription.urlString()) else { return }
|
||||
let lastNotificationTime = subscription.lastNotification()?.time ?? 0
|
||||
let sinceString = lastNotificationTime > 0 ? String(lastNotificationTime) : "all";
|
||||
let urlString = "\(url)/json?poll=1&since=\(sinceString)"
|
||||
|
||||
Log.d(tag, "Polling from \(urlString)")
|
||||
fetchJsonData(urlString: urlString, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func publish(
|
||||
subscription: Subscription,
|
||||
message: String,
|
||||
title: String,
|
||||
priority: Int = 3,
|
||||
tags: [String] = [],
|
||||
completionHandler: @escaping (Notification?, Error?) -> Void
|
||||
) {
|
||||
guard let url = URL(string: subscription.urlString()) else { return }
|
||||
var request = URLRequest(url: url)
|
||||
|
||||
Log.d(tag, "Publishing to \(url)")
|
||||
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(title, forHTTPHeaderField: "Title")
|
||||
request.setValue(String(priority), forHTTPHeaderField: "Priority")
|
||||
request.setValue(tags.joined(separator: ","), forHTTPHeaderField: "Tags")
|
||||
request.httpBody = message.data(using: String.Encoding.utf8)
|
||||
URLSession.shared.dataTask(with: request) { (data, response, error) in
|
||||
print(data)
|
||||
print(response)
|
||||
print(error)
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func fetchJsonData<T: Decodable>(urlString: String, completionHandler: @escaping ([T]?, Error?) -> ()) {
|
||||
guard let url = URL(string: urlString) else { return }
|
||||
let request = URLRequest(url: url)
|
||||
|
||||
URLSession.shared.dataTask(with: request) { (data, response, error) in
|
||||
if let error = error {
|
||||
print(error)
|
||||
completionHandler(nil, error)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let lines = String(decoding: data!, as: UTF8.self).split(whereSeparator: \.isNewline)
|
||||
var notifications: [T] = []
|
||||
for jsonLine in lines {
|
||||
notifications.append(try JSONDecoder().decode(T.self, from: jsonLine.data(using: .utf8)!))
|
||||
}
|
||||
completionHandler(notifications, nil)
|
||||
} catch {
|
||||
print(error)
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
20
ntfy/Utils/Helpers.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// Helpers.swift
|
||||
// ntfy
|
||||
//
|
||||
// Created by Philipp Heckel on 5/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
let appBaseUrl = "http://192.168.1.4" // FIXME
|
||||
|
||||
func topicUrl(baseUrl: String, topic: String) -> String {
|
||||
return "\(baseUrl)/\(topic)"
|
||||
}
|
||||
|
||||
func topicShortUrl(baseUrl: String, topic: String) -> String {
|
||||
return topicUrl(baseUrl: baseUrl, topic: topic)
|
||||
.replacingOccurrences(of: "http://", with: "")
|
||||
.replacingOccurrences(of: "https://", with: "")
|
||||
}
|
||||
62
ntfy/Utils/Log.swift
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//
|
||||
// Log.swift
|
||||
// ntfy
|
||||
//
|
||||
// Created by Philipp Heckel on 5/18/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Log {
|
||||
static var dateFormat = "yyyy-MM-dd hh:mm:ss.SSSSSSZ"
|
||||
static var dateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = dateFormat
|
||||
formatter.locale = Locale.current
|
||||
formatter.timeZone = TimeZone.current
|
||||
return formatter
|
||||
}
|
||||
|
||||
static func d(_ tag: String, _ message: String, _ other: Any...) {
|
||||
log(.debug, tag, message, other)
|
||||
}
|
||||
|
||||
static func i(_ tag: String, _ message: String, _ other: Any...) {
|
||||
log(.info, tag, message, other)
|
||||
}
|
||||
|
||||
static func w(_ tag: String, _ message: String, _ other: Any...) {
|
||||
log(.warning, tag, message, other)
|
||||
}
|
||||
|
||||
static func e(_ tag: String, _ message: String, _ other: Any...) {
|
||||
log(.error, tag, message, other)
|
||||
}
|
||||
|
||||
static func log(_ level: LogLevel, _ tag: String, _ message: String, _ other: Any...) {
|
||||
print("\(dateStr()) ntfyApp [\(levelStr(level))] \(tag): \(message)")
|
||||
if !other.isEmpty {
|
||||
print(other)
|
||||
}
|
||||
}
|
||||
|
||||
static func dateStr() -> String {
|
||||
return dateFormatter.string(from: Date())
|
||||
}
|
||||
|
||||
static func levelStr(_ level: LogLevel) -> String {
|
||||
switch level {
|
||||
case .debug: return "DEBUG"
|
||||
case .info: return "INFO"
|
||||
case .warning: return "WARNING ⚠️"
|
||||
case .error: return "ERROR ‼️"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LogLevel {
|
||||
case debug
|
||||
case info
|
||||
case warning
|
||||
case error
|
||||
}
|
||||
182
ntfy/Views/NotificationListView.swift
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
//
|
||||
// SubscriptionDetail.swift
|
||||
// ntfy.sh
|
||||
//
|
||||
// Created by Andrew Cope on 1/15/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum ActiveAlert {
|
||||
case clear, unsubscribe, selected
|
||||
}
|
||||
|
||||
struct NotificationListView: View {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
@ObservedObject var subscription: Subscription
|
||||
|
||||
@State private var editMode = EditMode.inactive
|
||||
@State private var selection = Set<Notification>()
|
||||
|
||||
@State private var showAlert = false
|
||||
@State private var activeAlert: ActiveAlert = .clear
|
||||
|
||||
private let store = Store.shared
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selection) {
|
||||
ForEach(subscription.notificationsSorted(), id: \.self) { notification in
|
||||
NotificationRowView(notification: notification)
|
||||
}
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.environment(\.editMode, self.$editMode)
|
||||
.navigationBarBackButtonHidden(self.editMode == .active)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text(subscription.displayName()).font(.headline)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if (self.editMode == .active) {
|
||||
editButton
|
||||
} else {
|
||||
Menu {
|
||||
editButton
|
||||
Button("Send test notification") {
|
||||
let possibleTags = ["warning", "skull", "success", "triangular_flag_on_post", "de", "us", "dog", "cat", "rotating_light", "bike", "backup", "rsync", "this-s-a-tag", "ios"]
|
||||
let priority = Int.random(in: 1..<6)
|
||||
let tags = Array(possibleTags.shuffled().prefix(Int.random(in: 0..<4)))
|
||||
ApiService.shared.publish(
|
||||
subscription: subscription,
|
||||
message: "This is a test notification from the ntfy iOS app. It has a priority of \(priority). If you send another one, it may look different.",
|
||||
title: "Test: You can set a title if you like",
|
||||
priority: priority,
|
||||
tags: tags
|
||||
) { _,_ in
|
||||
print("Success")
|
||||
}
|
||||
}
|
||||
Button("Clear all notifications") {
|
||||
self.showAlert = true
|
||||
self.activeAlert = .clear
|
||||
}
|
||||
Button("Unsubscribe") {
|
||||
self.showAlert = true
|
||||
self.activeAlert = .unsubscribe
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
if (self.editMode == .active) {
|
||||
Button(action: {
|
||||
self.showAlert = true
|
||||
self.activeAlert = .selected
|
||||
}) {
|
||||
Text("Delete")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showAlert) {
|
||||
switch activeAlert {
|
||||
case .clear:
|
||||
return Alert(
|
||||
title: Text("Clear notifications"),
|
||||
message: Text("Do you really want to delete all of the notifications in this topic?"),
|
||||
primaryButton: .destructive(
|
||||
Text("Permanently delete"),
|
||||
action: {
|
||||
//Database.current.deleteNotificationsForSubscription(subscription: subscription)
|
||||
//viewModel.notifications = Database.current.getNotifications(subscription: subscription)
|
||||
//subscription.loadNotifications()
|
||||
}),
|
||||
secondaryButton: .cancel())
|
||||
case .unsubscribe:
|
||||
return Alert(
|
||||
title: Text("Unsubscribe"),
|
||||
message: Text("Do you really want to unsubscribe from this topic and delete all of the notifications you received?"),
|
||||
primaryButton: .destructive(
|
||||
Text("Unsubscribe"),
|
||||
action: {
|
||||
try? context.delete(subscription)
|
||||
self.presentationMode.wrappedValue.dismiss()
|
||||
}),
|
||||
secondaryButton: .cancel())
|
||||
case .selected:
|
||||
return Alert(
|
||||
title: Text("Delete"),
|
||||
message: Text("Do you really want to delete these selected notifications?"),
|
||||
primaryButton: .destructive(
|
||||
Text("Delete"),
|
||||
action: {
|
||||
//deleteSelectedNotifications(notifications: subscription.notifications)
|
||||
self.editMode = .inactive
|
||||
}),
|
||||
secondaryButton: .cancel())
|
||||
}
|
||||
}
|
||||
/*.overlay(Group {
|
||||
if subscription.notifications.isEmpty() {
|
||||
Text("No Notifications")
|
||||
.font(.headline)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
})*/
|
||||
.refreshable {
|
||||
print("Refresh")
|
||||
ApiService.shared.poll(subscription: subscription) { messages, error in
|
||||
guard let messages = messages else {
|
||||
print(error)
|
||||
return
|
||||
}
|
||||
print("Saving new messages to subscription \(subscription.urlString())", messages)
|
||||
DispatchQueue.main.async {
|
||||
for message in messages {
|
||||
store.saveNotification(fromMessage: message, subscription: subscription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
print("onAppear")
|
||||
//subscription.loadNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
private var editButton: some View {
|
||||
if editMode == .inactive {
|
||||
return Button(action: {
|
||||
self.editMode = .active
|
||||
self.selection = Set<Notification>()
|
||||
}) {
|
||||
Text("Select messages")
|
||||
}
|
||||
} else {
|
||||
return Button(action: {
|
||||
self.editMode = .inactive
|
||||
self.selection = Set<Notification>()
|
||||
}) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteSelectedNotifications(notifications: [Notification]) {
|
||||
print("deletedSelected")
|
||||
/*
|
||||
for id in selection {
|
||||
if let index = subscription.notifications.lastIndex(where: { $0 == id }) {
|
||||
subscription.notifications.remove(at: index)
|
||||
//Database.current.deleteNotification(notification: notifications[index])
|
||||
}
|
||||
}*/
|
||||
selection = Set<Notification>()
|
||||
}
|
||||
}
|
||||
32
ntfy/Views/NotificationRowView.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// NotificationRow.swift
|
||||
// ntfy.sh
|
||||
//
|
||||
// Created by Andrew Cope on 1/15/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NotificationRowView: View {
|
||||
let notification: Notification
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text(notification.title ?? "")
|
||||
.font(.headline)
|
||||
.bold()
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(notification.shortDateTime())
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.gray)
|
||||
|
||||
}
|
||||
Spacer()
|
||||
Text(notification.message ?? "")
|
||||
.font(.body)
|
||||
}
|
||||
.padding(.all, 4)
|
||||
}
|
||||
}
|
||||
55
ntfy/Views/SubscriptionAddView.swift
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
//
|
||||
// AddSubscriptionView.swift
|
||||
// ntfy.sh
|
||||
//
|
||||
// Created by Andrew Cope on 1/16/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import FirebaseMessaging
|
||||
|
||||
struct SubscriptionAddView: View {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
|
||||
@State private var topic: String = ""
|
||||
private let store = Store.shared
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Form {
|
||||
Section(
|
||||
header: Text("Topic name"),
|
||||
footer: Text("Topics may not be password protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications")
|
||||
) {
|
||||
TextField("Topic name, e.g. phil_alerts", text: $topic)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: subscribeAction) {
|
||||
Text("Subscribe")
|
||||
}
|
||||
.disabled(!isTopicValid(topic: sanitizeTopic(topic: topic)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sanitizeTopic(topic: String) -> String {
|
||||
return topic.trimmingCharacters(in: [" "])
|
||||
}
|
||||
|
||||
private func isTopicValid(topic: String) -> Bool {
|
||||
return !topic.isEmpty && (topic.range(of: "^[-_A-Za-z0-9]{1,64}$", options: .regularExpression, range: nil, locale: nil) != nil)
|
||||
}
|
||||
|
||||
private func subscribeAction() {
|
||||
print("Subscribing to \(topicUrl(baseUrl: appBaseUrl, topic: topic))")
|
||||
Messaging.messaging().subscribe(toTopic: topic)
|
||||
|
||||
store.saveSubscription(baseUrl: appBaseUrl, topic: topic)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
36
ntfy/Views/SubscriptionRowView.swift
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//
|
||||
// SubscriptionRow.swift
|
||||
// ntfy.sh
|
||||
//
|
||||
// Created by Andrew Cope on 1/15/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SubscriptionRow: View {
|
||||
@ObservedObject var subscription: Subscription
|
||||
|
||||
var body: some View {
|
||||
let totalNotificationCount = subscription.notificationCount()
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text(subscription.displayName())
|
||||
.font(.headline)
|
||||
.bold()
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(subscription.lastNotification()?.shortDateTime() ?? "")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.gray)
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.system(size: 12.0))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(totalNotificationCount) notification\(totalNotificationCount != 1 ? "s" : "")")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.all, 4)
|
||||
}
|
||||
}
|
||||
84
ntfy/Views/SubscriptionsListView.swift
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
//
|
||||
// ContentView.swift
|
||||
// ntfy-ios
|
||||
//
|
||||
// Created by Andrew Cope on 1/15/22.
|
||||
//
|
||||
|
||||
// https://www.hackingwithswift.com/books/ios-swiftui/how-to-combine-core-data-and-swiftui
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import FirebaseMessaging
|
||||
|
||||
struct SubscriptionsList: View {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@FetchRequest(sortDescriptors: [NSSortDescriptor(key: "topic", ascending: true)]) var subscriptions: FetchedResults<Subscription>
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
ForEach(subscriptions) { subscription in
|
||||
ZStack {
|
||||
NavigationLink(destination: NotificationListView(subscription: subscription)) {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0.0)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
SubscriptionRow(subscription: subscription)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
unsubscribe(subscription)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
.navigationTitle("Subscribed topics")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
NavigationLink(
|
||||
destination: SubscriptionAddView()
|
||||
) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
.overlay(Group {
|
||||
if subscriptions.isEmpty {
|
||||
Text("No topics")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
})
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
|
||||
func unsubscribe(_ subscription: Subscription) {
|
||||
DispatchQueue.main.async {
|
||||
if let topic = subscription.topic {
|
||||
Messaging.messaging().unsubscribe(fromTopic: topic)
|
||||
}
|
||||
context.delete(subscription)
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
struct SubscriptionsList_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SubscriptionsList(
|
||||
subscriptions: NtfySubscriptionList,
|
||||
currentView: (.subscriptionList)
|
||||
)
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
|
@ -4,5 +4,9 @@
|
|||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.heckel.ntfy</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -6,20 +6,27 @@
|
|||
//
|
||||
|
||||
import UserNotifications
|
||||
import CoreData
|
||||
|
||||
// https://debashishdas3100.medium.com/save-push-notifications-to-coredata-userdefaults-ios-swift-5-ea074390b57
|
||||
|
||||
class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
var bestAttemptContent: UNMutableNotificationContent?
|
||||
|
||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
|
||||
override func didReceive(
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
self.contentHandler = contentHandler
|
||||
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
|
||||
|
||||
print("NotificationService didReceive")
|
||||
if let bestAttemptContent = bestAttemptContent {
|
||||
// Modify the notification content here...
|
||||
bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
|
||||
|
||||
let userInfo = bestAttemptContent.userInfo
|
||||
Store.shared.saveNotification(fromUserInfo: userInfo)
|
||||
contentHandler(bestAttemptContent)
|
||||
}
|
||||
}
|
||||
|
|
@ -31,5 +38,5 @@ class NotificationService: UNNotificationServiceExtension {
|
|||
contentHandler(bestAttemptContent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
12
ntfyNSE/ntfyNSE.entitlements
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.heckel.ntfy</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||