Skip to content

Commit df2e4ad

Browse files
committed
Chapter 16 sources
1 parent 50670b0 commit df2e4ad

22 files changed

+320
-23
lines changed

Chapter 16/MyProjectClient/iOS/Assets/MyProject.entitlements

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5+
<key>aps-environment</key>
6+
<string>development</string>
57
<key>com.apple.developer.applesignin</key>
68
<array>
79
<string>Default</string>

Chapter 16/MyProjectClient/iOS/Sources/AppDelegate.swift

+19-1
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ import UIKit
1010

1111
@UIApplicationMain
1212
class AppDelegate: UIResponder {
13-
13+
var accountView: AccountView?
1414
}
1515

1616
extension AppDelegate: UIApplicationDelegate {
1717

1818
func application(_ application: UIApplication,
1919
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
20+
21+
application.registerForRemoteNotifications()
22+
2023
return true
2124
}
2225

@@ -26,5 +29,20 @@ extension AppDelegate: UIApplicationDelegate {
2629
return .init(name: "Default Configuration",
2730
sessionRole: connectingSceneSession.role)
2831
}
32+
33+
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
34+
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
35+
print(token)
36+
UserDefaults.standard.set(token, forKey: "device-token")
37+
UserDefaults.standard.synchronize()
38+
self.accountView = App.shared.modules.account() as? AccountView
39+
self.accountView?.presenter?.registerUserDevice() { [unowned self] in
40+
self.accountView = nil
41+
}
42+
}
43+
44+
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
45+
print(error)
46+
}
2947
}
3048

Chapter 16/MyProjectClient/iOS/Sources/Modules/Account/AccountInteractor.swift

+6
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,10 @@ extension AccountInteractor: AccountInteractorPresenterInterface {
2121
.mapError { $0 as Error }
2222
.eraseToAnyPublisher()
2323
}
24+
25+
func register(deviceToken: String, bearerToken: String) -> AnyPublisher<Void, Error> {
26+
self.services.api.register(deviceToken: deviceToken, bearerToken: bearerToken)
27+
.mapError { $0 as Error }
28+
.eraseToAnyPublisher()
29+
}
2430
}

Chapter 16/MyProjectClient/iOS/Sources/Modules/Account/AccountModule.swift

+2
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@ protocol AccountPresenterViewInterface: PresenterViewInterface {
2929
func close()
3030
func signIn(token: String)
3131
func logout()
32+
func registerUserDevice(_ block: @escaping (() -> Void))
3233
}
3334

3435
// MARK: - interactor
3536

3637
protocol AccountInteractorPresenterInterface: InteractorPresenterInterface {
3738
func signIn(token: String) -> AnyPublisher<String, Error>
39+
func register(deviceToken: String, bearerToken: String) -> AnyPublisher<Void, Error>
3840
}
3941

4042
// MARK: - view

Chapter 16/MyProjectClient/iOS/Sources/Modules/Account/AccountPresenter.swift

+20
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,24 @@ extension AccountPresenter: AccountPresenterViewInterface {
6464
UserDefaults.standard.synchronize()
6565
self.view.displayLogin()
6666
}
67+
68+
func registerUserDevice(_ block: @escaping (() -> Void)) {
69+
guard
70+
let bearerToken = UserDefaults.standard.string(forKey: "user-token"),
71+
let deviceToken = UserDefaults.standard.string(forKey: "device-token")
72+
else {
73+
return
74+
}
75+
self.operations["device"] = self.interactor.register(deviceToken: deviceToken, bearerToken: bearerToken)
76+
.sink(receiveCompletion: { [weak self] completion in
77+
switch completion {
78+
case .finished:
79+
print("Device registered.")
80+
case .failure(let error):
81+
print(error)
82+
}
83+
self?.operations.removeValue(forKey: "device")
84+
block()
85+
}) { _ in }
86+
}
6787
}

Chapter 16/MyProjectClient/iOS/Sources/Modules/Account/AccountView.swift

+9
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import Foundation
99
import UIKit
1010
import AuthenticationServices
11+
import UserNotifications
1112

1213
final class AccountView: UIViewController, ViewInterface {
1314

@@ -50,6 +51,7 @@ final class AccountView: UIViewController, ViewInterface {
5051
self.view.backgroundColor = .systemBackground
5152

5253
self.navigationItem.rightBarButtonItem = .init(barButtonSystemItem: .close, target: self, action: #selector(self.close))
54+
self.navigationItem.leftBarButtonItem = .init(barButtonSystemItem: .fastForward, target: self, action: #selector(self.notif))
5355

5456
self.logoutButton.setTitle("Logout", for: .normal)
5557
self.logoutButton.addTarget(self, action: #selector(self.logout), for: .touchUpInside)
@@ -65,6 +67,13 @@ final class AccountView: UIViewController, ViewInterface {
6567
@objc func logout() {
6668
self.presenter.logout()
6769
}
70+
71+
@objc func notif() {
72+
let notif = UNUserNotificationCenter.current()
73+
notif.requestAuthorization(options: [.badge, .sound, .alert]) { granted, error in
74+
// check if granted or error happened
75+
}
76+
}
6877

6978
@objc func siwa() {
7079
let provider = ASAuthorizationAppleIDProvider()

Chapter 16/MyProjectClient/iOS/Sources/Modules/Root/RootView.swift

+6-6
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,12 @@ extension RootView: UITableViewDataSource {
133133
cell.coverView.image = defaultImage
134134

135135
self.operations["cell-\(indexPath.row)"]?.cancel()
136-
self.operations["cell-\(indexPath.row)"] = URLSession.shared
137-
.downloadTaskPublisher(for: item.imageUrl)
138-
.map { UIImage(contentsOfFile: $0.url.path) ?? defaultImage }
139-
.replaceError(with: defaultImage)
140-
.receive(on: DispatchQueue.main)
141-
.assign(to: \.image, on: cell.coverView)
136+
// self.operations["cell-\(indexPath.row)"] = URLSession.shared
137+
// .downloadTaskPublisher(for: item.imageUrl)
138+
// .map { UIImage(contentsOfFile: $0.url.path) ?? defaultImage }
139+
// .replaceError(with: defaultImage)
140+
// .receive(on: DispatchQueue.main)
141+
// .assign(to: \.image, on: cell.coverView)
142142

143143
return cell
144144
}

Chapter 16/MyProjectClient/iOS/Sources/Services/Api/ApiServiceInterface.swift

+1
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ protocol ApiServiceInterface: ServiceInterface {
2828

2929
func getBlogPosts() -> AnyPublisher<Page<BlogPostListObject>, HTTP.Error>
3030
func siwa(token: String) -> AnyPublisher<UserToken, HTTP.Error>
31+
func register(deviceToken: String, bearerToken: String) -> AnyPublisher<Void, HTTP.Error>
3132
}

Chapter 16/MyProjectClient/iOS/Sources/Services/Api/MyProject/MyProjectApiService.swift

+34-1
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ final class MyProjectApiService: ApiServiceInterface {
5858
let url = URL(string: self.baseUrl + "/user/sign-in-with-apple")!
5959
var request = URLRequest(url: url)
6060
request.httpMethod = HTTP.Method.post.rawValue.uppercased()
61+
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
6162
request.httpBody = try! JSONEncoder().encode(Body(idToken: token))
62-
63+
6364
return URLSession.shared.dataTaskPublisher(for: request)
6465
.tryMap { data, response in
6566
guard let httpResponse = response as? HTTPURLResponse else {
@@ -79,4 +80,36 @@ final class MyProjectApiService: ApiServiceInterface {
7980
}
8081
.eraseToAnyPublisher()
8182
}
83+
84+
func register(deviceToken: String, bearerToken: String) -> AnyPublisher<Void, HTTP.Error> {
85+
struct Body: Codable {
86+
let token: String
87+
}
88+
89+
let url = URL(string: self.baseUrl + "/user/devices")!
90+
var request = URLRequest(url: url)
91+
request.httpMethod = HTTP.Method.post.rawValue.uppercased()
92+
request.addValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
93+
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
94+
request.httpBody = try! JSONEncoder().encode(Body(token: deviceToken))
95+
96+
return URLSession.shared.dataTaskPublisher(for: request)
97+
.tryMap { data, response in
98+
guard let httpResponse = response as? HTTPURLResponse else {
99+
throw HTTP.Error.invalidResponse
100+
}
101+
guard httpResponse.statusCode == 200 else {
102+
throw HTTP.Error.statusCode(httpResponse.statusCode)
103+
}
104+
return ()
105+
}
106+
.mapError { error -> HTTP.Error in
107+
if let httpError = error as? HTTP.Error {
108+
return httpError
109+
}
110+
return HTTP.Error.unknown(error)
111+
}
112+
.eraseToAnyPublisher()
113+
}
114+
82115
}

Chapter 16/MyProjectClient/iOS/Sources/Services/ServiceBuilder.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ import Foundation
1111
final class ServiceBuilder: ServiceBuilderInterface {
1212

1313
lazy var api: ApiServiceInterface = {
14-
MyProjectApiService(baseUrl: "http://localhost:8080/api")
14+
MyProjectApiService(baseUrl: "http://192.168.0.129:3000/api")
1515
}()
1616
}

Chapter 16/myProject/.env.development

+6-2
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ FS_NAME=vaportestbucket
66
FS_REGION=us-west-1
77
AWS_KEY=my-key
88
AWS_SECRET=my-secret
9-
SIWA_ID=com.example.myproject.service
10-
SIWA_APP_ID=com.example.myproject.ios
9+
SIWA_ID=com.example.service
10+
SIWA_APP_ID=com.example.ios.app
1111
SIWA_REDIRECT_URL=https://localhost/redirect
1212
SIWA_TEAM_ID=team-id
1313
SIWA_JWK_ID=jwk-id
1414
SIWA_KEY=base-64-encoded-key
15+
APNS_KEY_ID=key-id
16+
APNS_TEAM_ID=team-id
17+
APNS_TOPIC=ios-bundle-identifier
18+
APNS_KEY=base-64-encoded-key

Chapter 16/myProject/Sources/App/Extensions/Environment+App.swift

+6-2
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,16 @@ extension Environment {
1111

1212
static let awsKey = Self.get("AWS_KEY")!
1313
static let awsSecret = Self.get("AWS_SECRET")!
14-
14+
1515
static let siwaId = Self.get("SIWA_ID")!
16-
//...
1716
static let siwaAppId = Self.get("SIWA_APP_ID")!
1817
static let siwaRedirectUrl = Self.get("SIWA_REDIRECT_URL")!
1918
static let siwaTeamId = Self.get("SIWA_TEAM_ID")!
2019
static let siwaJWKId = Self.get("SIWA_JWK_ID")!
2120
static let siwaKey = Self.get("SIWA_KEY")!.base64Decoded()!
21+
//...
22+
static let apnsKeyId = Self.get("APNS_KEY_ID")!
23+
static let apnsTeamId = Self.get("APNS_TEAM_ID")!
24+
static let apnsTopic = Self.get("APNS_TOPIC")!
25+
static let apnsKey = Self.get("APNS_KEY")!.base64Decoded()!
2226
}

Chapter 16/myProject/Sources/App/Modules/Admin/Views/Home.leaf

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
<section class="wrapper">
77
<p>#(message)</p>
88
<a href="/admin/blog/posts">Blog posts</a> &middot;
9-
<a href="/admin/blog/categories">Blog categories</a>
9+
<a href="/admin/blog/categories">Blog categories</a> &middot;
10+
<a href="/admin/user/push">Push notification</a>
1011
</section>
1112
#endexport
1213
#endextend
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import Vapor
2+
import Fluent
3+
import ViewKit
4+
5+
struct UserAdminController {
6+
7+
func renderPushView(_ req: Request,
8+
title: String? = nil,
9+
message: String? = nil,
10+
userId: String? = nil,
11+
status: String? = nil) -> EventLoopFuture<View> {
12+
struct Context: Encodable {
13+
let title: String?
14+
var message: String?
15+
var userId: String?
16+
var status: String?
17+
var users: [FormFieldOption]
18+
}
19+
20+
return UserModel.query(on: req.db).all()
21+
.mapEach { FormFieldOption(key: $0.id!.uuidString, label: $0.email) }
22+
.flatMap {
23+
let context = Context(title: title,
24+
message: message,
25+
userId: userId,
26+
status: status,
27+
users: $0)
28+
return req.view.render("User/Frontend/Push", context)
29+
}
30+
}
31+
32+
func pushView(req: Request) throws -> EventLoopFuture<View> {
33+
self.renderPushView(req)
34+
}
35+
36+
func push(req: Request) throws -> EventLoopFuture<View> {
37+
struct Input: Decodable {
38+
let title: String
39+
let message: String
40+
let userId: String
41+
}
42+
let input = try req.content.decode(Input.self)
43+
guard !input.title.isEmpty || !input.message.isEmpty else {
44+
return self.renderPushView(req, status: "Missing required field!")
45+
}
46+
guard let userId = UUID(uuidString: input.userId) else {
47+
throw Abort(.badRequest)
48+
}
49+
return UserModel
50+
.query(on: req.db)
51+
.filter(\.$id == userId)
52+
.with(\.$devices)
53+
.first()
54+
.unwrap(or: Abort(.notFound))
55+
.flatMap { user -> EventLoopFuture<Void> in
56+
let futures: [EventLoopFuture<Void>] = user.devices.map {
57+
return req.apns.send(.init(title: input.title, subtitle: input.message), to: $0.token)
58+
}
59+
return EventLoopFuture.andAllComplete(futures, on: req.eventLoop)
60+
}
61+
.flatMap {
62+
self.renderPushView(req, status: "Notification sent!")
63+
}
64+
}
65+
}

Chapter 16/myProject/Sources/App/Modules/User/Controllers/UserApiController.swift

+13
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,17 @@ struct UserApiController {
2424
UserTokenModel.create(on: req.db, for: user.id!).map { $0.getContent }
2525
}
2626
}
27+
28+
func registerDevice(req: Request) throws -> EventLoopFuture<HTTPStatus> {
29+
guard let user = req.auth.get(UserModel.self) else {
30+
throw Abort(.unauthorized)
31+
}
32+
struct Input: Decodable {
33+
let token: String
34+
}
35+
let input = try req.content.decode(Input.self)
36+
return UserDeviceModel(token: input.token, userId: user.id!)
37+
.create(on: req.db)
38+
.transform(to: HTTPStatus.ok)
39+
}
2740
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import Fluent
2+
3+
struct UserMigration_v1_3_0: Migration {
4+
5+
func prepare(on db: Database) -> EventLoopFuture<Void> {
6+
db.schema(UserDeviceModel.schema)
7+
.id()
8+
.field(UserDeviceModel.FieldKeys.token, .string, .required)
9+
.field(UserDeviceModel.FieldKeys.userId, .uuid)
10+
.foreignKey(UserDeviceModel.FieldKeys.userId,
11+
references: UserModel.schema, .id,
12+
onDelete: .cascade,
13+
onUpdate: .cascade)
14+
.unique(on: UserDeviceModel.FieldKeys.token)
15+
.create()
16+
}
17+
18+
func revert(on db: Database) -> EventLoopFuture<Void> {
19+
db.schema(UserDeviceModel.schema).delete()
20+
}
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Vapor
2+
import Fluent
3+
import ViperKit
4+
5+
final class UserDeviceModel: ViperModel {
6+
typealias Module = UserModule
7+
8+
static let name = "devices"
9+
10+
struct FieldKeys {
11+
static var token: FieldKey { "token" }
12+
static var userId: FieldKey { "user_id" }
13+
}
14+
15+
// MARK: - fields
16+
17+
@ID() var id: UUID?
18+
@Field(key: FieldKeys.token) var token: String
19+
@Parent(key: FieldKeys.userId) var user: UserModel
20+
21+
init() { }
22+
23+
init(id: UserDeviceModel.IDValue? = nil,
24+
token: String,
25+
userId: UUID)
26+
{
27+
self.id = id
28+
self.token = token
29+
self.$user.id = userId
30+
}
31+
}

0 commit comments

Comments
 (0)