First working version

This commit is contained in:
Christoph Hagen
2022-06-07 11:26:32 +02:00
parent 5ffea2c2c1
commit 25fbaef134
13 changed files with 992 additions and 20 deletions

131
FlurSchnaps/API.swift Normal file
View File

@ -0,0 +1,131 @@
import Foundation
import CryptoKit
import PushAPI
import SwiftUI
final class API {
@AppStorage("server")
var server: String = ""
var url: URL? {
URL(string: server)
}
init() {
}
init(server: URL) {
self.server = server.path
}
init(server: URL, application: ApplicationId) {
self.server = server.path
self.application = application
}
@AppStorage("application")
var application: ApplicationId = ""
private static let encoder = JSONEncoder()
private static let decoder = JSONDecoder()
func register(token: PushToken, name: String) async -> AuthenticationToken? {
let device = DeviceRegistration(
pushToken: token,
application: application,
name: name)
guard let token = await post(.registerNewDevice, body: device) else {
print("Failed to register")
return nil
}
guard token.count == 16 else {
print("Failed to register: Unexpected token length: \(token.count)")
return nil
}
return token
}
func getDeviceList(pushToken: PushToken, authToken: AuthenticationToken) async -> [DeviceRegistration] {
let device = DeviceAuthentication(pushToken: pushToken, authentication: authToken)
guard let data = await post(.listDevicesInApplication, body: device) else {
print("Devices: Failed")
return []
}
do {
return try API.decoder.decode([DeviceRegistration].self, from: data)
} catch {
print("Devices: Failed to decode response")
return []
}
}
func getUnconfirmedDevices(masterKey: String) async -> [DeviceRegistration] {
let hash = hash(masterKey)
guard let data = await post(.listUnapprovedDevices, bodyData: hash) else {
print("Devices: Failed")
return []
}
do {
return try API.decoder.decode([DeviceRegistration].self, from: data)
} catch {
print("Devices: Failed to decode response")
return []
}
}
private func hash(_ masterKey: String) -> Data {
Data(SHA256.hash(data: masterKey.data(using: .utf8)!))
}
func confirm(pushToken: PushToken, with masterKey: String) async -> Bool {
let hash = hash(masterKey)
let device = DeviceDecision(pushToken: pushToken, masterKeyHash: hash)
return await post(.approveDevice, body: device) != nil
}
func reject(pushToken: PushToken, with masterKey: String) async -> Bool {
let hash = hash(masterKey)
let device = DeviceDecision(pushToken: pushToken, masterKeyHash: hash)
return await post(.rejectDevice, body: device) != nil
}
func isConfirmed(token: PushToken, authentication: AuthenticationToken) async -> Bool {
let device = DeviceAuthentication(pushToken: token, authentication: authentication)
return await post(.isDeviceApproved, body: device) != nil
}
func send(push: AuthenticatedPushMessage) async -> Bool {
await post(.sendPushNotification, body: push) != nil
}
private func post<T>(_ route: Route, body: T) async -> Data? where T: Encodable {
let bodyData = try! API.encoder.encode(body)
return await post(route, bodyData: bodyData)
}
private func post(_ route: Route, bodyData: Data) async -> Data? {
guard let url = url else {
return nil
}
var request = URLRequest(url: url.appendingPathComponent(route.rawValue))
request.httpBody = bodyData
request.httpMethod = "POST"
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
return nil
}
guard httpResponse.statusCode == 200 else {
print("Failed with code: \(httpResponse.statusCode)")
return nil
}
return data
} catch {
print("Failed with error: \(error)")
return nil
}
}
}

View File

@ -1,21 +1,326 @@
//
// ContentView.swift
// FlurSchnaps
//
// Created by CH on 04.06.22.
//
import SwiftUI
import APNSwift
import PushAPI
import SFSafeSymbols
struct ContentView: View {
@AppStorage("pushToken")
var pushToken: PushToken?
var hasPushToken: Bool {
pushToken != nil
}
@AppStorage("authToken")
var authToken: AuthenticationToken?
@State
var isConfirmed = false
@State
var hasNotificationPermissions: Bool? = nil
@State
var showDeviceList = false
@State
var api = API()
@AppStorage("deviceName")
var deviceName: String = ""
@State
var deviceList: [DeviceRegistration] = []
var couldBeRegistered: Bool {
pushToken != nil && authToken != nil
}
@AppStorage("pushTitle")
var pushMessageTitle: String = ""
@AppStorage("pushBody")
var pushMessageText: String = ""
@State
var includeOwnDeviceInPush = false
var canSendNotification: Bool {
isConfirmed && (includeOwnDeviceInPush || deviceList.count > 1)
}
func statusView(_ state: Bool?) -> some View {
let symbol: SFSymbol
let color: Color
if let state = state {
symbol = state ? .checkmarkCircle : .xmarkCircle
color = state ? .green : .red
} else {
symbol = .questionmarkCircle
color = .gray
}
return Image(systemSymbol: symbol)
.renderingMode(.template)
.foregroundColor(color)
}
func updateNotificationPermissionState() {
Task {
let state = await getPushPermissionState()
DispatchQueue.main.async {
hasNotificationPermissions = state
}
}
}
private func getPushPermissionState() async -> Bool? {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
return true
case .denied:
return false
case .notDetermined:
return nil
@unknown default:
return nil
}
}
var body: some View {
Text("Hello, world!")
.padding()
NavigationView {
VStack(spacing: 8) {
HStack {
statusView(hasPushToken)
Text("remote-notifications-title")
}
HStack {
statusView(hasNotificationPermissions)
Text("notification-permissions-title")
}
HStack {
statusView(authToken != nil)
Text("push-server-registration-title")
}
HStack {
statusView(deviceList.count > 1)
Text("other-devices-title")
}
if pushToken == nil {
Text("register-for-remote-notifications-text")
.padding()
.multilineTextAlignment(.center)
Button("register-for-remote-notifications-button", action: registerForRemoteNotifications)
.padding()
} else if hasNotificationPermissions == nil {
Button("request-notification-permission-button", action: requestNotificationPermission)
.padding()
} else if hasNotificationPermissions == false {
Text("no-notification-permissions-text")
.padding()
.multilineTextAlignment(.center)
Button("no-notification-permissions-button", action: openNotificationSettings)
.padding()
} else if authToken == nil {
Text("register-device-text")
.padding()
TextEntryField("Server url", placeholder: "register-device-server-placeholder", symbol: .network, showClearButton: true, text: $api.server)
.padding(.horizontal, 50)
.padding(.top)
TextEntryField("Application", placeholder: "register-device-application-placeholder", symbol: .questionmarkApp, showClearButton: true, text: $api.application)
.padding(.horizontal, 50)
.padding(.top)
TextEntryField("Device name", placeholder: "register-device-name-placeholder", symbol: .iphone, text: $deviceName)
.padding(.horizontal, 50)
.padding(.top)
Button("register-device-button", action: register)
.disabled(pushToken == nil || authToken != nil || deviceName.isEmpty)
.padding()
} else {
Text("push-message-description")
.padding()
TextEntryField("Push title", placeholder: "push-message-title-placeholder", symbol: .bubbleLeft, showClearButton: true, text: $pushMessageTitle)
.padding(.horizontal, 50)
.disabled(!isConfirmed)
TextEntryField("Push text", placeholder: "push-message-placeholder", symbol: .textformat, showClearButton: true, text: $pushMessageText)
.padding(.horizontal, 50)
.disabled(!isConfirmed)
Toggle("toggle-include-own-device-text", isOn: $includeOwnDeviceInPush)
.padding(.horizontal, 50)
.padding(.top)
Button("send-notification-button", action: sendPush)
.disabled(!canSendNotification)
.padding()
Button("show-device-list-button", action: showDevices)
.disabled(!isConfirmed)
.padding()
}
Spacer()
}
.navigationTitle("FlurSchnaps")
}
.sheet(isPresented: $showDeviceList) {
if let push = pushToken, let auth = authToken {
DeviceList(pushToken: push,
authToken: auth,
api: api,
isPresented: $showDeviceList,
devices: deviceList)
}
}.onAppear {
startPeriodicUpdates()
}.onDisappear {
stopPeriodicUpdates()
}
}
@State
private var timer: Timer?
private func startPeriodicUpdates() {
guard timer == nil else {
return
}
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
updateState()
}
updateState()
}
private func stopPeriodicUpdates() {
timer?.invalidate()
timer = nil
}
private func updateState() {
updateNotificationPermissionState()
if isConfirmed {
updateDeviceList()
} else if couldBeRegistered {
checkPushRegistrationStatus()
}
}
func registerForRemoteNotifications() {
UIApplication.shared.registerForRemoteNotifications()
}
func requestNotificationPermission() {
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
UNUserNotificationCenter.current().requestAuthorization(
options: authOptions,
completionHandler: {_, _ in
updateNotificationPermissionState()
})
}
func openNotificationSettings() {
if let appSettings = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(appSettings) {
UIApplication.shared.open(appSettings)
}
}
func register() {
guard let token = pushToken else {
print("No token to register")
return
}
let name = deviceName
Task {
print("Registering...")
guard let auth = await api.register(token: token, name: name) else {
DispatchQueue.main.async {
authToken = nil
isConfirmed = false
}
return
}
print("Registered")
DispatchQueue.main.async {
authToken = auth
isConfirmed = false
updateDeviceList()
}
}
}
func checkPushRegistrationStatus() {
guard let token = pushToken, let authToken = authToken else {
return
}
Task {
let confirmed = await api.isConfirmed(token: token, authentication: authToken)
if !confirmed {
print("Not confirmed by server: \(api.url?.path ?? "No server") (\(api.server))")
print(token.base64EncodedString())
print(authToken.base64EncodedString())
}
DispatchQueue.main.async {
isConfirmed = confirmed
}
}
}
func updateDeviceList() {
guard let authToken = authToken,
let pushToken = pushToken else {
return
}
Task {
let devices = await api.getDeviceList(pushToken: pushToken, authToken: authToken)
DispatchQueue.main.async {
self.deviceList = devices
}
}
}
func showDevices() {
showDeviceList = true
}
func sendPush() {
guard let authToken = authToken else {
return
}
guard let pushToken = pushToken else {
return
}
var recipients = deviceList.map { $0.pushToken }
guard recipients.count > 0 else {
return
}
if !includeOwnDeviceInPush {
recipients = recipients.filter { $0 != pushToken }
}
let body = pushMessageText
let alert = APNSwiftAlert(
title: pushMessageTitle,
body: body)
let payload = APNSwiftPayload(
alert: alert,
sound: .normal("default"))
let content = PushMessage(
recipients: recipients,
payload: payload,
pushType: .alert)
let sender = DeviceAuthentication(
pushToken: pushToken,
authentication: authToken)
let message = AuthenticatedPushMessage(
sender: sender,
message: content)
Task {
let sent = await api.send(push: message)
print("Sent push message: \(sent)")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.previewDevice("iPhone 8")
}
}

View File

@ -0,0 +1,42 @@
import Foundation
extension Data {
public var hexEncoded: String {
return map { String(format: "%02hhx", $0) }.joined()
}
// Convert 0 ... 9, a ... f, A ...F to their decimal value,
// return nil for all other input characters
private func decodeNibble(_ u: UInt16) -> UInt8? {
switch(u) {
case 0x30 ... 0x39:
return UInt8(u - 0x30)
case 0x41 ... 0x46:
return UInt8(u - 0x41 + 10)
case 0x61 ... 0x66:
return UInt8(u - 0x61 + 10)
default:
return nil
}
}
public init?(fromHexEncodedString string: String) {
let utf16 = string.utf16
self.init(capacity: utf16.count/2)
var i = utf16.startIndex
guard utf16.count % 2 == 0 else {
return nil
}
while i != utf16.endIndex {
guard let hi = decodeNibble(utf16[i]),
let lo = decodeNibble(utf16[utf16.index(i, offsetBy: 1, limitedBy: utf16.endIndex)!]) else {
return nil
}
var value = hi << 4 + lo
self.append(&value, count: 1)
i = utf16.index(i, offsetBy: 2, limitedBy: utf16.endIndex)!
}
}
}

View File

@ -0,0 +1,90 @@
import SwiftUI
import PushAPI
extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}
struct DeviceList: View {
let pushToken: PushToken
let authToken: AuthenticationToken
let api: API
@Binding
var isPresented: Bool
@State
var devices: [DeviceRegistration]
var body: some View {
NavigationView {
VStack(spacing: 0) {
List(devices) { device in
HStack {
Text(device.name.nonEmpty ?? "Device")
.font(.headline)
Spacer()
VStack(alignment: .leading) {
Text(device.application)
.font(.footnote)
.fontWeight(.bold)
.padding(.bottom, 2)
Text(device.pushToken.prefix(5).hexEncoded + "...")
.font(.caption)
}
}
}.refreshable {
await updateList()
}
}.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: dismiss) {
Text("Cancel")
}
}
}.navigationBarTitle("device-list-title")
}.onAppear() {
Task {
await updateList()
}
}
}
private func updateList() async {
let devices = await api.getDeviceList(pushToken: pushToken, authToken: authToken)
print("Updated device list: \(devices.count)")
DispatchQueue.main.async {
self.devices = devices
}
}
private func dismiss() {
isPresented = false
}
}
struct DeviceList_Previews: PreviewProvider {
static var previews: some View {
DeviceList(pushToken: Data(repeating: 42, count: 32),
authToken: Data(repeating: 42, count: 16),
api: .init(server: URL(string: "https://christophhagen.de/push")!),
isPresented: .constant(true),
devices: [DeviceRegistration(
pushToken: Data([1,2,3,4,5]),
application: "CC Messenger",
name: "Some")])
}
}
extension DeviceRegistration: Identifiable {
public var id: String {
pushToken.prefix(5).hexEncoded
}
}

View File

@ -0,0 +1,8 @@
<?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>
</dict>
</plist>

View File

@ -1,17 +1,72 @@
//
// FlurSchnapsApp.swift
// FlurSchnaps
//
// Created by CH on 04.06.22.
//
import SwiftUI
@main
struct FlurSchnapsApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self)
var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
@AppStorage("pushToken")
var pushToken: Data?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
// For iOS 10 display notification (sent via APNS)
UNUserNotificationCenter.current().delegate = self
UIApplication.shared.registerForRemoteNotifications()
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
print("Registered with token: \(deviceToken)")
self.pushToken = deviceToken
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
print(userInfo)
completionHandler(UIBackgroundFetchResult.newData)
}
}
extension AppDelegate: UNUserNotificationCenterDelegate {
// Receive displayed notifications for iOS 10 devices.
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
let userInfo = notification.request.content.userInfo
print(userInfo)
// Change this to your preferred presentation option
completionHandler([[.banner, .badge, .sound]])
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
}
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
print(userInfo)
completionHandler()
}
}

10
FlurSchnaps/Info.plist Normal file
View File

@ -0,0 +1,10 @@
<?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>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,59 @@
import SwiftUI
import SFSafeSymbols
struct TextEntryField: View {
let name: String
let placeholder: LocalizedStringKey
let symbol: SFSymbol
let showClearButton: Bool
@Binding
var text: String
init(_ name: String, placeholder: LocalizedStringKey, symbol: SFSymbol, showClearButton: Bool = false, text: Binding<String>) {
self.name = name
self.placeholder = placeholder
self.symbol = symbol
self.showClearButton = showClearButton
self._text = text
}
var body: some View {
TextField(name, text: $text, prompt: Text(placeholder))
.padding(7)
.padding(.horizontal, 25)
.background(Color(.systemGray5))
.cornerRadius(8)
.overlay(
HStack {
Image(systemSymbol: symbol)
.foregroundColor(.gray)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(.leading, 8)
if showClearButton && text != "" {
Button(action: {
self.text = ""
}) {
Image(systemSymbol: .multiplyCircleFill)
.foregroundColor(.gray)
.padding(.trailing, 8)
}
}
}
)
}
}
struct TextEntryField_Previews: PreviewProvider {
static var previews: some View {
TextEntryField("Test",
placeholder: "Enter text...",
symbol: .textformat,
text: .constant(""))
.previewLayout(.fixed(width: 375, height: 50))
}
}

View File

@ -0,0 +1,41 @@
"remote-notifications-title" = "Remote notifications";
"notification-permissions-title" = "Benachrichtigungen";
"request-notification-permission-button" = "Erlaubnis erteilen";
"push-server-registration-title" = "Geräte-Registrierung";
"other-devices-title" = "Verfügbare Geräte";
"register-for-remote-notifications-text" = "Das Gerät konnte sich nicht für Benachrichtigungen anmelden. Bitte überprüfe deine Internetverbindung.";
"register-for-remote-notifications-button" = "Erneut versuchen";
"no-notification-permissions-text" = "Die App hat keine Berechtigung zum Anzeigen von Benachrichtigungen. Bitte ändere die Einstellungen.";
"no-notification-permissions-button" = "Einstellungen";
"register-device-text" = "Registriere das Gerät mit einem Push Server, um Benachrichtigungen zu erhalten.";
"register-device-server-placeholder" = "Server";
"register-device-application-placeholder" = "Anwendung";
"register-device-name-placeholder" = "Name des Geräts";
"register-device-button" = "Gerät registrieren";
"push-message-description" = "Sende eine Benachrichtigung an alle Geräte, mit einem optionalen Text.";
"push-message-title-placeholder" = "Titel";
"push-message-placeholder" = "Nachricht";
"toggle-include-own-device-text" = "Eigenes Gerät benachrichtigen";
"send-notification-button" = "Nachricht senden";
"device-list-title" = "Geräte";
"show-device-list-button" = "Liste einzeigen";

View File

@ -0,0 +1,41 @@
"remote-notifications-title" = "Remote notifications";
"notification-permissions-title" = "Notification permissions";
"request-notification-permission-button" = "Enable notifications";
"push-server-registration-title" = "Push server registration";
"other-devices-title" = "Other devices available";
"register-for-remote-notifications-text" = "The device could not register for remote notifications. Check your internet connection.";
"register-for-remote-notifications-button" = "Retry";
"no-notification-permissions-text" = "The app doesn't have permission to display notifications. Please change the permissions for the app to work.";
"no-notification-permissions-button" = "Device settings";
"register-device-text" = "Register the device with the push server to receive notifications.";
"register-device-server-placeholder" = "Server url";
"register-device-application-placeholder" = "Application ID";
"register-device-name-placeholder" = "Device name";
"register-device-button" = "Register device";
"push-message-description" = "Send a push notification to all other devices, with an optional custom text";
"push-message-title-placeholder" = "Message title";
"push-message-placeholder" = "Message text";
"toggle-include-own-device-text" = "Include own device";
"send-notification-button" = "Send notification";
"device-list-title" = "Devices";
"show-device-list-button" = "Show device list";