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

View File

@ -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 = "<group>"; };
88DBE727284B7EB200D1573B /* FlurSchnaps.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FlurSchnaps.entitlements; sourceTree = "<group>"; };
88DBE728284B813500D1573B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
88DBE729284B989C00D1573B /* DeviceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceList.swift; sourceTree = "<group>"; };
E2349960284E372B002B55F8 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
E2349962284E3733002B55F8 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
E2349963284F3133002B55F8 /* TextEntryField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextEntryField.swift; sourceTree = "<group>"; };
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 = "<group>"; };
E29A7E48284B6143000B908A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
E29A7E4A284B6144000B908A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
E29A7E4D284B6144000B908A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
E29A7E54284B619A000B908A /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
};
/* 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 */;
}

View File

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

View File

@ -0,0 +1,14 @@
<?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>SchemeUserState</key>
<dict>
<key>FlurSchnaps.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

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!")
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";