diff --git a/FlurSchnaps.xcodeproj/project.pbxproj b/FlurSchnaps.xcodeproj/project.pbxproj index 444a188..326b0fd 100644 --- a/FlurSchnaps.xcodeproj/project.pbxproj +++ b/FlurSchnaps.xcodeproj/project.pbxproj @@ -7,18 +7,33 @@ objects = { /* Begin PBXBuildFile section */ + 881E0B26284B74E200435EC2 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 881E0B25284B74E200435EC2 /* Data+Extensions.swift */; }; + 88DBE72A284B989C00D1573B /* DeviceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DBE729284B989C00D1573B /* DeviceList.swift */; }; + E2349959284E0695002B55F8 /* PushAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E2349958284E0695002B55F8 /* PushAPI */; }; + E234995C284E1D02002B55F8 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E234995B284E1D02002B55F8 /* SFSafeSymbols */; }; + E234995F284E372B002B55F8 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E2349961284E372B002B55F8 /* Localizable.strings */; }; + E2349964284F3133002B55F8 /* TextEntryField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2349963284F3133002B55F8 /* TextEntryField.swift */; }; E29A7E47284B6143000B908A /* FlurSchnapsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29A7E46284B6143000B908A /* FlurSchnapsApp.swift */; }; E29A7E49284B6143000B908A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29A7E48284B6143000B908A /* ContentView.swift */; }; E29A7E4B284B6144000B908A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E29A7E4A284B6144000B908A /* Assets.xcassets */; }; E29A7E4E284B6144000B908A /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E29A7E4D284B6144000B908A /* Preview Assets.xcassets */; }; + E29A7E55284B619A000B908A /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29A7E54284B619A000B908A /* API.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 881E0B25284B74E200435EC2 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; + 88DBE727284B7EB200D1573B /* FlurSchnaps.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FlurSchnaps.entitlements; sourceTree = ""; }; + 88DBE728284B813500D1573B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 88DBE729284B989C00D1573B /* DeviceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceList.swift; sourceTree = ""; }; + E2349960284E372B002B55F8 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + E2349962284E3733002B55F8 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + E2349963284F3133002B55F8 /* TextEntryField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextEntryField.swift; sourceTree = ""; }; E29A7E43284B6143000B908A /* FlurSchnaps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FlurSchnaps.app; sourceTree = BUILT_PRODUCTS_DIR; }; E29A7E46284B6143000B908A /* FlurSchnapsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlurSchnapsApp.swift; sourceTree = ""; }; E29A7E48284B6143000B908A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; E29A7E4A284B6144000B908A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E29A7E4D284B6144000B908A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + E29A7E54284B619A000B908A /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +41,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E234995C284E1D02002B55F8 /* SFSafeSymbols in Frameworks */, + E2349959284E0695002B55F8 /* PushAPI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -51,8 +68,15 @@ E29A7E45284B6143000B908A /* FlurSchnaps */ = { isa = PBXGroup; children = ( + E2349961284E372B002B55F8 /* Localizable.strings */, + 88DBE728284B813500D1573B /* Info.plist */, + 88DBE727284B7EB200D1573B /* FlurSchnaps.entitlements */, E29A7E46284B6143000B908A /* FlurSchnapsApp.swift */, E29A7E48284B6143000B908A /* ContentView.swift */, + E2349963284F3133002B55F8 /* TextEntryField.swift */, + 88DBE729284B989C00D1573B /* DeviceList.swift */, + E29A7E54284B619A000B908A /* API.swift */, + 881E0B25284B74E200435EC2 /* Data+Extensions.swift */, E29A7E4A284B6144000B908A /* Assets.xcassets */, E29A7E4C284B6144000B908A /* Preview Content */, ); @@ -83,6 +107,10 @@ dependencies = ( ); name = FlurSchnaps; + packageProductDependencies = ( + E2349958284E0695002B55F8 /* PushAPI */, + E234995B284E1D02002B55F8 /* SFSafeSymbols */, + ); productName = FlurSchnaps; productReference = E29A7E43284B6143000B908A /* FlurSchnaps.app */; productType = "com.apple.product-type.application"; @@ -109,8 +137,13 @@ knownRegions = ( en, Base, + de, ); mainGroup = E29A7E3A284B6143000B908A; + packageReferences = ( + E2349957284E0695002B55F8 /* XCRemoteSwiftPackageReference "Push-API" */, + E234995A284E1D02002B55F8 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, + ); productRefGroup = E29A7E44284B6143000B908A /* Products */; projectDirPath = ""; projectRoot = ""; @@ -126,6 +159,7 @@ buildActionMask = 2147483647; files = ( E29A7E4E284B6144000B908A /* Preview Assets.xcassets in Resources */, + E234995F284E372B002B55F8 /* Localizable.strings in Resources */, E29A7E4B284B6144000B908A /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -137,13 +171,29 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E2349964284F3133002B55F8 /* TextEntryField.swift in Sources */, E29A7E49284B6143000B908A /* ContentView.swift in Sources */, + 881E0B26284B74E200435EC2 /* Data+Extensions.swift in Sources */, + E29A7E55284B619A000B908A /* API.swift in Sources */, + 88DBE72A284B989C00D1573B /* DeviceList.swift in Sources */, E29A7E47284B6143000B908A /* FlurSchnapsApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXVariantGroup section */ + E2349961284E372B002B55F8 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + E2349960284E372B002B55F8 /* en */, + E2349962284E3733002B55F8 /* de */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin XCBuildConfiguration section */ E29A7E4F284B6144000B908A /* Debug */ = { isa = XCBuildConfiguration; @@ -264,17 +314,21 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = FlurSchnaps/FlurSchnaps.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"FlurSchnaps/Preview Content\""; DEVELOPMENT_TEAM = H8WR4M6QQ4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FlurSchnaps/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -284,7 +338,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -293,17 +347,21 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = FlurSchnaps/FlurSchnaps.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"FlurSchnaps/Preview Content\""; DEVELOPMENT_TEAM = H8WR4M6QQ4; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FlurSchnaps/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -313,7 +371,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -339,6 +397,38 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + E2349957284E0695002B55F8 /* XCRemoteSwiftPackageReference "Push-API" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://christophhagen.de/git/ch/Push-API"; + requirement = { + branch = main; + kind = branch; + }; + }; + E234995A284E1D02002B55F8 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + E2349958284E0695002B55F8 /* PushAPI */ = { + isa = XCSwiftPackageProductDependency; + package = E2349957284E0695002B55F8 /* XCRemoteSwiftPackageReference "Push-API" */; + productName = PushAPI; + }; + E234995B284E1D02002B55F8 /* SFSafeSymbols */ = { + isa = XCSwiftPackageProductDependency; + package = E234995A284E1D02002B55F8 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; + productName = SFSafeSymbols; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = E29A7E3B284B6143000B908A /* Project object */; } diff --git a/FlurSchnaps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/FlurSchnaps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..a3e664c --- /dev/null +++ b/FlurSchnaps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,86 @@ +{ + "pins" : [ + { + "identity" : "apnswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server-community/APNSwift.git", + "state" : { + "revision" : "99a3c7bb5fd211009438fb386d18c94bb2f63b17", + "version" : "4.0.0" + } + }, + { + "identity" : "jwt-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/jwt-kit.git", + "state" : { + "revision" : "3537dd319dfbcc403a5165d8c19c4834e8e64730", + "version" : "4.5.0" + } + }, + { + "identity" : "push-api", + "kind" : "remoteSourceControl", + "location" : "https://christophhagen.de/git/ch/Push-API", + "state" : { + "branch" : "main", + "revision" : "d5fb765ac998e2f731ec6fe8bf473a396af9b61c" + } + }, + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", + "state" : { + "revision" : "c8c33d947d8a1c883aa19fd24e14fd738b06e369", + "version" : "3.3.2" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "d9825fa541df64b1a7b182178d61b9a82730d01f", + "version" : "2.1.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "5d66f7ba25daf4f94100e7022febf3c75e37a6c7", + "version" : "1.4.2" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "124119f0bb12384cef35aa041d7c3a686108722d", + "version" : "2.40.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "72bcaf607b40d7c51044f65b0f5ed8581a911832", + "version" : "1.21.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "1750873bce84b4129b5303655cce2c3d35b9ed3a", + "version" : "2.19.0" + } + } + ], + "version" : 2 +} diff --git a/FlurSchnaps.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist b/FlurSchnaps.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..981ac7a --- /dev/null +++ b/FlurSchnaps.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + FlurSchnaps.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/FlurSchnaps/API.swift b/FlurSchnaps/API.swift new file mode 100644 index 0000000..824101c --- /dev/null +++ b/FlurSchnaps/API.swift @@ -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(_ 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 + } + } + + +} diff --git a/FlurSchnaps/ContentView.swift b/FlurSchnaps/ContentView.swift index 48ed390..42adf43 100644 --- a/FlurSchnaps/ContentView.swift +++ b/FlurSchnaps/ContentView.swift @@ -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") } } diff --git a/FlurSchnaps/Data+Extensions.swift b/FlurSchnaps/Data+Extensions.swift new file mode 100644 index 0000000..f36b39f --- /dev/null +++ b/FlurSchnaps/Data+Extensions.swift @@ -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)! + } + } +} diff --git a/FlurSchnaps/DeviceList.swift b/FlurSchnaps/DeviceList.swift new file mode 100644 index 0000000..f492b02 --- /dev/null +++ b/FlurSchnaps/DeviceList.swift @@ -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 + } +} diff --git a/FlurSchnaps/FlurSchnaps.entitlements b/FlurSchnaps/FlurSchnaps.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/FlurSchnaps/FlurSchnaps.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/FlurSchnaps/FlurSchnapsApp.swift b/FlurSchnaps/FlurSchnapsApp.swift index 34df1f0..63369b8 100644 --- a/FlurSchnaps/FlurSchnapsApp.swift +++ b/FlurSchnaps/FlurSchnapsApp.swift @@ -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() + } +} diff --git a/FlurSchnaps/Info.plist b/FlurSchnaps/Info.plist new file mode 100644 index 0000000..ca9a074 --- /dev/null +++ b/FlurSchnaps/Info.plist @@ -0,0 +1,10 @@ + + + + + UIBackgroundModes + + remote-notification + + + diff --git a/FlurSchnaps/TextEntryField.swift b/FlurSchnaps/TextEntryField.swift new file mode 100644 index 0000000..386dfd0 --- /dev/null +++ b/FlurSchnaps/TextEntryField.swift @@ -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) { + 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)) + } +} diff --git a/FlurSchnaps/de.lproj/Localizable.strings b/FlurSchnaps/de.lproj/Localizable.strings new file mode 100644 index 0000000..3a2a549 --- /dev/null +++ b/FlurSchnaps/de.lproj/Localizable.strings @@ -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"; diff --git a/FlurSchnaps/en.lproj/Localizable.strings b/FlurSchnaps/en.lproj/Localizable.strings new file mode 100644 index 0000000..82b328b --- /dev/null +++ b/FlurSchnaps/en.lproj/Localizable.strings @@ -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";