From 4c9e6448204795bf9c5d049712903b1f7dc5d6b5 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 28 Jan 2021 19:28:37 +0800 Subject: [PATCH 01/10] feat: add some entities and make it pass unit tests --- .../Entity/Mastodon+Entity+Activity.swift | 25 ++++ .../Entity/Mastodon+Entity+Announcement.swift | 52 ++++++++ ...Mastodon+Entity+AnnouncementReaction.swift | 37 ++++++ .../Entity/Mastodon+Entity+Attachment.swift | 124 ++++++++++++++++++ .../Entity/Mastodon+Entity+Context.swift | 23 ++++ .../Entity/Mastodon+Entity+Conversation.swift | 36 +++++ .../Entity/Mastodon+Entity+FeaturedTag.swift | 36 +++++ .../Entity/Mastodon+Entity+Filter.swift | 47 +++++++ .../Mastodon+Entity+IdentityProof.swift | 34 +++++ .../Entity/Mastodon+Entity+List.swift | 40 ++++++ .../Entity/Mastodon+Entity+Media.swift | 12 ++ .../Entity/Mastodon+Entity+Status.swift | 4 +- 12 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Activity.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Announcement.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+AnnouncementReaction.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Context.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Conversation.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+FeaturedTag.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+IdentityProof.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+List.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Media.swift diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Activity.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Activity.swift new file mode 100644 index 000000000..e7bdb0b1c --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Activity.swift @@ -0,0 +1,25 @@ +// +// Mastodon+Entity+Activity.swift +// +// +// Created by MainasuK Cirno on 2021/1/28. +// + +import Foundation + +extension Mastodon.Entity { + /// Activity + /// + /// - Since: 2.1.2 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/activity/) + public struct Activity: Codable { + public let week: Date + public let statuses: Int + public let logins: Int + public let registrations: Int + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Announcement.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Announcement.swift new file mode 100644 index 000000000..cc9b48a3d --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Announcement.swift @@ -0,0 +1,52 @@ +// +// Mastodon+Entity+Announcement.swift +// +// +// Created by MainasuK Cirno on 2021/1/28. +// + +import Foundation + +extension Mastodon.Entity { + /// Announcement + /// + /// - Since: 3.1.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/announcement/) + public struct Announcement: Codable { + + public typealias ID = String + + // Base + public let id: ID + public let text: String + public let published: Bool? + public let allDay: Bool + public let createdAt: Date + public let updatedAt: Date + public let read: Bool + public let reactions: [AnnouncementReaction] + + public let scheduledAt: Date? + public let startsAt: Date? + public let endsAt: Date? + + enum CodingKeys: String, CodingKey { + case id + case text + case published + case allDay + case createdAt = "created_at" + case updatedAt = "updated_at" + case read + case reactions + + case scheduledAt = "scheduled_at" + case startsAt = "starts_at" + case endsAt + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+AnnouncementReaction.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+AnnouncementReaction.swift new file mode 100644 index 000000000..0261165fb --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+AnnouncementReaction.swift @@ -0,0 +1,37 @@ +// +// Mastodon+Entity+AnnouncementReaction.swift +// +// +// Created by MainasuK Cirno on 2021/1/28. +// + +import Foundation + +extension Mastodon.Entity { + /// AnnouncementReaction + /// + /// - Since: 3.1.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/announcementreaction/) + public struct AnnouncementReaction: Codable { + // Base + public let name: String + public let count: Int + public let me: Bool + + // Custom Emoji + public let url: String? + public let staticURL: String? + + enum CodingKeys: String, CodingKey { + case name + case count + case me + case url + case staticURL = "static_url" + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift new file mode 100644 index 000000000..aad48fef4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift @@ -0,0 +1,124 @@ +// +// Mastodon+Entity+Attachment.swift +// +// +// Created by MainasuK Cirno on 2021/1/28. +// + +import Foundation + +extension Mastodon.Entity { + /// Attachment + /// + /// - Since: 0.6.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/attachment/) + public struct Attachment: Codable { + + public typealias ID = String + + public let id: ID + public let type: Type? + public let url: String + public let previewURL: String + + public let remoteURL: String? + public let textURL: String? + public let meta: Meta? + public let description: String? + public let blurhash: String? + + enum CodingKeys: String, CodingKey { + case id + case type + case url + case previewURL = "preview_url" + + case remoteURL = "remote_url" + case textURL = "text_url" + case meta + case description + case blurhash + } + } +} + +extension Mastodon.Entity.Attachment { + public enum `Type`: String, Codable { + case unknown + case image + case gifv + case video + case audio + } +} + + +extension Mastodon.Entity.Attachment { + /// # Reference + /// https://github.com/tootsuite/mastodon/blob/v3.3.0/app/models/media_attachment.rb + public struct Meta: Codable { + public let original: Format? + public let small: Format? + public let focus: Focus? + + public let length: String? + public let duration: Double? + public let fps: Int? + public let size: String? + public let width: Int? + public let height: Int? + public let aspect: Double? + public let audioEncode: String? + public let audioBitrate: String? + public let audioChannels: String? + + enum CodingKeys: String, CodingKey { + case original + case small + case focus + + case length + case duration + case fps + case size + case width + case height + case aspect + case audioEncode = "audio_encode" + case audioBitrate = "audio_bitrate" + case audioChannels = "audio_channels" + } + + } +} + +extension Mastodon.Entity.Attachment.Meta { + public struct Format: Codable { + public let width: Int? + public let height: Int? + public let size: String? + public let aspect: Double? + public let frameRate: String? + public let duration: Double? + public let bitrate: Int? + + enum CodingKeys: String, CodingKey { + case width + case height + case size + case aspect + case frameRate = "frame_rate" + case duration + case bitrate + } + } + + public struct Focus: Codable { + public let x: Double + public let y: Double + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Context.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Context.swift new file mode 100644 index 000000000..ce996ab3f --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Context.swift @@ -0,0 +1,23 @@ +// +// Mastodon+Entity+Context.swift +// +// +// Created by MainasuK Cirno on 2021/1/28. +// + +import Foundation + +extension Mastodon.Entity { + /// Context + /// + /// - Since: 0.6.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/context/) + public struct Context: Codable { + public let ancestors: [Status] + public let descendants: [Status] + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Conversation.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Conversation.swift new file mode 100644 index 000000000..51662f91c --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Conversation.swift @@ -0,0 +1,36 @@ +// +// Mastodon+Entity+Conversation.swift +// +// +// Created by MainasuK Cirno on 2021/1/28. +// + +import Foundation + +extension Mastodon.Entity { + /// Conversation + /// + /// - Since: 2.6.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/conversation/) + public struct Conversation: Codable { + public typealias ID = String + + public let id: ID + public let accounts: [Account] + public let unread: Bool + + public let lastStatus: Status? + + enum CodingKeys: String, CodingKey { + case id + case accounts + case unread + + case lastStatus = "last_status" + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+FeaturedTag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+FeaturedTag.swift new file mode 100644 index 000000000..cdc592121 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+FeaturedTag.swift @@ -0,0 +1,36 @@ +// +// Mastodon+Entity+FeaturedTag.swift +// +// +// Created by MainasuK Cirno on 2021/1/28. +// + +import Foundation + +extension Mastodon.Entity { + /// FeaturedTag + /// + /// - Since: 3.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/featuredtag/) + public struct FeaturedTag: Codable { + public typealias ID = String + + public let id: ID + public let name: String + public let url: String? + public let statusesCount: Int + public let lastStatusAt: Date + + enum CodingKeys: String, CodingKey { + case id + case name + case url + case statusesCount = "statuses_count" + case lastStatusAt = "last_status_at" + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift new file mode 100644 index 000000000..36cee2c15 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift @@ -0,0 +1,47 @@ +// +// Mastodon+Entity+Filter.swift +// +// +// Created by MainasuK Cirno on 2021/1/28. +// + +import Foundation + +extension Mastodon.Entity { + /// Field + /// + /// - Since: 2.4.3 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/filter/) + public struct Filter: Codable { + public typealias ID = String + + public let id: ID + public let phrase: String + public let context: [Context?] + public let expiresAt: Date + public let irreversible: Bool + public let wholeWord: Bool + + enum CodingKeys: String, CodingKey { + case id + case phrase + case context + case expiresAt = "expires_at" + case irreversible + case wholeWord = "whole_word" + } + } +} + +extension Mastodon.Entity.Filter { + public enum Context: String, Codable { + case home + case notifications + case `public` + case thread + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+IdentityProof.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+IdentityProof.swift new file mode 100644 index 000000000..cc2046ee6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+IdentityProof.swift @@ -0,0 +1,34 @@ +// +// Mastodon+Entity+IdentityProof.swift +// +// +// Created by MainasuK Cirno on 2021/1/28. +// + +import Foundation + +extension Mastodon.Entity { + /// IdentityProof + /// + /// - Since: 2.8.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/identityproof/) + public struct IdentityProof: Codable { + public let provider: String + public let providerUsername: String + public let profileURL: String + public let proofURL: String + public let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case provider = "provider" + case providerUsername = "provider_username" + case profileURL = "profile_url" + case proofURL = "proof_url" + case updatedAt = "updated_at" + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+List.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+List.swift new file mode 100644 index 000000000..b9e3ba3b8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+List.swift @@ -0,0 +1,40 @@ +// +// Mastodon+Entity+List.swift +// +// +// Created by MainasuK Cirno on 2021/1/28. +// + +import Foundation + +extension Mastodon.Entity { + /// List + /// + /// - Since: 2.1.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/28 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/list/) + public struct List: Codable { + public typealias ID = String + + public let id: ID + public let title: String + public let repliesPolicy: ReplyPolicy? + + enum CodingKeys: String, CodingKey { + case id + case title + case repliesPolicy = "replies_policy" + } + } +} + +extension Mastodon.Entity { + public enum ReplyPolicy: String, Codable { + case followed + case list + case none + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Media.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Media.swift new file mode 100644 index 000000000..5257f078a --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Media.swift @@ -0,0 +1,12 @@ +// +// Mastodon+Entity+Media.swift +// +// +// Created by MainasuK Cirno on 2021/1/28. +// + +import Foundation + +extension Mastodon.Entity { + +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift index 7fa3f0cb4..9479f20a1 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift @@ -31,9 +31,10 @@ extension Mastodon.Entity { public let account: Account public let content: String - public let visibility: String? + public let visibility: Visibility? public let sensitive: Bool? public let spoilerText: String? + public let mediaAttachments: [Attachment] public let application: Application? // Rendering @@ -73,6 +74,7 @@ extension Mastodon.Entity { case visibility case sensitive case spoilerText = "spoiler_text" + case mediaAttachments = "media_attachments" case application case mentions From fe83c02e03a2e3f36cb7aae1cfd7a1caea140fb5 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 29 Jan 2021 14:49:25 +0800 Subject: [PATCH 02/10] feat: implement all entities --- .../Entity/Mastodon+Entity+Attachment.swift | 28 ++++++- .../Entity/Mastodon+Entity+Card.swift | 26 ++++++- .../Entity/Mastodon+Entity+Filter.swift | 26 ++++++- .../Entity/Mastodon+Entity+List.swift | 23 +++++- .../Entity/Mastodon+Entity+Marker.swift | 38 +++++++++ .../Entity/Mastodon+Entity+Media.swift | 12 --- .../Entity/Mastodon+Entity+Notification.swift | 77 +++++++++++++++++++ .../Entity/Mastodon+Entity+Preferences.swift | 58 ++++++++++++++ .../Mastodon+Entity+PushSubscription.swift | 44 +++++++++++ .../Entity/Mastodon+Entity+Relationship.swift | 53 +++++++++++++ .../Entity/Mastodon+Entity+Report.swift | 30 ++++++++ .../Entity/Mastodon+Entity+Results.swift | 41 ++++++++++ .../Mastodon+Entity+ScheduledStatus.swift | 60 +++++++++++++++ .../Entity/Mastodon+Entity+Source.swift | 24 +++++- .../Entity/Mastodon+Entity+Status.swift | 24 +++++- .../Entity/Mastodon+Entity+Token.swift | 25 ++++++ .../MastodonSDK/Entity/Mastodon+Entity.swift | 4 +- 17 files changed, 571 insertions(+), 22 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Marker.swift delete mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Media.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Preferences.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+PushSubscription.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Relationship.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Report.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Results.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ScheduledStatus.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Token.swift diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift index aad48fef4..9c1a34106 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift @@ -21,7 +21,7 @@ extension Mastodon.Entity { public typealias ID = String public let id: ID - public let type: Type? + public let type: Type public let url: String public let previewURL: String @@ -47,12 +47,36 @@ extension Mastodon.Entity { } extension Mastodon.Entity.Attachment { - public enum `Type`: String, Codable { + public enum `Type`: RawRepresentable, Codable { case unknown case image case gifv case video case audio + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "unknown": self = .unknown + case "image": self = .image + case "gifv": self = .gifv + case "video": self = .video + case "audio": self = .audio + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .unknown: return "unknown" + case .image: return "image" + case .gifv: return "gifv" + case .video: return "video" + case .audio: return "audio" + case ._other(let value): return value + } + } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Card.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Card.swift index ba97d1d78..e23e25a2d 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Card.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Card.swift @@ -21,7 +21,7 @@ extension Mastodon.Entity { public let url: String public let title: String public let description: String - public let type: Type? + public let type: Type public let authorName: String? public let authorURL: String? @@ -54,10 +54,32 @@ extension Mastodon.Entity { } extension Mastodon.Entity.Card { - public enum `Type`: String, Codable { + public enum `Type`: RawRepresentable, Codable { case link case photo case video case rich + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "link": self = .link + case "photo": self = .photo + case "video": self = .video + case "rich": self = .rich + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .link: return "link" + case .photo: return "photo" + case .video: return "video" + case .rich: return "rich" + case ._other(let value): return value + } + } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift index 36cee2c15..6374c0ab0 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift @@ -21,7 +21,7 @@ extension Mastodon.Entity { public let id: ID public let phrase: String - public let context: [Context?] + public let context: [Context] public let expiresAt: Date public let irreversible: Bool public let wholeWord: Bool @@ -38,10 +38,32 @@ extension Mastodon.Entity { } extension Mastodon.Entity.Filter { - public enum Context: String, Codable { + public enum Context: RawRepresentable, Codable { case home case notifications case `public` case thread + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "home": self = .home + case "notifications": self = .notifications + case "public": self = .`public` + case "thread": self = .thread + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .home: return "home" + case .notifications: return "notifications" + case .public: return "public" + case .thread: return "thread" + case ._other(let value): return value + } + } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+List.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+List.swift index b9e3ba3b8..de4359db3 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+List.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+List.swift @@ -21,6 +21,7 @@ extension Mastodon.Entity { public let id: ID public let title: String + public let repliesPolicy: ReplyPolicy? enum CodingKeys: String, CodingKey { @@ -32,9 +33,29 @@ extension Mastodon.Entity { } extension Mastodon.Entity { - public enum ReplyPolicy: String, Codable { + public enum ReplyPolicy: RawRepresentable, Codable { case followed case list case none + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "followed": self = .followed + case "list": self = .list + case "none": self = .none + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .followed: return "followed" + case .list: return "list" + case .none: return "none" + case ._other(let value): return value + } + } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Marker.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Marker.swift new file mode 100644 index 000000000..e4dcaf7ee --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Marker.swift @@ -0,0 +1,38 @@ +// +// Mastodon+Entity+Marker.swift +// +// +// Created by MainasuK Cirno on 2021/1/28. +// + +import Foundation + +extension Mastodon.Entity { + /// Marker + /// + /// - Since: 3.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/marker/) + public struct Marker: Codable { + // Base + public let home: Position + public let notifications: Position + } +} + +extension Mastodon.Entity.Marker { + public struct Position: Codable { + public let lastReadID: Mastodon.Entity.Status.ID + public let updatedAt: Date + public let version: Int + + enum CodingKeys: String, CodingKey { + case lastReadID = "last_read_id" + case updatedAt = "updated_at" + case version + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Media.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Media.swift deleted file mode 100644 index 5257f078a..000000000 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Media.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Mastodon+Entity+Media.swift -// -// -// Created by MainasuK Cirno on 2021/1/28. -// - -import Foundation - -extension Mastodon.Entity { - -} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift new file mode 100644 index 000000000..413c89bd3 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift @@ -0,0 +1,77 @@ +// +// Mastodon+Entity+Notification.swift +// +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import Foundation + +extension Mastodon.Entity { + /// Notification + /// + /// - Since: 0.9.9 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/notification/) + public struct Notification: Codable { + public typealias ID = String + + public let id: ID + public let type: Type + public let createdAt: Date + public let account: Account + + public let status: Status? + + enum CodingKeys: String, CodingKey { + case id + case type + case createdAt = "created_at" + case account + case status + } + } +} + +extension Mastodon.Entity.Notification { + public enum `Type`: RawRepresentable, Codable { + case follow + case followRequest + case mention + case reblog + case favourite + case poll + case status + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "follow": self = .follow + case "follow_request": self = .followRequest + case "mention": self = .mention + case "reblog": self = .reblog + case "favourite": self = .favourite + case "poll": self = .poll + case "status": self = .status + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .follow: return "follow" + case .followRequest: return "follow_request" + case .mention: return "mention" + case .reblog: return "reblog" + case .favourite: return "favourite" + case .poll: return "poll" + case .status: return "status" + case ._other(let value): return value + } + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Preferences.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Preferences.swift new file mode 100644 index 000000000..7daeee18b --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Preferences.swift @@ -0,0 +1,58 @@ +// +// Mastodon+Entity+Preferences.swift +// +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import Foundation + +extension Mastodon.Entity { + /// Preferences + /// + /// - Since: 2.8.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/preferences/) + public struct Preferences: Codable { + public let postingDefaultVisibility: Visibility + public let postingDefaultSensitive: Bool + public let postingDefaultLanguage: String? // (ISO 639-1 language two-letter code) + public let readingExpandMedia: ExpandMedia + public let readingExpandSpoilers: Bool + } +} + +extension Mastodon.Entity.Preferences { + public typealias Visibility = Mastodon.Entity.Source.Privacy +} + +extension Mastodon.Entity.Preferences { + public enum ExpandMedia: RawRepresentable, Codable { + case `default` + case showAll + case hideAll + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "default": self = .default + case "showAll": self = .showAll + case "hideAll": self = .hideAll + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .default: return "default" + case .showAll: return "showAll" + case .hideAll: return "hideAll" + case ._other(let value): return value + } + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+PushSubscription.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+PushSubscription.swift new file mode 100644 index 000000000..f649de83d --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+PushSubscription.swift @@ -0,0 +1,44 @@ +// +// Mastodon+Entity+PushSubscription.swift +// +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import Foundation + +extension Mastodon.Entity { + /// PushSubscription + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/pushsubscription/) + public struct PushSubscription: Codable { + public typealias ID = String + + public let id: ID + public let endpoint: String + public let serverKey: String + public let alerts: Alerts + + enum CodingKeys: String, CodingKey { + case id + case endpoint + case serverKey = "server_key" + case alerts + } + } +} + +extension Mastodon.Entity.PushSubscription { + public struct Alerts: Codable { + public let follow: Bool + public let favourite: Bool + public let reblog: Bool + public let mention: Bool + public let poll: Bool? + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Relationship.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Relationship.swift new file mode 100644 index 000000000..ca733440b --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Relationship.swift @@ -0,0 +1,53 @@ +// +// Mastodon+Entity+Relationship.swift +// +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import Foundation + +extension Mastodon.Entity { + /// Relationship + /// + /// - Since: 0.6.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/relationship/) + public struct Relationship: Codable { + public typealias ID = String + + public let id: ID + public let following: Bool + public let requested: Bool? + public let endorsed: Bool? + public let followedBy: Bool + public let muting: Bool? + public let mutingNotifications: Bool? + public let showingReblogs: Bool? + public let notifying: Bool? + public let blocking: Bool + public let domainBlocking: Bool? + public let blockedBy: Bool? + public let note: String? + + enum CodingKeys: String, CodingKey { + case id + case following + case requested + case endorsed + case followedBy = "followed_by" + case muting + case mutingNotifications = "muting_notifications" + case showingReblogs = "showing_reblogs" + case notifying + case blocking + case domainBlocking = "domain_blocking" + case blockedBy = "blocked_by" + case note + + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Report.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Report.swift new file mode 100644 index 000000000..5b2571206 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Report.swift @@ -0,0 +1,30 @@ +// +// Mastodon+Entity+Report.swift +// +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import Foundation + +extension Mastodon.Entity { + /// Report + /// + /// - Since: ? + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/report/) + public struct Report: Codable { + public typealias ID = String + + public let id: ID // undocumented + public let actionTaken: Bool? // undocumented + + enum CodingKeys: String, CodingKey { + case id + case actionTaken = "action_taken" + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Results.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Results.swift new file mode 100644 index 000000000..107c0ed05 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Results.swift @@ -0,0 +1,41 @@ +// +// Mastodon+Entity+Results.swift +// +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import Foundation + +extension Mastodon.Entity { + /// Results (v1) + /// + /// - Since: ? + /// - Version: 3.0.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/results/) + public struct Results: Codable { + public let accounts: [Account] + public let statuses: [Status] + public let hashtags: [String] + } +} + +extension Mastodon.Entity.V2 { + /// Results (v2) + /// + /// - Since: 2.4.1 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/results/) + public struct Results: Codable { + public let accounts: [Mastodon.Entity.Account] + public let statuses: [Mastodon.Entity.Status] + public let hashtags: [Mastodon.Entity.Tag] + } +} + diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ScheduledStatus.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ScheduledStatus.swift new file mode 100644 index 000000000..8a330c555 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ScheduledStatus.swift @@ -0,0 +1,60 @@ +// +// Mastodon+Entity+ScheduledStatus.swift +// +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import Foundation + +extension Mastodon.Entity { + /// ScheduledStatus + /// + /// - Since: 2.7.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/scheduledstatus/) + public struct ScheduledStatus: Codable { + public typealias ID = String + + public let id: ID + public let scheduledAt: Date + public let params: Parameters + public let mediaAttachments: [Attachment] + } +} + +extension Mastodon.Entity.ScheduledStatus { + public struct Parameters: Codable { + public let text: String + public let inReplyToID: Mastodon.Entity.Account.ID? + public let mediaIDs: [Mastodon.Entity.Attachment.ID]? + public let sensitive: Bool? + public let spoilerText: String? + public let visibility: Visibility + public let scheduledAt: Date? + public let poll: Mastodon.Entity.Poll? // undocumented + public let applicationID: String + + // public let idempotency: Bool? // undoumented + // public let withRateLimit // undoumented + + enum CodingKeys: String, CodingKey { + case text + case inReplyToID = "in_reply_to_id" + case mediaIDs = "media_ids" + case sensitive + case spoilerText = "spoiler_text" + case visibility + case scheduledAt = "scheduled_at" + case poll + case applicationID = "application_id" + } + } +} + +extension Mastodon.Entity.ScheduledStatus.Parameters { + public typealias Visibility = Mastodon.Entity.Source.Privacy +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Source.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Source.swift index c808fc87c..5d0884377 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Source.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Source.swift @@ -40,10 +40,32 @@ extension Mastodon.Entity { } extension Mastodon.Entity.Source { - public enum Privacy: String, Codable { + public enum Privacy: RawRepresentable, Codable { case `public` case unlisted case `private` case direct + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "public": self = .public + case "unlisted": self = .unlisted + case "private": self = .private + case "direct": self = .direct + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .public: return "public" + case .unlisted: return "unlisted" + case .private: return "private" + case .direct: return "direct" + case ._other(let value): return value + } + } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift index 9479f20a1..210a8e1d8 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift @@ -105,10 +105,32 @@ extension Mastodon.Entity { } extension Mastodon.Entity.Status { - public enum Visibility: String, Codable { + public enum Visibility: RawRepresentable, Codable { case `public` case unlisted case `private` case direct + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "public": self = .public + case "unlisted": self = .unlisted + case "private": self = .private + case "direct": self = .direct + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .public: return "public" + case .unlisted: return "unlisted" + case .private: return "private" + case .direct: return "direct" + case ._other(let value): return value + } + } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Token.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Token.swift new file mode 100644 index 000000000..5f9d273c4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Token.swift @@ -0,0 +1,25 @@ +// +// Mastodon+Entity+Token.swift +// +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import Foundation + +extension Mastodon.Entity { + /// Token + /// + /// - Since: 0.1.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/token/) + public struct Token: Codable { + public let accessToken: String + public let tokenType: String + public let scope: String + public let createdAt: Date + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity.swift index 77da12ebf..ccf087b03 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity.swift @@ -7,7 +7,9 @@ import Foundation -extension Mastodon.Entity { } +extension Mastodon.Entity { + public enum V2 { } +} // MARK: - Entity Document Template /// Entity Name From 71de1ed9bed4e7569e13a8e7ef65b1a06a31c7bc Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 29 Jan 2021 19:38:11 +0800 Subject: [PATCH 03/10] feat: add OAuth API endpoint unit test --- Mastodon.xcodeproj/project.pbxproj | 53 ++++++++++ .../xcshareddata/swiftpm/Package.resolved | 9 ++ Mastodon/Extension/OSLog.swift | 20 ++++ .../AuthenticationViewController.swift | 10 ++ ...PinBasedAuthenticationViewController.swift | 25 +++++ ...todonPinBasedAuthenticationViewModel.swift | 12 +++ ...ationViewModelNavigationDelegateShim.swift | 34 +++++++ MastodonSDK.xctestplan | 25 ++++- .../MastodonSDK/API/Mastodon+API+App.swift | 54 +++++++++- .../MastodonSDK/API/Mastodon+API+OAuth.swift | 99 +++++++++++++++++++ .../API/Mastodon+API+Timeline.swift | 30 ++---- .../MastodonSDK/API/Mastodon+API.swift | 22 +++-- .../API/MastodonSDK+API+AppTests.swift | 83 ++++++++++++++++ .../API/MastodonSDK+API+OAuthTests.swift | 29 ++++++ .../MastodonSDKTests/MastodonSDKTests.swift | 55 ++--------- 15 files changed, 482 insertions(+), 78 deletions(-) create mode 100644 Mastodon/Extension/OSLog.swift create mode 100644 Mastodon/Scene/Authentication/AuthenticationViewController.swift create mode 100644 Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewController.swift create mode 100644 Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModel.swift create mode 100644 Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift create mode 100644 MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AppTests.swift create mode 100644 MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 4cef307c8..8841f7ee3 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -11,6 +11,12 @@ 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; 7A9135D4559750AF07CA9BE4 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */; }; + DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB01409525C40B6700F9F3CF /* AuthenticationViewController.swift */; }; + DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; + DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; }; + DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; + DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; }; + DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DB3D102425BAA7B400EAA174 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3D102225BAA7B400EAA174 /* Assets.swift */; }; @@ -108,6 +114,11 @@ A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release.xcconfig"; sourceTree = ""; }; CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DB01409525C40B6700F9F3CF /* AuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewController.swift; sourceTree = ""; }; + DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewController.swift; sourceTree = ""; }; + DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = ""; }; + DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = ""; }; + DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; DB3D102225BAA7B400EAA174 /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; @@ -160,6 +171,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, 7A9135D4559750AF07CA9BE4 /* Pods_Mastodon.framework in Frameworks */, @@ -224,6 +236,25 @@ name = Frameworks; sourceTree = ""; }; + DB01409B25C40BB600F9F3CF /* Authentication */ = { + isa = PBXGroup; + children = ( + DB0140A625C40C0900F9F3CF /* PinBased */, + DB01409525C40B6700F9F3CF /* AuthenticationViewController.swift */, + ); + path = Authentication; + sourceTree = ""; + }; + DB0140A625C40C0900F9F3CF /* PinBased */ = { + isa = PBXGroup; + children = ( + DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */, + DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */, + DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */, + ); + path = PinBased; + sourceTree = ""; + }; DB3D0FF725BAA68500EAA174 /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -400,6 +431,7 @@ children = ( DB8AF54E25C13703002E6C99 /* MainTab */, DB8AF55625C137A8002E6C99 /* HomeViewController.swift */, + DB01409B25C40BB600F9F3CF /* Authentication */, ); path = Scene; sourceTree = ""; @@ -407,6 +439,7 @@ DB8AF56225C138BC002E6C99 /* Extension */ = { isa = PBXGroup; children = ( + DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, ); path = Extension; @@ -447,6 +480,7 @@ packageProductDependencies = ( DB3D0FF225BAA61700EAA174 /* AlamofireImage */, 5D526FE125BE9AC400460CB9 /* MastodonSDK */, + DB0140BC25C40D7500F9F3CF /* CommonOSLog */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -569,6 +603,7 @@ mainGroup = DB427DC925BAA00100D1B89D; packageReferences = ( DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */, + DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -753,15 +788,20 @@ DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, + DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, + DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, + DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, DB8AF55725C137A8002E6C99 /* HomeViewController.swift in Sources */, DB3D102525BAA7B400EAA174 /* Strings.swift in Sources */, + DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */, DB3D102425BAA7B400EAA174 /* Assets.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, + DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1263,6 +1303,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/CommonOSLog"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.1; + }; + }; DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Alamofire/AlamofireImage.git"; @@ -1278,6 +1326,11 @@ isa = XCSwiftPackageProductDependency; productName = MastodonSDK; }; + DB0140BC25C40D7500F9F3CF /* CommonOSLog */ = { + isa = XCSwiftPackageProductDependency; + package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; + productName = CommonOSLog; + }; DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = { isa = XCSwiftPackageProductDependency; package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 17197be49..296f9dd2a 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -19,6 +19,15 @@ "version": "4.1.0" } }, + { + "package": "CommonOSLog", + "repositoryURL": "https://github.com/MainasuK/CommonOSLog", + "state": { + "branch": null, + "revision": "c121624a30698e9886efe38aebb36ff51c01b6c2", + "version": "0.1.1" + } + }, { "package": "swift-nio", "repositoryURL": "https://github.com/apple/swift-nio.git", diff --git a/Mastodon/Extension/OSLog.swift b/Mastodon/Extension/OSLog.swift new file mode 100644 index 000000000..0121200d9 --- /dev/null +++ b/Mastodon/Extension/OSLog.swift @@ -0,0 +1,20 @@ +// +// OSLog.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/1/29 +// + +import os +import Foundation +import CommonOSLog + +extension OSLog { + static let api: OSLog = { + #if DEBUG + return OSLog(subsystem: OSLog.subsystem + ".api", category: "api") + #else + return OSLog.disabled + #endif + }() +} diff --git a/Mastodon/Scene/Authentication/AuthenticationViewController.swift b/Mastodon/Scene/Authentication/AuthenticationViewController.swift new file mode 100644 index 000000000..12e1d14d5 --- /dev/null +++ b/Mastodon/Scene/Authentication/AuthenticationViewController.swift @@ -0,0 +1,10 @@ +// +// AuthenticationViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import UIKit + + diff --git a/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewController.swift b/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewController.swift new file mode 100644 index 000000000..6f9ce506f --- /dev/null +++ b/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewController.swift @@ -0,0 +1,25 @@ +// +// MastodonPinBasedAuthenticationViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import os.log +import Foundation +import WebKit + +final class MastodonPinBasedAuthenticationViewController: NSObject { + + + weak var viewModel: MastodonPinBasedAuthenticationViewModel? + + init(viewModel: MastodonPinBasedAuthenticationViewModel) { + self.viewModel = viewModel + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModel.swift b/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModel.swift new file mode 100644 index 000000000..4ae5e14a4 --- /dev/null +++ b/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModel.swift @@ -0,0 +1,12 @@ +// +// MastodonPinBasedAuthenticationViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import Foundation + +final class MastodonPinBasedAuthenticationViewModel { + +} diff --git a/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift b/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift new file mode 100644 index 000000000..3f7092b1f --- /dev/null +++ b/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift @@ -0,0 +1,34 @@ +// +// MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/1/29. +// + +import os.log +import Foundation +import WebKit + +final class MastodonPinBasedAuthenticationViewModelNavigationDelegateShim: NSObject { + + weak var viewModel: MastodonPinBasedAuthenticationViewModel? + + init(viewModel: MastodonPinBasedAuthenticationViewModel) { + self.viewModel = viewModel + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } +} + + +// MARK: - WKNavigationDelegate +extension MastodonPinBasedAuthenticationViewModelNavigationDelegateShim: WKNavigationDelegate { + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + // TODO: + } + +} + diff --git a/MastodonSDK.xctestplan b/MastodonSDK.xctestplan index 14cf031db..28e9be637 100644 --- a/MastodonSDK.xctestplan +++ b/MastodonSDK.xctestplan @@ -2,9 +2,26 @@ "configurations" : [ { "id" : "5119353D-C795-4264-89FD-8376D9B144F8", - "name" : "Configuration 1", + "name" : "mstdn.jp", "options" : { - + "environmentVariableEntries" : [ + { + "key" : "domain", + "value" : "mstdn.jp" + } + ] + } + }, + { + "id" : "C5184AF3-B83B-4A7E-949C-6B1AA3ABE7D1", + "name" : "pawoo.net", + "options" : { + "environmentVariableEntries" : [ + { + "key" : "domain", + "value" : "pawoo.net" + } + ] } } ], @@ -13,6 +30,10 @@ }, "testTargets" : [ { + "skippedTests" : [ + "MastodonSDKTests\/testCreateAnAnpplication()", + "MastodonSDKTests\/testVerifyAppCredentials()" + ], "target" : { "containerPath" : "container:MastodonSDK", "identifier" : "MastodonSDKTests", diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift index 987164614..54105790f 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+App.swift @@ -14,12 +14,31 @@ extension Mastodon.API.App { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("apps") } + static func verifyCredentialsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("apps/verify_credentials") + } + + /// Create an application + /// + /// Using this endpoint to obtain `client_id` and `client_secret` for later OAuth token exchange + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/apps/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `CreateQuery` + /// - Returns: `AnyPublisher` contains `Application` nested in the response public static func create( session: URLSession, domain: String, query: CreateQuery ) -> AnyPublisher, Error> { - let request = Mastodon.API.request( + let request = Mastodon.API.post( url: appEndpointURL(domain: domain), query: query, authorization: nil @@ -31,6 +50,39 @@ extension Mastodon.API.App { } .eraseToAnyPublisher() } + + /// Verify application token + /// + /// Using this endpoint to verify App token + /// + /// - Since: 2.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/apps/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: App token + /// - Returns: `AnyPublisher` contains `Application` nested in the response + public static func verifyCredentials( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: verifyCredentialsEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Application.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift index 88461f995..c25526ff6 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift @@ -6,6 +6,7 @@ // import Foundation +import Combine extension Mastodon.API.OAuth { @@ -16,3 +17,101 @@ extension Mastodon.API.OAuth { } } + +extension Mastodon.API.OAuth { + + static func authorizeEndpointURL(domain: String) -> URL { + return Mastodon.API.oauthEndpointURL(domain: domain).appendingPathComponent("authorize") + } + + /// Construct user authorize endpoint URL + /// + /// This method construct a URL for user authorize + /// + /// - Since: 0.1.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/1/29 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/apps/oauth/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `AuthorizeQuery` + static func authorizeURL( + domain: String, + query: AuthorizeQuery + ) -> URL { + let request = Mastodon.API.get( + url: authorizeEndpointURL(domain: domain), + query: query, + authorization: nil + ) + let url = request.url! + return url + } + +// static func authorize( +// session: URLSession, +// domain: String, +// query: AuthorizeQuery +// ) -> AnyPublisher, Error> { +// let request = Mastodon.API.post( +// url: authorizeEndpointURL(domain: domain), +// query: query, +// authorization: nil +// ) +// return session.dataTaskPublisher(for: request) +// .tryMap { data, response in +// let value = try Mastodon.API.decode(type: Mastodon.Entity.Token.self, from: data, response: response) +// return Mastodon.Response.Content(value: value, response: response) +// } +// .eraseToAnyPublisher() +// } + +} + +extension Mastodon.API.OAuth { + public struct AuthorizeQuery: GetQuery { + + public let forceLogin: String? + public let responseType: String + public let clientID: String + public let redirectURI: String + public let scope: String? + + public init( + forceLogin: String? = nil, + responseType: String = "code", + clientID: String, + redirectURI: String = "urn:ietf:wg:oauth:2.0:oob", + scope: String? = "read write follow push" + ) { + self.forceLogin = forceLogin + self.responseType = responseType + self.clientID = clientID + self.redirectURI = redirectURI + self.scope = scope + } + + enum CodingKeys: String, CodingKey { + case forceLogin = "force_login" + case responseType = "response_type" + case clientID + case redirectURI = "redirect_uri" + case scope + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + forceLogin.flatMap { items.append(URLQueryItem(name: "force_login", value: $0)) } + items.append(URLQueryItem(name: "response_type", value: responseType)) + items.append(URLQueryItem(name: "clientID", value: clientID)) + items.append(URLQueryItem(name: "redirect_uri", value: redirectURI)) + scope.flatMap { items.append(URLQueryItem(name: "scope", value: $0)) } + guard !items.isEmpty else { return nil } + return items + } + + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index 13d0dc141..95f912b41 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -19,7 +19,7 @@ extension Mastodon.API.Timeline { domain: String, query: PublicTimelineQuery ) -> AnyPublisher, Error> { - let request = Mastodon.API.request( + let request = Mastodon.API.get( url: publicTimelineEndpointURL(domain: domain), query: query, authorization: nil @@ -65,27 +65,13 @@ extension Mastodon.API.Timeline { var queryItems: [URLQueryItem]? { var items: [URLQueryItem] = [] - local.flatMap { - items.append(URLQueryItem(name: "local", value: $0.queryItemValue)) - } - remote.flatMap { - items.append(URLQueryItem(name: "remote", value: $0.queryItemValue)) - } - onlyMedia.flatMap { - items.append(URLQueryItem(name: "only_media", value: $0.queryItemValue)) - } - maxID.flatMap { - items.append(URLQueryItem(name: "max_id", value: $0)) - } - sinceID.flatMap { - items.append(URLQueryItem(name: "since_id", value: $0)) - } - minID.flatMap { - items.append(URLQueryItem(name: "min_id", value: $0)) - } - limit.flatMap { - items.append(URLQueryItem(name: "limit", value: String($0))) - } + local.flatMap { items.append(URLQueryItem(name: "local", value: $0.queryItemValue)) } + remote.flatMap { items.append(URLQueryItem(name: "remote", value: $0.queryItemValue)) } + onlyMedia.flatMap { items.append(URLQueryItem(name: "only_media", value: $0.queryItemValue)) } + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } guard !items.isEmpty else { return nil } return items } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 9c3fee054..fcab0eb3e 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -53,9 +53,15 @@ extension Mastodon.API { return decoder }() + static func oauthEndpointURL(domain: String) -> URL { + return URL(string: "https://" + domain + "/oauth/")! + } static func endpointURL(domain: String) -> URL { return URL(string: "https://" + domain + "/api/v1/")! } + static func endpointV2URL(domain: String) -> URL { + return URL(string: "https://" + domain + "/api/v2/")! + } } @@ -67,13 +73,15 @@ extension Mastodon.API { extension Mastodon.API { - static func request( + static func get( url: URL, - query: GetQuery, + query: GetQuery?, authorization: OAuth.Authorization? ) -> URLRequest { var components = URLComponents(string: url.absoluteString)! - components.queryItems = query.queryItems + if let query = query { + components.queryItems = query.queryItems + } let requestURL = components.url! var request = URLRequest( @@ -91,9 +99,9 @@ extension Mastodon.API { return request } - static func request( + static func post( url: URL, - query: PostQuery, + query: PostQuery?, authorization: OAuth.Authorization? ) -> URLRequest { let components = URLComponents(string: url.absoluteString)! @@ -104,7 +112,9 @@ extension Mastodon.API { timeoutInterval: Mastodon.API.timeoutInterval ) request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") - request.httpBody = query.body + if let query = query { + request.httpBody = query.body + } if let authorization = authorization { request.setValue( "Bearer \(authorization.accessToken)", diff --git a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AppTests.swift b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AppTests.swift new file mode 100644 index 000000000..f74fe61dc --- /dev/null +++ b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AppTests.swift @@ -0,0 +1,83 @@ +// +// MastodonSDK+API+AppTests.swift +// +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import os.log +import XCTest +import Combine +@testable import MastodonSDK + +extension MastodonSDKTests { + + func testCreateAnAnpplication() throws { + try _testCreateAnAnpplication(domain: domain) + } + + func _testCreateAnAnpplication(domain: String) throws { + let theExpectation = expectation(description: "Create An Application") + + let query = Mastodon.API.App.CreateQuery( + clientName: "XCTest", + website: nil + ) + Mastodon.API.App.create(session: session, domain: domain, query: query) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + XCTFail(error.localizedDescription) + case .finished: + break + } + } receiveValue: { response in + XCTAssertEqual(response.value.name, "XCTest") + XCTAssertEqual(response.value.website, nil) + XCTAssertEqual(response.value.redirectURI, "urn:ietf:wg:oauth:2.0:oob") + os_log("%{public}s[%{public}ld], %{public}s: (%s) clientID %s", ((#file as NSString).lastPathComponent), #line, #function, domain, response.value.clientID ?? "nil") + os_log("%{public}s[%{public}ld], %{public}s: (%s) clientSecret %s", ((#file as NSString).lastPathComponent), #line, #function, domain, response.value.clientSecret ?? "nil") + theExpectation.fulfill() + } + .store(in: &disposeBag) + + wait(for: [theExpectation], timeout: 5.0) + } + +} + +extension MastodonSDKTests { + + func testVerifyAppCredentials() throws { + try _testVerifyAppCredentials(domain: domain, accessToken: "") + } + + func _testVerifyAppCredentials(domain: String, accessToken: String) throws { + let theExpectation = expectation(description: "Verify App Credentials") + + let authorization = Mastodon.API.OAuth.Authorization(accessToken: accessToken) + Mastodon.API.App.verifyCredentials( + session: session, + domain: domain, + authorization: authorization + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + XCTFail(error.localizedDescription) + case .finished: + break + } + } receiveValue: { response in + XCTAssertEqual(response.value.name, "XCTest") + XCTAssertEqual(response.value.website, nil) + theExpectation.fulfill() + } + .store(in: &disposeBag) + + wait(for: [theExpectation], timeout: 5.0) + } + +} diff --git a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift new file mode 100644 index 000000000..b027578f1 --- /dev/null +++ b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift @@ -0,0 +1,29 @@ +// +// MastodonSDK+API+OAuthTests.swift +// +// +// Created by MainasuK Cirno on 2021/1/29. +// + +import os.log +import XCTest +import Combine +@testable import MastodonSDK + +extension MastodonSDKTests { + + func testOAuthAuthorize() throws { + try _testOAuthAuthorize(domain: domain) + } + + func _testOAuthAuthorize(domain: String) throws { + let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: "StubClientID") + let authorizeURL = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query) + os_log("%{public}s[%{public}ld], %{public}s: (%s) authorizeURL %s", ((#file as NSString).lastPathComponent), #line, #function, domain, authorizeURL.absoluteString) + XCTAssertEqual( + authorizeURL.absoluteString, + "https://\(domain)/oauth/authorize?response_type=code&clientID=StubClientID&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=read%20write%20follow%20push" + ) + } + +} diff --git a/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift b/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift index 15d2cee00..8996d1aa8 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/MastodonSDKTests.swift @@ -5,59 +5,20 @@ import Combine final class MastodonSDKTests: XCTestCase { var disposeBag = Set() - - let mstdnDomain = "mstdn.jp" - let pawooDomain = "pawoo.net" + let session = URLSession(configuration: .ephemeral) + var domain: String { MastodonSDKTests.environmentVariable(key: "domain") } + + static func environmentVariable(key: String) -> String { + return ProcessInfo.processInfo.environment[key]! + } } extension MastodonSDKTests { - func testCreateAnAnpplication_mstdn() throws { - try _testCreateAnAnpplication(domain: pawooDomain) - } - - func testCreateAnAnpplication_pawoo() throws { - try _testCreateAnAnpplication(domain: pawooDomain) - } - - func _testCreateAnAnpplication(domain: String) throws { - let theExpectation = expectation(description: "Create An Application") - - let query = Mastodon.API.App.CreateQuery( - clientName: "XCTest", - website: nil - ) - Mastodon.API.App.create(session: session, domain: domain, query: query) - .receive(on: DispatchQueue.main) - .sink { completion in - switch completion { - case .failure(let error): - XCTFail(error.localizedDescription) - case .finished: - break - } - } receiveValue: { response in - XCTAssertEqual(response.value.name, "XCTest") - XCTAssertEqual(response.value.website, nil) - XCTAssertEqual(response.value.redirectURI, "urn:ietf:wg:oauth:2.0:oob") - theExpectation.fulfill() - } - .store(in: &disposeBag) - - wait(for: [theExpectation], timeout: 10.0) - } -} - -extension MastodonSDKTests { - - func testPublicTimeline_mstdn() throws { - try _testPublicTimeline(domain: mstdnDomain) - } - - func testPublicTimeline_pawoo() throws { - try _testPublicTimeline(domain: pawooDomain) + func testPublicTimeline() throws { + try _testPublicTimeline(domain: domain) } private func _testPublicTimeline(domain: String) throws { From 0f8ad0c4443b824125087de3a3a8bc423ed1e988 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Feb 2021 15:38:54 +0800 Subject: [PATCH 04/10] feat: add authentication scene --- Mastodon.xcodeproj/project.pbxproj | 4 ++ Mastodon/Coordinator/SceneCoordinator.swift | 10 ++- .../AuthenticationViewController.swift | 70 +++++++++++++++++++ .../AuthenticationViewModel.swift | 48 +++++++++++++ Mastodon/Scene/HomeViewController.swift | 1 - Mastodon/Supporting Files/SceneDelegate.swift | 7 ++ 6 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 Mastodon/Scene/Authentication/AuthenticationViewModel.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 8841f7ee3..94f6e02ef 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ DB8AF55725C137A8002E6C99 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55625C137A8002E6C99 /* HomeViewController.swift */; }; DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; + DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -161,6 +162,7 @@ DB8AF55625C137A8002E6C99 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = ""; }; DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; + DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = ""; }; @@ -241,6 +243,7 @@ children = ( DB0140A625C40C0900F9F3CF /* PinBased */, DB01409525C40B6700F9F3CF /* AuthenticationViewController.swift */, + DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */, ); path = Authentication; sourceTree = ""; @@ -791,6 +794,7 @@ DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, + DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 600846d4c..a8d3dbb8a 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -37,7 +37,7 @@ extension SceneCoordinator { } enum Scene { - + case authentication(viewModel: AuthenticationViewModel) } } @@ -108,8 +108,12 @@ private extension SceneCoordinator { func get(scene: Scene) -> UIViewController? { let viewController: UIViewController? - // TODO: - viewController = nil + switch scene { + case .authentication(let viewModel): + let _viewController = AuthenticationViewController() + _viewController.viewModel = viewModel + viewController = _viewController + } setupDependency(for: viewController as? NeedsDependency) diff --git a/Mastodon/Scene/Authentication/AuthenticationViewController.swift b/Mastodon/Scene/Authentication/AuthenticationViewController.swift index 12e1d14d5..bb66b1e63 100644 --- a/Mastodon/Scene/Authentication/AuthenticationViewController.swift +++ b/Mastodon/Scene/Authentication/AuthenticationViewController.swift @@ -5,6 +5,76 @@ // Created by MainasuK Cirno on 2021/1/29. // +import os.log import UIKit +import Combine +final class AuthenticationViewController: UIViewController, NeedsDependency { + + var disposeBag = Set() + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: AuthenticationViewModel! + + let domainTextField: UITextField = { + let textField = UITextField() + textField.placeholder = "example.com" + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.keyboardType = .URL + return textField + }() + + private(set) lazy var signInBarButtonItem = UIBarButtonItem(title: "Sign In", style: .plain, target: self, action: #selector(AuthenticationViewController.signInBarButtonItemPressed(_:))) + +} +extension AuthenticationViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Authentication" + view.backgroundColor = .systemBackground + navigationItem.rightBarButtonItem = signInBarButtonItem + + domainTextField.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(domainTextField) + NSLayoutConstraint.activate([ + domainTextField.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 8), + domainTextField.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor, constant: 8), + domainTextField.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor, constant: 8), + domainTextField.heightAnchor.constraint(equalToConstant: 44), // FIXME: + ]) + + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: domainTextField) + .compactMap { notification in + guard let textField = notification.object as? UITextField? else { return nil } + return textField?.text ?? "" + } + .assign(to: \.value, on: viewModel.input) + .store(in: &disposeBag) + + viewModel.isSignInButtonEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: signInBarButtonItem) + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + domainTextField.becomeFirstResponder() + } + +} + +extension AuthenticationViewController { + + @objc private func signInBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/Authentication/AuthenticationViewModel.swift b/Mastodon/Scene/Authentication/AuthenticationViewModel.swift new file mode 100644 index 000000000..4d0e21f33 --- /dev/null +++ b/Mastodon/Scene/Authentication/AuthenticationViewModel.swift @@ -0,0 +1,48 @@ +// +// AuthenticationViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/1. +// + +import Foundation +import Combine + +final class AuthenticationViewModel { + + var disposeBag = Set() + + // input + let input = CurrentValueSubject("") + + // output + let domain = CurrentValueSubject(nil) + let isSignInButtonEnabled = CurrentValueSubject(false) + + init() { + input + .map { input in + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return nil } + + let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed + guard let url = URL(string: urlString), + let host = url.host else { + return nil + } + let components = host.components(separatedBy: ".") + guard (components.filter { !$0.isEmpty }).count >= 2 else { return nil } + + return host + } + .assign(to: \.value, on: domain) + .store(in: &disposeBag) + + domain + .print() + .map { $0 != nil } + .assign(to: \.value, on: isSignInButtonEnabled) + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/HomeViewController.swift b/Mastodon/Scene/HomeViewController.swift index 6a533558d..0d0f8cc96 100644 --- a/Mastodon/Scene/HomeViewController.swift +++ b/Mastodon/Scene/HomeViewController.swift @@ -14,7 +14,6 @@ final class HomeViewController: UIViewController, NeedsDependency { } - extension HomeViewController { override func viewDidLoad() { diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index ed7a36994..2cddc5873 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -24,6 +24,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { self.coordinator = sceneCoordinator sceneCoordinator.setup() + + #if DEBUG + DispatchQueue.main.async { + let authenticationViewModel = AuthenticationViewModel() + sceneCoordinator.present(scene: .authentication(viewModel: authenticationViewModel), from: nil, transition: .modal(animated: false, completion: nil)) + } + #endif window.makeKeyAndVisible() } From b749d0a7bcb91e217103ee8eebb2a51c9b8408ce Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Feb 2021 16:04:39 +0800 Subject: [PATCH 05/10] fix: Toot.emojis wrong keyPath issue --- CoreDataStack/Entity/Toot.swift | 11 ++++++----- .../Persist/APIService+Persist+Timeline.swift | 14 +++++++++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index 59ef034dd..a0908d9de 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -93,7 +93,7 @@ public extension Toot { } if let emojis = property.emojis { - toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: emojis) + toot.mutableSetValue(forKey: #keyPath(Toot.emojis)).addObjects(from: emojis) } if let tags = property.tags { @@ -123,10 +123,11 @@ public extension Toot { if let bookmarkedBy = property.bookmarkedBy { toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy) } - if let pinnedBy = property.pinnedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.pinnedBy)) - } - + + // TODO: not implement yet + // if let pinnedBy = property.pinnedBy { + // toot.mutableSetValue(forKey: #keyPath(Toot.pinnedBy)) + // } toot.updatedAt = property.updatedAt toot.deletedAt = property.deletedAt diff --git a/Mastodon/Service/Persist/APIService+Persist+Timeline.swift b/Mastodon/Service/Persist/APIService+Persist+Timeline.swift index 2fc645a88..d3e9ed5eb 100644 --- a/Mastodon/Service/Persist/APIService+Persist+Timeline.swift +++ b/Mastodon/Service/Persist/APIService+Persist+Timeline.swift @@ -72,6 +72,18 @@ extension APIService.Persist { homeTimelineIndexes: nil) Toot.insert(into: managedObjectContext, property: tootProperty, author: author) } - }.eraseToAnyPublisher() + } + .handleEvents(receiveOutput: { result in + switch result { + case .success: + break + case .failure(let error): + #if DEBUG + debugPrint(error) + #endif + assertionFailure(error.localizedDescription) + } + }) + .eraseToAnyPublisher() } } From 36c18071820f78542940b794ffc543e485f4275e Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Feb 2021 19:31:10 +0800 Subject: [PATCH 06/10] feat: [WIP] add authentication scene --- Mastodon.xcodeproj/project.pbxproj | 129 +++++++++------- Mastodon/Coordinator/SceneCoordinator.swift | 5 + .../AuthenticationViewController.swift | 14 ++ .../AuthenticationViewModel.swift | 144 +++++++++++++++++- ...PinBasedAuthenticationViewController.swift | 62 +++++++- ...todonPinBasedAuthenticationViewModel.swift | 28 ++++ ...ationViewModelNavigationDelegateShim.swift | 9 +- Mastodon/Service/APIService+APIError.swift | 29 ++++ Mastodon/Service/APIService+Account.swift | 24 +++ Mastodon/Service/APIService+App.swift | 32 ++++ .../Service/APIService+Authentication.swift | 35 +++++ .../Service/APIService+PublicTimeline.swift | 3 +- Mastodon/Supporting Files/SceneDelegate.swift | 2 +- .../API/Mastodon+API+Account.swift | 34 +++++ .../MastodonSDK/API/Mastodon+API+OAuth.swift | 101 +++++++++--- .../MastodonSDK/API/Mastodon+API.swift | 33 +++- .../Entity/Mastodon+Entity+Token.swift | 7 + 17 files changed, 600 insertions(+), 91 deletions(-) create mode 100644 Mastodon/Service/APIService+APIError.swift create mode 100644 Mastodon/Service/APIService+Account.swift create mode 100644 Mastodon/Service/APIService+App.swift create mode 100644 Mastodon/Service/APIService+Authentication.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index f3328cbf0..1c9c6c724 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -11,7 +11,7 @@ 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; 2D152A8C25C295CC009AA50C /* TimelinePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* TimelinePostView.swift */; }; 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; - 2D42FF6125C8177C004A627A /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* SwiftPackageProductDependency */; }; + 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* ActiveLabel */; }; 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* MastodonContent.swift */; }; 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; }; @@ -20,7 +20,7 @@ 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.swift */; }; 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; - 2D61336925C18A4F00CAE157 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* SwiftPackageProductDependency */; }; + 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; }; 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; }; @@ -35,7 +35,7 @@ 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; 3533495136D843E85211E3E2 /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1B4523A7981F1044DE89C21 /* Pods_Mastodon_MastodonUITests.framework */; }; 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; - 5D526FE225BE9AC400460CB9 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* SwiftPackageProductDependency */; }; + 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; 7A9135D4559750AF07CA9BE4 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 602D783BEC22881EBAD84419 /* Pods_Mastodon.framework */; }; @@ -43,12 +43,10 @@ DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; }; DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; - DB0140BD25C40D7500F9F3CF /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* SwiftPackageProductDependency */; }; + DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; }; DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; - DB3D0FF325BAA61700EAA174 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* SwiftPackageProductDependency */; }; + DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; - DB3D102425BAA7B400EAA174 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3D102225BAA7B400EAA174 /* Assets.swift */; }; - DB3D102525BAA7B400EAA174 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3D102325BAA7B400EAA174 /* Strings.swift */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD725BAA00100D1B89D /* SceneDelegate.swift */; }; DB427DDD25BAA00100D1B89D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDB25BAA00100D1B89D /* Main.storyboard */; }; @@ -80,6 +78,12 @@ DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */; }; + DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; }; + DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; }; + DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; + DB98338725C945ED00AD9700 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338525C945ED00AD9700 /* Strings.swift */; }; + DB98338825C945ED00AD9700 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338625C945ED00AD9700 /* Assets.swift */; }; + DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98339B25C96DE600AD9700 /* APIService+Account.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -176,8 +180,6 @@ DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - DB3D102225BAA7B400EAA174 /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; - DB3D102325BAA7B400EAA174 /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; DB427DD525BAA00100D1B89D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; DB427DD725BAA00100D1B89D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -217,6 +219,12 @@ DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; + DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = ""; }; + DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = ""; }; + DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = ""; }; + DB98338525C945ED00AD9700 /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; + DB98338625C945ED00AD9700 /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; + DB98339B25C96DE600AD9700 /* APIService+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Account.swift"; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = ""; }; @@ -227,13 +235,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DB0140BD25C40D7500F9F3CF /* BuildFile in Frameworks */, + DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, - 2D42FF6125C8177C004A627A /* BuildFile in Frameworks */, - 5D526FE225BE9AC400460CB9 /* BuildFile in Frameworks */, - 2D61336925C18A4F00CAE157 /* BuildFile in Frameworks */, + 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, + 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, + 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, 7A9135D4559750AF07CA9BE4 /* Pods_Mastodon.framework in Frameworks */, - DB3D0FF325BAA61700EAA174 /* BuildFile in Frameworks */, + DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -315,6 +323,10 @@ isa = PBXGroup; children = ( 2D61335D25C1894B00CAE157 /* APIService.swift */, + DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, + DB98336A25C9420100AD9700 /* APIService+App.swift */, + DB98337025C9443200AD9700 /* APIService+Authentication.swift */, + DB98339B25C96DE600AD9700 /* APIService+Account.swift */, 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, 2D61335625C1887F00CAE157 /* Persist */, ); @@ -444,15 +456,6 @@ path = Resources; sourceTree = ""; }; - DB3D101B25BAA79200EAA174 /* Generated */ = { - isa = PBXGroup; - children = ( - DB3D102225BAA7B400EAA174 /* Assets.swift */, - DB3D102325BAA7B400EAA174 /* Strings.swift */, - ); - path = Generated; - sourceTree = ""; - }; DB427DC925BAA00100D1B89D = { isa = PBXGroup; children = ( @@ -467,6 +470,7 @@ DB427DD325BAA00100D1B89D /* Products */, 1EBA4F56E920856A3FC84ACB /* Pods */, 3FE14AD363ED19AE7FF210A6 /* Frameworks */, + DB98335F25C93B0400AD9700 /* Recovered References */, ); sourceTree = ""; }; @@ -485,15 +489,15 @@ DB427DD425BAA00100D1B89D /* Mastodon */ = { isa = PBXGroup; children = ( - DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, DB427DE325BAA00100D1B89D /* Info.plist */, + DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, 2D76319C25C151DE00929FB9 /* Diffiable */, DB8AF52A25C13561002E6C99 /* State */, 2D61335525C1886800CAE157 /* Service */, - DB8AF56225C138BC002E6C99 /* Extension */, DB8AF55525C1379F002E6C99 /* Scene */, DB8AF54125C13647002E6C99 /* Coordinator */, - DB3D101B25BAA79200EAA174 /* Generated */, + DB8AF56225C138BC002E6C99 /* Extension */, + DB98338425C945ED00AD9700 /* Generated */, DB3D0FF825BAA6B200EAA174 /* Resources */, DB3D0FF725BAA68500EAA174 /* Supporting Files */, ); @@ -578,9 +582,9 @@ DB8AF52A25C13561002E6C99 /* State */ = { isa = PBXGroup; children = ( + DB8AF52D25C13561002E6C99 /* AppContext.swift */, DB8AF52B25C13561002E6C99 /* ViewStateStore.swift */, DB8AF52C25C13561002E6C99 /* DocumentStore.swift */, - DB8AF52D25C13561002E6C99 /* AppContext.swift */, ); path = State; sourceTree = ""; @@ -606,10 +610,10 @@ isa = PBXGroup; children = ( 2D7631A425C1532200929FB9 /* Share */, + DB01409B25C40BB600F9F3CF /* Authentication */, 2D76316325C14BAC00929FB9 /* PublicTimeline */, DB8AF54E25C13703002E6C99 /* MainTab */, DB8AF55625C137A8002E6C99 /* HomeViewController.swift */, - DB01409B25C40BB600F9F3CF /* Authentication */, ); path = Scene; sourceTree = ""; @@ -628,6 +632,23 @@ path = Extension; sourceTree = ""; }; + DB98335F25C93B0400AD9700 /* Recovered References */ = { + isa = PBXGroup; + children = ( + CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */, + ); + name = "Recovered References"; + sourceTree = ""; + }; + DB98338425C945ED00AD9700 /* Generated */ = { + isa = PBXGroup; + children = ( + DB98338525C945ED00AD9700 /* Strings.swift */, + DB98338625C945ED00AD9700 /* Assets.swift */, + ); + path = Generated; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -661,11 +682,11 @@ ); name = Mastodon; packageProductDependencies = ( - DB3D0FF225BAA61700EAA174 /* SwiftPackageProductDependency */, - 5D526FE125BE9AC400460CB9 /* SwiftPackageProductDependency */, - 2D61336825C18A4F00CAE157 /* SwiftPackageProductDependency */, - 2D42FF6025C8177C004A627A /* SwiftPackageProductDependency */, - DB0140BC25C40D7500F9F3CF /* SwiftPackageProductDependency */, + DB3D0FF225BAA61700EAA174 /* AlamofireImage */, + 5D526FE125BE9AC400460CB9 /* MastodonSDK */, + 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */, + 2D42FF6025C8177C004A627A /* ActiveLabel */, + DB0140BC25C40D7500F9F3CF /* CommonOSLog */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -788,10 +809,10 @@ ); mainGroup = DB427DC925BAA00100D1B89D; packageReferences = ( - DB3D0FF125BAA61700EAA174 /* RemoteSwiftPackageReference */, - 2D61336725C18A4F00CAE157 /* RemoteSwiftPackageReference */, - 2D42FF5F25C8177C004A627A /* RemoteSwiftPackageReference */, - DB0140BB25C40D7500F9F3CF /* RemoteSwiftPackageReference */, + DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */, + 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */, + 2D42FF5F25C8177C004A627A /* XCRemoteSwiftPackageReference "ActiveLabel" */, + DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -973,6 +994,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, @@ -980,7 +1002,9 @@ 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */, + DB98338825C945ED00AD9700 /* Assets.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, + DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, @@ -995,18 +1019,19 @@ 2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, + DB98338725C945ED00AD9700 /* Strings.swift in Sources */, + DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, + DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, DB8AF55725C137A8002E6C99 /* HomeViewController.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, 2D7631A825C1535600929FB9 /* TimelinePostTableViewCell.swift in Sources */, - DB3D102525BAA7B400EAA174 /* Strings.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */, - DB3D102425BAA7B400EAA174 /* Assets.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, ); @@ -1518,7 +1543,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 2D42FF5F25C8177C004A627A /* RemoteSwiftPackageReference */ = { + 2D42FF5F25C8177C004A627A /* XCRemoteSwiftPackageReference "ActiveLabel" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/TwidereProject/ActiveLabel.swift"; requirement = { @@ -1526,7 +1551,7 @@ minimumVersion = 3.0.0; }; }; - 2D61336725C18A4F00CAE157 /* RemoteSwiftPackageReference */ = { + 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Alamofire/AlamofireNetworkActivityIndicator"; requirement = { @@ -1534,7 +1559,7 @@ minimumVersion = 3.1.0; }; }; - DB0140BB25C40D7500F9F3CF /* RemoteSwiftPackageReference */ = { + DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/CommonOSLog"; requirement = { @@ -1542,7 +1567,7 @@ minimumVersion = 0.1.1; }; }; - DB3D0FF125BAA61700EAA174 /* RemoteSwiftPackageReference */ = { + DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Alamofire/AlamofireImage.git"; requirement = { @@ -1553,28 +1578,28 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 2D42FF6025C8177C004A627A /* SwiftPackageProductDependency */ = { + 2D42FF6025C8177C004A627A /* ActiveLabel */ = { isa = XCSwiftPackageProductDependency; - package = 2D42FF5F25C8177C004A627A /* RemoteSwiftPackageReference */; + package = 2D42FF5F25C8177C004A627A /* XCRemoteSwiftPackageReference "ActiveLabel" */; productName = ActiveLabel; }; - 2D61336825C18A4F00CAE157 /* SwiftPackageProductDependency */ = { + 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */ = { isa = XCSwiftPackageProductDependency; - package = 2D61336725C18A4F00CAE157 /* RemoteSwiftPackageReference */; + package = 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */; productName = AlamofireNetworkActivityIndicator; }; - 5D526FE125BE9AC400460CB9 /* SwiftPackageProductDependency */ = { + 5D526FE125BE9AC400460CB9 /* MastodonSDK */ = { isa = XCSwiftPackageProductDependency; productName = MastodonSDK; }; - DB0140BC25C40D7500F9F3CF /* SwiftPackageProductDependency */ = { + DB0140BC25C40D7500F9F3CF /* CommonOSLog */ = { isa = XCSwiftPackageProductDependency; - package = DB0140BB25C40D7500F9F3CF /* RemoteSwiftPackageReference */; + package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; productName = CommonOSLog; }; - DB3D0FF225BAA61700EAA174 /* SwiftPackageProductDependency */ = { + DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = { isa = XCSwiftPackageProductDependency; - package = DB3D0FF125BAA61700EAA174 /* RemoteSwiftPackageReference */; + package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; productName = AlamofireImage; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index a8d3dbb8a..0c8388901 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -38,6 +38,7 @@ extension SceneCoordinator { enum Scene { case authentication(viewModel: AuthenticationViewModel) + case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel) } } @@ -113,6 +114,10 @@ private extension SceneCoordinator { let _viewController = AuthenticationViewController() _viewController.viewModel = viewModel viewController = _viewController + case .mastodonPinBasedAuthentication(let viewModel): + let _viewController = MastodonPinBasedAuthenticationViewController() + _viewController.viewModel = viewModel + viewController = _viewController } setupDependency(for: viewController as? NeedsDependency) diff --git a/Mastodon/Scene/Authentication/AuthenticationViewController.swift b/Mastodon/Scene/Authentication/AuthenticationViewController.swift index bb66b1e63..dadad28a0 100644 --- a/Mastodon/Scene/Authentication/AuthenticationViewController.swift +++ b/Mastodon/Scene/Authentication/AuthenticationViewController.swift @@ -8,6 +8,7 @@ import os.log import UIKit import Combine +import MastodonSDK final class AuthenticationViewController: UIViewController, NeedsDependency { @@ -17,6 +18,7 @@ final class AuthenticationViewController: UIViewController, NeedsDependency { weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var viewModel: AuthenticationViewModel! + var mastodonPinBasedAuthenticationViewController: UIViewController? let domainTextField: UITextField = { let textField = UITextField() @@ -75,6 +77,18 @@ extension AuthenticationViewController { @objc private func signInBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard let domain = viewModel.domain.value else { + // TODO: alert error + return + } + viewModel.signInAction.send(domain) } } + +// MARK: - UIAdaptivePresentationControllerDelegate +extension AuthenticationViewController: UIAdaptivePresentationControllerDelegate { + func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return .fullScreen + } +} diff --git a/Mastodon/Scene/Authentication/AuthenticationViewModel.swift b/Mastodon/Scene/Authentication/AuthenticationViewModel.swift index 4d0e21f33..264ed487c 100644 --- a/Mastodon/Scene/Authentication/AuthenticationViewModel.swift +++ b/Mastodon/Scene/Authentication/AuthenticationViewModel.swift @@ -5,21 +5,34 @@ // Created by MainasuK Cirno on 2021/2/1. // -import Foundation +import os.log +import UIKit import Combine +import MastodonSDK final class AuthenticationViewModel { var disposeBag = Set() // input + let context: AppContext + let coordinator: SceneCoordinator let input = CurrentValueSubject("") + let signInAction = PassthroughSubject() // output let domain = CurrentValueSubject(nil) let isSignInButtonEnabled = CurrentValueSubject(false) + let isAuthenticating = CurrentValueSubject(false) + let authenticated = PassthroughSubject() + let error = CurrentValueSubject(nil) - init() { + private var mastodonPinBasedAuthenticationViewController: UIViewController? + + init(context: AppContext, coordinator: SceneCoordinator) { + self.context = context + self.coordinator = coordinator + input .map { input in let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -39,10 +52,135 @@ final class AuthenticationViewModel { .store(in: &disposeBag) domain - .print() .map { $0 != nil } .assign(to: \.value, on: isSignInButtonEnabled) .store(in: &disposeBag) + + signInAction + .handleEvents(receiveOutput: { [weak self] _ in + // trigger state change + guard let self = self else { return } + self.isAuthenticating.value = true + }) + .flatMap { domain in + context.apiService.createApplication(domain: domain) + .retry(3) + .tryMap { response -> AuthenticateInfo in + let application = response.value + guard let clientID = application.clientID, + let clientSecret = application.clientSecret else { + throw APIService.APIError.explicit(.badResponse) + } + let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID) + let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query) + return AuthenticateInfo( + domain: domain, + clientID: clientID, + clientSecret: clientSecret, + url: url + ) + } + } + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + // trigger state update + self.isAuthenticating.value = false + + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + self.error.value = error + case .finished: + break + } + } receiveValue: { [weak self] info in + guard let self = self else { return } + let mastodonPinBasedAuthenticationViewModel = MastodonPinBasedAuthenticationViewModel(authenticateURL: info.url) + self.authenticate( + info: info, + pinCodePublisher: mastodonPinBasedAuthenticationViewModel.pinCodePublisher + ) + self.mastodonPinBasedAuthenticationViewController = self.coordinator.present( + scene: .mastodonPinBasedAuthentication(viewModel: mastodonPinBasedAuthenticationViewModel), + from: nil, + transition: .modal(animated: true, completion: nil) + ) + } + .store(in: &disposeBag) + } + +} + +extension AuthenticationViewModel { + + struct AuthenticateInfo { + let domain: String + let clientID: String + let clientSecret: String + let url: URL + } + + func authenticate(info: AuthenticateInfo, pinCodePublisher: PassthroughSubject) { + pinCodePublisher + .handleEvents(receiveOutput: { [weak self] _ in + guard let self = self else { return } + self.isAuthenticating.value = true + self.mastodonPinBasedAuthenticationViewController?.dismiss(animated: true, completion: nil) + self.mastodonPinBasedAuthenticationViewController = nil + }) + .compactMap { [weak self] code -> AnyPublisher, Error>? in + guard let self = self else { return nil } + return self.context.apiService + .userAccessToken( + domain: info.domain, + clientID: info.clientID, + clientSecret: info.clientSecret, + code: code + ) + .flatMap { response -> AnyPublisher, Error> in + let token = response.value + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in success. Token: %s", ((#file as NSString).lastPathComponent), #line, #function, token.accessToken) + return AuthenticationViewModel.verifyAndSaveAuthentication( + context: self.context, + info: info, + token: token + ) + } + .eraseToAnyPublisher() + } + .switchToLatest() + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + self.isAuthenticating.value = false + self.error.value = error + case .finished: + break + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + let account = response.value + // TODO: + } + .store(in: &self.disposeBag) + } + + static func verifyAndSaveAuthentication( + context: AppContext, + info: AuthenticateInfo, + token: Mastodon.Entity.Token + ) -> AnyPublisher, Error> { + let authorization = Mastodon.API.OAuth.Authorization(accessToken: token.accessToken) + return context.apiService.accountVerifyCredentials( + domain: info.domain, + authorization: authorization + ) + // TODO: add persist logic } } diff --git a/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewController.swift b/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewController.swift index 6f9ce506f..fa57ddfd4 100644 --- a/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewController.swift +++ b/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewController.swift @@ -6,20 +6,70 @@ // import os.log -import Foundation +import UIKit +import Combine import WebKit -final class MastodonPinBasedAuthenticationViewController: NSObject { +final class MastodonPinBasedAuthenticationViewController: UIViewController, NeedsDependency { + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - weak var viewModel: MastodonPinBasedAuthenticationViewModel? + var disposeBag = Set() + var viewModel: MastodonPinBasedAuthenticationViewModel! - init(viewModel: MastodonPinBasedAuthenticationViewModel) { - self.viewModel = viewModel - } + let webView: WKWebView = { + let configuration = WKWebViewConfiguration() + configuration.processPool = WKProcessPool() + let webView = WKWebView(frame: .zero, configuration: configuration) + return webView + }() deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + // cleanup cookie + let httpCookieStore = webView.configuration.websiteDataStore.httpCookieStore + httpCookieStore.getAllCookies { cookies in + for cookie in cookies { + httpCookieStore.delete(cookie, completionHandler: nil) + } + } + } + +} + + + +extension MastodonPinBasedAuthenticationViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Authentication" + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(MastodonPinBasedAuthenticationViewController.cancelBarButtonItemPressed(_:))) + + webView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(webView) + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + let request = URLRequest(url: viewModel.authenticateURL) + webView.navigationDelegate = viewModel.navigationDelegate + webView.load(request) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: authenticate via: %s", ((#file as NSString).lastPathComponent), #line, #function, viewModel.authenticateURL.debugDescription) + } + +} + +extension MastodonPinBasedAuthenticationViewController { + + @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { + dismiss(animated: true, completion: nil) } } diff --git a/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModel.swift b/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModel.swift index 4ae5e14a4..5eac359eb 100644 --- a/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModel.swift +++ b/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModel.swift @@ -5,8 +5,36 @@ // Created by MainasuK Cirno on 2021/1/29. // +import os.log import Foundation +import Combine +import WebKit final class MastodonPinBasedAuthenticationViewModel { + // input + let authenticateURL: URL + + // output + let pinCodePublisher = PassthroughSubject() + private var navigationDelegateShim: MastodonPinBasedAuthenticationViewModelNavigationDelegateShim? + + init(authenticateURL: URL) { + self.authenticateURL = authenticateURL + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension MastodonPinBasedAuthenticationViewModel { + + var navigationDelegate: WKNavigationDelegate { + let navigationDelegateShim = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim(viewModel: self) + self.navigationDelegateShim = navigationDelegateShim + return navigationDelegateShim + } + } diff --git a/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift b/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift index 3f7092b1f..dd8901721 100644 --- a/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift +++ b/Mastodon/Scene/Authentication/PinBased/MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift @@ -27,7 +27,14 @@ final class MastodonPinBasedAuthenticationViewModelNavigationDelegateShim: NSObj extension MastodonPinBasedAuthenticationViewModelNavigationDelegateShim: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - // TODO: + guard let url = webView.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let codeQueryItem = components.queryItems?.first(where: { $0.name == "code" }), + let code = codeQueryItem.value else { + return + } + + viewModel?.pinCodePublisher.send(code) } } diff --git a/Mastodon/Service/APIService+APIError.swift b/Mastodon/Service/APIService+APIError.swift new file mode 100644 index 000000000..2bb56cf2c --- /dev/null +++ b/Mastodon/Service/APIService+APIError.swift @@ -0,0 +1,29 @@ +// +// APIService+Error.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-2-2. +// + +import UIKit +import MastodonSDK + +extension APIService { + enum APIError: Error { + + case implicit(ErrorReason) + case explicit(ErrorReason) + + enum ErrorReason { + // application internal error + case authenticationMissing + case badRequest + case badResponse + case requestThrottle + + // Server API error + case mastodonAPIError(Mastodon.API.Error) + } + + } +} diff --git a/Mastodon/Service/APIService+Account.swift b/Mastodon/Service/APIService+Account.swift new file mode 100644 index 000000000..1a9ad08b5 --- /dev/null +++ b/Mastodon/Service/APIService+Account.swift @@ -0,0 +1,24 @@ +// +// APIService+Account.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/2. +// + +import Foundation +import Combine +import MastodonSDK + +extension APIService { + + func accountVerifyCredentials( + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + return Mastodon.API.Account.verifyCredentials( + session: session, + domain: domain, + authorization: authorization + ) + } +} diff --git a/Mastodon/Service/APIService+App.swift b/Mastodon/Service/APIService+App.swift new file mode 100644 index 000000000..9726a6ee6 --- /dev/null +++ b/Mastodon/Service/APIService+App.swift @@ -0,0 +1,32 @@ +// +// APIService+App.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/2. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService { + + #if DEBUG + private static let clientName = "Skimming" + #else + private static let clientName = "Mastodon for iOS" + #endif + + func createApplication(domain: String) -> AnyPublisher, Error> { + let query = Mastodon.API.App.CreateQuery(clientName: APIService.clientName, website: nil) + return Mastodon.API.App.create( + session: session, + domain: domain, + query: query + ) + } + +} + diff --git a/Mastodon/Service/APIService+Authentication.swift b/Mastodon/Service/APIService+Authentication.swift new file mode 100644 index 000000000..486a27c44 --- /dev/null +++ b/Mastodon/Service/APIService+Authentication.swift @@ -0,0 +1,35 @@ +// +// APIService+Authentication.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/2. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService { + + func userAccessToken( + domain: String, + clientID: String, + clientSecret: String, + code: String + ) -> AnyPublisher, Error> { + let query = Mastodon.API.OAuth.AccessTokenQuery( + clientID: clientID, + clientSecret: clientSecret, + code: code, + grantType: "authorization_code" + ) + return Mastodon.API.OAuth.accessToken( + session: session, + domain: domain, + query: query + ) + } + +} diff --git a/Mastodon/Service/APIService+PublicTimeline.swift b/Mastodon/Service/APIService+PublicTimeline.swift index 6b73409cf..3e75d2461 100644 --- a/Mastodon/Service/APIService+PublicTimeline.swift +++ b/Mastodon/Service/APIService+PublicTimeline.swift @@ -26,7 +26,7 @@ extension APIService { domain: domain, query: Mastodon.API.Timeline.PublicTimelineQuery() ) - .flatMap { response -> AnyPublisher,Error> in + .flatMap { response -> AnyPublisher, Error> in return APIService.Persist.persistTimeline( domain: domain, managedObjectContext: self.backgroundManagedObjectContext, @@ -46,4 +46,5 @@ extension APIService { } .eraseToAnyPublisher() } + } diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 2cddc5873..6aeb17d17 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -27,7 +27,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { #if DEBUG DispatchQueue.main.async { - let authenticationViewModel = AuthenticationViewModel() + let authenticationViewModel = AuthenticationViewModel(context: appContext, coordinator: sceneCoordinator) sceneCoordinator.present(scene: .authentication(viewModel: authenticationViewModel), from: nil, transition: .modal(animated: false, completion: nil)) } #endif diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift new file mode 100644 index 000000000..4929b0bb9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -0,0 +1,34 @@ +// +// Mastodon+API+Account.swift +// +// +// Created by MainasuK Cirno on 2021/2/2. +// + +import Foundation +import Combine + +extension Mastodon.API.Account { + + static func verifyCredentialsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain) + } + + public static func verifyCredentials( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: verifyCredentialsEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift index c25526ff6..fc64c10d0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift @@ -14,6 +14,10 @@ extension Mastodon.API.OAuth { public struct Authorization { public let accessToken: String + + public init(accessToken: String) { + self.accessToken = accessToken + } } } @@ -23,6 +27,9 @@ extension Mastodon.API.OAuth { static func authorizeEndpointURL(domain: String) -> URL { return Mastodon.API.oauthEndpointURL(domain: domain).appendingPathComponent("authorize") } + static func accessTokenEndpointURL(domain: String) -> URL { + return Mastodon.API.oauthEndpointURL(domain: domain).appendingPathComponent("token") + } /// Construct user authorize endpoint URL /// @@ -38,7 +45,7 @@ extension Mastodon.API.OAuth { /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - query: `AuthorizeQuery` - static func authorizeURL( + public static func authorizeURL( domain: String, query: AuthorizeQuery ) -> URL { @@ -51,27 +58,41 @@ extension Mastodon.API.OAuth { return url } -// static func authorize( -// session: URLSession, -// domain: String, -// query: AuthorizeQuery -// ) -> AnyPublisher, Error> { -// let request = Mastodon.API.post( -// url: authorizeEndpointURL(domain: domain), -// query: query, -// authorization: nil -// ) -// return session.dataTaskPublisher(for: request) -// .tryMap { data, response in -// let value = try Mastodon.API.decode(type: Mastodon.Entity.Token.self, from: data, response: response) -// return Mastodon.Response.Content(value: value, response: response) -// } -// .eraseToAnyPublisher() -// } + /// Obtain User Access Token + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/2/2 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/apps/oauth/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `AccessTokenQuery` + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func accessToken( + session: URLSession, + domain: String, + query: AccessTokenQuery + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: accessTokenEndpointURL(domain: domain), + query: query, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Token.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } } extension Mastodon.API.OAuth { + public struct AuthorizeQuery: GetQuery { public let forceLogin: String? @@ -106,7 +127,7 @@ extension Mastodon.API.OAuth { var items: [URLQueryItem] = [] forceLogin.flatMap { items.append(URLQueryItem(name: "force_login", value: $0)) } items.append(URLQueryItem(name: "response_type", value: responseType)) - items.append(URLQueryItem(name: "clientID", value: clientID)) + items.append(URLQueryItem(name: "client_id", value: clientID)) items.append(URLQueryItem(name: "redirect_uri", value: redirectURI)) scope.flatMap { items.append(URLQueryItem(name: "scope", value: $0)) } guard !items.isEmpty else { return nil } @@ -114,4 +135,46 @@ extension Mastodon.API.OAuth { } } + + public struct AccessTokenQuery: Codable, PostQuery { + public init( + clientID: String, + clientSecret: String, + redirectURI: String = "urn:ietf:wg:oauth:2.0:oob", + scope: String? = "read write follow push", + code: String?, + grantType: String + ) { + self.clientID = clientID + self.clientSecret = clientSecret + self.redirectURI = redirectURI + self.scope = scope + self.code = code + self.grantType = grantType + } + + + public let clientID: String + public let clientSecret: String + public let redirectURI: String + public let scope: String? + public let code: String? + public let grantType: String + + enum CodingKeys: String, CodingKey { + case clientID = "client_id" + case clientSecret = "client_secret" + case redirectURI = "redirect_uri" + case scope + case code + case grantType = "grant_type" + + } + + var body: Data? { + return try? Mastodon.API.encoder.encode(self) + } + + } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index fcab0eb3e..283d6e1d5 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -37,17 +37,33 @@ extension Mastodon.API { let decoder = JSONDecoder() decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.custom { decoder throws -> Date in let container = try decoder.singleValueContainer() - let string = try container.decode(String.self) - - if let date = fractionalSecondsPreciseISO8601Formatter.date(from: string) { - return date - } - if let date = fullDatePreciseISO8601Formatter.date(from: string) { - return date + var logInfo = "" + do { + let string = try container.decode(String.self) + logInfo += string + + if let date = fractionalSecondsPreciseISO8601Formatter.date(from: string) { + return date + } + if let date = fullDatePreciseISO8601Formatter.date(from: string) { + return date + } + } catch { + // do nothing } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)") + var numberValue = "" + do { + let number = try container.decode(Double.self) + logInfo += "\(number)" + + return Date(timeIntervalSince1970: number) + } catch { + // do nothing + } + + throw DecodingError.dataCorruptedError(in: container, debugDescription: "[Decoder] Invalid date: \(logInfo)") } return decoder @@ -66,6 +82,7 @@ extension Mastodon.API { } extension Mastodon.API { + public enum Account { } public enum App { } public enum OAuth { } public enum Timeline { } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Token.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Token.swift index 5f9d273c4..e7f18b518 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Token.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Token.swift @@ -21,5 +21,12 @@ extension Mastodon.Entity { public let tokenType: String public let scope: String public let createdAt: Date + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + case scope + case createdAt = "created_at" + } } } From 2c6a0e383aebea535bea55a9b1babdcf85429587 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 3 Feb 2021 16:01:08 +0800 Subject: [PATCH 07/10] feat: implement authentication scene --- .../CoreData.xcdatamodel/contents | 20 ++- .../Entity/MastodonAuthentication.swift | 161 ++++++++++++++++++ CoreDataStack/Entity/MastodonUser.swift | 86 +++++++++- Mastodon.xcodeproj/project.pbxproj | 58 ++++++- Mastodon/Coordinator/SceneCoordinator.swift | 11 ++ Mastodon/Extension/MastodonUser.swift | 26 +++ Mastodon/Extension/UIAlertController.swift | 37 ++++ Mastodon/Extension/UIBarButtonItem.swift | 20 +++ .../AuthenticationViewController.swift | 98 ++++++++++- .../AuthenticationViewModel.swift | 122 +++++++------ .../Scene/MainTab/MainTabBarController.swift | 20 +++ Mastodon/Service/APIService+APIError.swift | 29 ---- Mastodon/Service/APIService+Account.swift | 24 --- .../APIService/APIService+APIError.swift | 91 ++++++++++ .../APIService/APIService+Account.swift | 46 +++++ .../{ => APIService}/APIService+App.swift | 0 .../APIService+Authentication.swift | 0 .../APIService+PublicTimeline.swift | 0 .../Service/{ => APIService}/APIService.swift | 2 + ...vice+CoreData+MastodonAuthentication.swift | 76 +++++++++ .../APIService+CoreData+MastodonUser.swift | 86 ++++++++++ .../Persist/APIService+Persist+Timeline.swift | 0 Mastodon/Service/AuthenticationService.swift | 148 ++++++++++++++++ Mastodon/State/AppContext.swift | 6 + Mastodon/Supporting Files/SceneDelegate.swift | 24 ++- .../Mastodon+API+Error+MastodonAPIError.swift | 19 +++ .../API/Mastodon+API+Account.swift | 2 +- .../Entity/Mastodon+Entity+Source.swift | 4 +- 28 files changed, 1075 insertions(+), 141 deletions(-) create mode 100644 CoreDataStack/Entity/MastodonAuthentication.swift create mode 100644 Mastodon/Extension/MastodonUser.swift create mode 100644 Mastodon/Extension/UIAlertController.swift create mode 100644 Mastodon/Extension/UIBarButtonItem.swift delete mode 100644 Mastodon/Service/APIService+APIError.swift delete mode 100644 Mastodon/Service/APIService+Account.swift create mode 100644 Mastodon/Service/APIService/APIService+APIError.swift create mode 100644 Mastodon/Service/APIService/APIService+Account.swift rename Mastodon/Service/{ => APIService}/APIService+App.swift (100%) rename Mastodon/Service/{ => APIService}/APIService+Authentication.swift (100%) rename Mastodon/Service/{ => APIService}/APIService+PublicTimeline.swift (100%) rename Mastodon/Service/{ => APIService}/APIService.swift (94%) create mode 100644 Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonAuthentication.swift create mode 100644 Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift rename Mastodon/Service/{ => APIService}/Persist/APIService+Persist+Timeline.swift (100%) create mode 100644 Mastodon/Service/AuthenticationService.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index ca67b1820..5cd582bf1 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -25,6 +25,20 @@ + + + + + + + + + + + + + + @@ -38,6 +52,7 @@ + @@ -97,9 +112,10 @@ - + + \ No newline at end of file diff --git a/CoreDataStack/Entity/MastodonAuthentication.swift b/CoreDataStack/Entity/MastodonAuthentication.swift new file mode 100644 index 000000000..e58c2e877 --- /dev/null +++ b/CoreDataStack/Entity/MastodonAuthentication.swift @@ -0,0 +1,161 @@ +// +// MastodonAuthentication.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import Foundation +import CoreData + +final public class MastodonAuthentication: NSManagedObject { + + public typealias ID = UUID + + @NSManaged public private(set) var identifier: ID + + @NSManaged public private(set) var domain: String + @NSManaged public private(set) var userID: String + @NSManaged public private(set) var username: String + + @NSManaged public private(set) var appAccessToken: String + @NSManaged public private(set) var userAccessToken: String + @NSManaged public private(set) var clientID: String + @NSManaged public private(set) var clientSecret: String + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + @NSManaged public private(set) var activedAt: Date + + // one-to-one relationship + @NSManaged public private(set) var user: MastodonUser + +} + +extension MastodonAuthentication { + + public override func awakeFromInsert() { + super.awakeFromInsert() + identifier = UUID() + + let now = Date() + createdAt = now + updatedAt = now + activedAt = now + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property, + user: MastodonUser + ) -> MastodonAuthentication { + let authentication: MastodonAuthentication = context.insertObject() + + authentication.domain = property.domain + authentication.userID = property.userID + authentication.username = property.username + authentication.appAccessToken = property.appAccessToken + authentication.userAccessToken = property.userAccessToken + authentication.clientID = property.clientID + authentication.clientSecret = property.clientSecret + + authentication.user = user + + return authentication + } + + public func update(username: String) { + if self.username != username { + self.username = username + } + } + public func update(appAccessToken: String) { + if self.appAccessToken != appAccessToken { + self.appAccessToken = appAccessToken + } + } + public func update(userAccessToken: String) { + if self.userAccessToken != userAccessToken { + self.userAccessToken = userAccessToken + } + } + public func update(clientID: String) { + if self.clientID != clientID { + self.clientID = clientID + } + } + public func update(clientSecret: String) { + if self.clientSecret != clientSecret { + self.clientSecret = clientSecret + } + } + + public func update(activedAt: Date) { + if self.activedAt != activedAt { + self.activedAt = activedAt + } + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + +} + +extension MastodonAuthentication { + public struct Property { + + public let domain: String + public let userID: String + public let username: String + public let appAccessToken: String + public let userAccessToken: String + public let clientID: String + public let clientSecret: String + + public init( + domain: String, + userID: String, + username: String, + appAccessToken: String, + userAccessToken: String, + clientID: String, + clientSecret: String + ) { + self.domain = domain + self.userID = userID + self.username = username + self.appAccessToken = appAccessToken + self.userAccessToken = userAccessToken + self.clientID = clientID + self.clientSecret = clientSecret + } + + } +} + +extension MastodonAuthentication: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \MastodonAuthentication.createdAt, ascending: false)] + } +} + +extension MastodonAuthentication { + + static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonAuthentication.domain), domain) + } + + static func predicate(userID: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonAuthentication.userID), userID) + } + + public static func predicate(domain: String, userID: String) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonAuthentication.predicate(domain: domain), + MastodonAuthentication.predicate(userID: userID) + ]) + } + +} diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index 338acd514..8ac22c0bf 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -8,12 +8,14 @@ import CoreData import Foundation -public final class MastodonUser: NSManagedObject { +final public class MastodonUser: NSManagedObject { + public typealias ID = String + @NSManaged public private(set) var identifier: ID @NSManaged public private(set) var domain: String - @NSManaged public private(set) var id: String + @NSManaged public private(set) var id: ID @NSManaged public private(set) var acct: String @NSManaged public private(set) var username: String @NSManaged public private(set) var displayName: String @@ -25,6 +27,7 @@ public final class MastodonUser: NSManagedObject { // one-to-one relationship @NSManaged public private(set) var pinnedToot: Toot? + @NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication? // one-to-many relationship @NSManaged public private(set) var toots: Set? @@ -36,11 +39,13 @@ public final class MastodonUser: NSManagedObject { @NSManaged public private(set) var bookmarked: Set? @NSManaged public private(set) var retweets: Set? + } -public extension MastodonUser { +extension MastodonUser { + @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, property: Property ) -> MastodonUser { @@ -61,6 +66,38 @@ public extension MastodonUser { return user } + + + public func update(acct: String) { + if self.acct != acct { + self.acct = acct + } + } + public func update(username: String) { + if self.username != username { + self.username = username + } + } + public func update(displayName: String) { + if self.displayName != displayName { + self.displayName = displayName + } + } + public func update(avatar: String) { + if self.avatar != avatar { + self.avatar = avatar + } + } + public func update(avatarStatic: String?) { + if self.avatarStatic != avatarStatic { + self.avatarStatic = avatarStatic + } + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + } public extension MastodonUser { @@ -108,3 +145,44 @@ extension MastodonUser: Managed { return [NSSortDescriptor(keyPath: \MastodonUser.createdAt, ascending: false)] } } + +extension MastodonUser { + + static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.domain), domain) + } + + static func predicate(id: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.id), id) + } + + public static func predicate(domain: String, id: String) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonUser.predicate(domain: domain), + MastodonUser.predicate(id: id) + ]) + } + + static func predicate(ids: [String]) -> NSPredicate { + return NSPredicate(format: "%K IN %@", #keyPath(MastodonUser.id), ids) + } + + public static func predicate(domain: String, ids: [String]) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonUser.predicate(domain: domain), + MastodonUser.predicate(ids: ids) + ]) + } + + static func predicate(username: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.username), username) + } + + public static func predicate(domain: String, username: String) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonUser.predicate(domain: domain), + MastodonUser.predicate(username: username) + ]) + } + +} diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 1c9c6c724..fb5c211e2 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -54,6 +54,13 @@ DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */; }; DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; }; DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; }; + DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; }; + DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */; }; + DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */; }; + DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */; }; + DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */; }; + DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; }; + DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */ = {isa = PBXBuildFile; fileRef = DB89B9F025C10FD0008580ED /* CoreDataStack.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -193,6 +200,13 @@ DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MastodonUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = ""; }; DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = ""; }; + DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = ""; }; + DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonUser.swift"; sourceTree = ""; }; + DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUser.swift; sourceTree = ""; }; + DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthentication.swift; sourceTree = ""; }; + DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = ""; }; + DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = ""; }; DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -322,13 +336,8 @@ 2D61335525C1886800CAE157 /* Service */ = { isa = PBXGroup; children = ( - 2D61335D25C1894B00CAE157 /* APIService.swift */, - DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, - DB98336A25C9420100AD9700 /* APIService+App.swift */, - DB98337025C9443200AD9700 /* APIService+Authentication.swift */, - DB98339B25C96DE600AD9700 /* APIService+Account.swift */, - 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, - 2D61335625C1887F00CAE157 /* Persist */, + DB45FB0425CA87B4005A8AC7 /* APIService */, + DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, ); path = Service; sourceTree = ""; @@ -522,6 +531,30 @@ path = MastodonUITests; sourceTree = ""; }; + DB45FB0425CA87B4005A8AC7 /* APIService */ = { + isa = PBXGroup; + children = ( + 2D61335625C1887F00CAE157 /* Persist */, + DB45FB0925CA87BC005A8AC7 /* CoreData */, + 2D61335D25C1894B00CAE157 /* APIService.swift */, + DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, + DB98336A25C9420100AD9700 /* APIService+App.swift */, + DB98337025C9443200AD9700 /* APIService+Authentication.swift */, + DB98339B25C96DE600AD9700 /* APIService+Account.swift */, + 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, + ); + path = APIService; + sourceTree = ""; + }; + DB45FB0925CA87BC005A8AC7 /* CoreData */ = { + isa = PBXGroup; + children = ( + DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, + DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, + ); + path = CoreData; + sourceTree = ""; + }; DB89B9EF25C10FD0008580ED /* CoreDataStack */ = { isa = PBXGroup; children = ( @@ -566,6 +599,7 @@ 2D927F0725C7E9A8004F19B8 /* Tag.swift */, 2D927F0D25C7E9C9004F19B8 /* History.swift */, 2D927F1325C7EDD9004F19B8 /* Emoji.swift */, + DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */, ); path = Entity; sourceTree = ""; @@ -621,13 +655,16 @@ DB8AF56225C138BC002E6C99 /* Extension */ = { isa = PBXGroup; children = ( + DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, 2D46976325C2A71500CF4AA9 /* UIIamge.swift */, 2DF123A625C3B0210020F248 /* ActiveLabel.swift */, 2D42FF6A25C817D2004A627A /* MastodonContent.swift */, + DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, + DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, ); path = Extension; sourceTree = ""; @@ -998,10 +1035,12 @@ 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, + DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2D152A8C25C295CC009AA50C /* TimelinePostView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */, + DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, @@ -1014,14 +1053,17 @@ DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, + DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, 2D76319F25C1521200929FB9 /* TimelineSection.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, + DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, + DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, @@ -1034,6 +1076,7 @@ DB01409625C40B6700F9F3CF /* AuthenticationViewController.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, + DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1069,6 +1112,7 @@ DB89BA4425C1165F008580ED /* Managed.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, + DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */, 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */, DB89BA1D25C1107F008580ED /* URL.swift in Sources */, 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 0c8388901..9418b6383 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -39,6 +39,8 @@ extension SceneCoordinator { enum Scene { case authentication(viewModel: AuthenticationViewModel) case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel) + + case alertController(alertController: UIAlertController) } } @@ -118,6 +120,15 @@ private extension SceneCoordinator { let _viewController = MastodonPinBasedAuthenticationViewController() _viewController.viewModel = viewModel viewController = _viewController + case .alertController(let alertController): + if let popoverPresentationController = alertController.popoverPresentationController { + assert( + popoverPresentationController.sourceView != nil || + popoverPresentationController.sourceRect != .zero || + popoverPresentationController.barButtonItem != nil + ) + } + viewController = alertController } setupDependency(for: viewController as? NeedsDependency) diff --git a/Mastodon/Extension/MastodonUser.swift b/Mastodon/Extension/MastodonUser.swift new file mode 100644 index 000000000..1f6d41839 --- /dev/null +++ b/Mastodon/Extension/MastodonUser.swift @@ -0,0 +1,26 @@ +// +// MastodonUser.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension MastodonUser.Property { + init(entity: Mastodon.Entity.Account, domain: String, networkDate: Date) { + self.init( + id: entity.id, + domain: domain, + acct: entity.acct, + username: entity.username, + displayName: entity.displayName, + avatar: entity.avatar, + avatarStatic: entity.avatarStatic, + createdAt: entity.createdAt, + networkDate: networkDate + ) + } +} diff --git a/Mastodon/Extension/UIAlertController.swift b/Mastodon/Extension/UIAlertController.swift new file mode 100644 index 000000000..7abe20cbd --- /dev/null +++ b/Mastodon/Extension/UIAlertController.swift @@ -0,0 +1,37 @@ +// +// UIAlertController.swift +// Mastodon +// + +import UIKit + +// Reference: +// https://nshipster.com/swift-foundation-error-protocols/ +extension UIAlertController { + convenience init( + _ error: Error, + preferredStyle: UIAlertController.Style + ) { + let title: String + let message: String? + if let error = error as? LocalizedError { + title = error.errorDescription ?? "Unknown Error" + message = [ + error.failureReason, + error.recoverySuggestion + ] + .compactMap { $0 } + .joined(separator: " ") + } else { + title = "Internal Error" + message = error.localizedDescription + } + + self.init( + title: title, + message: message, + preferredStyle: preferredStyle + ) + } +} + diff --git a/Mastodon/Extension/UIBarButtonItem.swift b/Mastodon/Extension/UIBarButtonItem.swift new file mode 100644 index 000000000..8a0630f03 --- /dev/null +++ b/Mastodon/Extension/UIBarButtonItem.swift @@ -0,0 +1,20 @@ +// +// UIBarButtonItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import UIKit + +extension UIBarButtonItem { + + static var activityIndicatorBarButtonItem: UIBarButtonItem { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + let barButtonItem = UIBarButtonItem(customView: activityIndicatorView) + activityIndicatorView.startAnimating() + return barButtonItem + } + +} + diff --git a/Mastodon/Scene/Authentication/AuthenticationViewController.swift b/Mastodon/Scene/Authentication/AuthenticationViewController.swift index dadad28a0..42dc854d6 100644 --- a/Mastodon/Scene/Authentication/AuthenticationViewController.swift +++ b/Mastodon/Scene/Authentication/AuthenticationViewController.swift @@ -18,7 +18,6 @@ final class AuthenticationViewController: UIViewController, NeedsDependency { weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var viewModel: AuthenticationViewModel! - var mastodonPinBasedAuthenticationViewController: UIViewController? let domainTextField: UITextField = { let textField = UITextField() @@ -30,7 +29,7 @@ final class AuthenticationViewController: UIViewController, NeedsDependency { }() private(set) lazy var signInBarButtonItem = UIBarButtonItem(title: "Sign In", style: .plain, target: self, action: #selector(AuthenticationViewController.signInBarButtonItemPressed(_:))) - + let activityIndicatorBarButtonItem = UIBarButtonItem.activityIndicatorBarButtonItem } extension AuthenticationViewController { @@ -59,10 +58,59 @@ extension AuthenticationViewController { .assign(to: \.value, on: viewModel.input) .store(in: &disposeBag) + viewModel.isAuthenticating + .receive(on: DispatchQueue.main) + .sink { [weak self] isAuthenticating in + guard let self = self else { return } + self.navigationItem.rightBarButtonItem = isAuthenticating ? self.activityIndicatorBarButtonItem : self.signInBarButtonItem + } + .store(in: &disposeBag) + + viewModel.authenticated + .receive(on: DispatchQueue.main) + .sink { [weak self] domain, user in + guard let self = self else { return } + // reset view hierarchy only if needs + if self.viewModel.viewHierarchyShouldReset { + self.context.authenticationService.activeMastodonUser(domain: domain, userID: user.id) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .success(let isActived): + assert(isActived) + self.coordinator.setup() + } + } + .store(in: &self.disposeBag) + } else { + self.dismiss(animated: true, completion: nil) + } + } + .store(in: &disposeBag) + viewModel.isSignInButtonEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: signInBarButtonItem) .store(in: &disposeBag) + + viewModel.error + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { [weak self] error in + guard let self = self else { return } + let alertController = UIAlertController(error, preferredStyle: .alert) + let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) + alertController.addAction(okAction) + self.coordinator.present( + scene: .alertController(alertController: alertController), + from: nil, + transition: .alertController(animated: true, completion: nil) + ) + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -81,7 +129,51 @@ extension AuthenticationViewController { // TODO: alert error return } - viewModel.signInAction.send(domain) + guard !viewModel.isAuthenticating.value else { return } + viewModel.isAuthenticating.value = true + context.apiService.createApplication(domain: domain) + .tryMap { response -> AuthenticationViewModel.AuthenticateInfo in + let application = response.value + guard let clientID = application.clientID, + let clientSecret = application.clientSecret else { + throw APIService.APIError.explicit(.badResponse) + } + let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID) + let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query) + return AuthenticationViewModel.AuthenticateInfo( + domain: domain, + clientID: clientID, + clientSecret: clientSecret, + url: url + ) + } + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + // trigger state update + self.viewModel.isAuthenticating.value = false + + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + self.viewModel.error.value = error + case .finished: + break + } + } receiveValue: { [weak self] info in + guard let self = self else { return } + let mastodonPinBasedAuthenticationViewModel = MastodonPinBasedAuthenticationViewModel(authenticateURL: info.url) + self.viewModel.authenticate( + info: info, + pinCodePublisher: mastodonPinBasedAuthenticationViewModel.pinCodePublisher + ) + self.viewModel.mastodonPinBasedAuthenticationViewController = self.coordinator.present( + scene: .mastodonPinBasedAuthentication(viewModel: mastodonPinBasedAuthenticationViewModel), + from: nil, + transition: .modal(animated: true, completion: nil) + ) + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Authentication/AuthenticationViewModel.swift b/Mastodon/Scene/Authentication/AuthenticationViewModel.swift index 264ed487c..a56a4387a 100644 --- a/Mastodon/Scene/Authentication/AuthenticationViewModel.swift +++ b/Mastodon/Scene/Authentication/AuthenticationViewModel.swift @@ -7,6 +7,8 @@ import os.log import UIKit +import CoreData +import CoreDataStack import Combine import MastodonSDK @@ -17,21 +19,24 @@ final class AuthenticationViewModel { // input let context: AppContext let coordinator: SceneCoordinator + let isAuthenticationExist: Bool let input = CurrentValueSubject("") - let signInAction = PassthroughSubject() // output + let viewHierarchyShouldReset: Bool let domain = CurrentValueSubject(nil) let isSignInButtonEnabled = CurrentValueSubject(false) let isAuthenticating = CurrentValueSubject(false) - let authenticated = PassthroughSubject() + let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>() let error = CurrentValueSubject(nil) - private var mastodonPinBasedAuthenticationViewController: UIViewController? + var mastodonPinBasedAuthenticationViewController: UIViewController? - init(context: AppContext, coordinator: SceneCoordinator) { + init(context: AppContext, coordinator: SceneCoordinator, isAuthenticationExist: Bool) { self.context = context self.coordinator = coordinator + self.isAuthenticationExist = isAuthenticationExist + self.viewHierarchyShouldReset = isAuthenticationExist input .map { input in @@ -44,8 +49,11 @@ final class AuthenticationViewModel { return nil } let components = host.components(separatedBy: ".") - guard (components.filter { !$0.isEmpty }).count >= 2 else { return nil } + guard !components.contains(where: { $0.isEmpty }) else { return nil } + guard components.count >= 2 else { return nil } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: iput host: %s", ((#file as NSString).lastPathComponent), #line, #function, host) + return host } .assign(to: \.value, on: domain) @@ -55,60 +63,6 @@ final class AuthenticationViewModel { .map { $0 != nil } .assign(to: \.value, on: isSignInButtonEnabled) .store(in: &disposeBag) - - signInAction - .handleEvents(receiveOutput: { [weak self] _ in - // trigger state change - guard let self = self else { return } - self.isAuthenticating.value = true - }) - .flatMap { domain in - context.apiService.createApplication(domain: domain) - .retry(3) - .tryMap { response -> AuthenticateInfo in - let application = response.value - guard let clientID = application.clientID, - let clientSecret = application.clientSecret else { - throw APIService.APIError.explicit(.badResponse) - } - let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID) - let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query) - return AuthenticateInfo( - domain: domain, - clientID: clientID, - clientSecret: clientSecret, - url: url - ) - } - } - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - guard let self = self else { return } - // trigger state update - self.isAuthenticating.value = false - - switch completion { - case .failure(let error): - // TODO: handle error - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - self.error.value = error - case .finished: - break - } - } receiveValue: { [weak self] info in - guard let self = self else { return } - let mastodonPinBasedAuthenticationViewModel = MastodonPinBasedAuthenticationViewModel(authenticateURL: info.url) - self.authenticate( - info: info, - pinCodePublisher: mastodonPinBasedAuthenticationViewModel.pinCodePublisher - ) - self.mastodonPinBasedAuthenticationViewController = self.coordinator.present( - scene: .mastodonPinBasedAuthentication(viewModel: mastodonPinBasedAuthenticationViewModel), - from: nil, - transition: .modal(animated: true, completion: nil) - ) - } - .store(in: &disposeBag) } } @@ -145,7 +99,7 @@ extension AuthenticationViewModel { return AuthenticationViewModel.verifyAndSaveAuthentication( context: self.context, info: info, - token: token + userToken: token ) } .eraseToAnyPublisher() @@ -165,7 +119,9 @@ extension AuthenticationViewModel { } receiveValue: { [weak self] response in guard let self = self else { return } let account = response.value - // TODO: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user %s sign in success", ((#file as NSString).lastPathComponent), #line, #function, account.username) + + self.authenticated.send((domain: info.domain, account: account)) } .store(in: &self.disposeBag) } @@ -173,14 +129,52 @@ extension AuthenticationViewModel { static func verifyAndSaveAuthentication( context: AppContext, info: AuthenticateInfo, - token: Mastodon.Entity.Token + userToken: Mastodon.Entity.Token ) -> AnyPublisher, Error> { - let authorization = Mastodon.API.OAuth.Authorization(accessToken: token.accessToken) + let authorization = Mastodon.API.OAuth.Authorization(accessToken: userToken.accessToken) + let managedObjectContext = context.backgroundManagedObjectContext + return context.apiService.accountVerifyCredentials( domain: info.domain, authorization: authorization ) - // TODO: add persist logic + .flatMap { response -> AnyPublisher, Error> in + let account = response.value + let mastodonUserRequest = MastodonUser.sortedFetchRequest + mastodonUserRequest.predicate = MastodonUser.predicate(domain: info.domain, id: account.id) + mastodonUserRequest.fetchLimit = 1 + guard let mastodonUser = try? managedObjectContext.fetch(mastodonUserRequest).first else { + return Fail(error: APIService.APIError.explicit(.badCredentials)).eraseToAnyPublisher() + } + + let property = MastodonAuthentication.Property( + domain: info.domain, + userID: mastodonUser.id, + username: mastodonUser.username, + appAccessToken: userToken.accessToken, // TODO: swap app token + userAccessToken: userToken.accessToken, + clientID: info.clientID, + clientSecret: info.clientSecret + ) + return managedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeMastodonAuthentication( + into: managedObjectContext, + for: mastodonUser, + in: info.domain, + property: property, + networkDate: response.networkDate + ) + } + .setFailureType(to: Error.self) + .tryMap { result in + switch result { + case .failure(let error): throw error + case .success: return response + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } } diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index 2d6c43136..8c975ef47 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -88,6 +88,26 @@ extension MainTabBarController { let tabBarAppearance = UITabBarAppearance() tabBarAppearance.configureWithDefaultBackground() tabBar.standardAppearance = tabBarAppearance + + context.apiService.error + .receive(on: DispatchQueue.main) + .sink { [weak self] error in + guard let self = self, let coordinator = self.coordinator else { return } + switch error { + case .implicit: + break + case .explicit: + let alertController = UIAlertController(error, preferredStyle: .alert) + let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) + alertController.addAction(okAction) + coordinator.present( + scene: .alertController(alertController: alertController), + from: nil, + transition: .alertController(animated: true, completion: nil) + ) + } + } + .store(in: &disposeBag) #if DEBUG // selectedIndex = 1 diff --git a/Mastodon/Service/APIService+APIError.swift b/Mastodon/Service/APIService+APIError.swift deleted file mode 100644 index 2bb56cf2c..000000000 --- a/Mastodon/Service/APIService+APIError.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// APIService+Error.swift -// Mastodon -// -// Created by Cirno MainasuK on 2021-2-2. -// - -import UIKit -import MastodonSDK - -extension APIService { - enum APIError: Error { - - case implicit(ErrorReason) - case explicit(ErrorReason) - - enum ErrorReason { - // application internal error - case authenticationMissing - case badRequest - case badResponse - case requestThrottle - - // Server API error - case mastodonAPIError(Mastodon.API.Error) - } - - } -} diff --git a/Mastodon/Service/APIService+Account.swift b/Mastodon/Service/APIService+Account.swift deleted file mode 100644 index 1a9ad08b5..000000000 --- a/Mastodon/Service/APIService+Account.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// APIService+Account.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021/2/2. -// - -import Foundation -import Combine -import MastodonSDK - -extension APIService { - - func accountVerifyCredentials( - domain: String, - authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - return Mastodon.API.Account.verifyCredentials( - session: session, - domain: domain, - authorization: authorization - ) - } -} diff --git a/Mastodon/Service/APIService/APIService+APIError.swift b/Mastodon/Service/APIService/APIService+APIError.swift new file mode 100644 index 000000000..9a72e0e4f --- /dev/null +++ b/Mastodon/Service/APIService/APIService+APIError.swift @@ -0,0 +1,91 @@ +// +// APIService+Error.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-2-2. +// + +import UIKit +import MastodonSDK + +extension APIService { + enum APIError: Error { + + case implicit(ErrorReason) + case explicit(ErrorReason) + + enum ErrorReason { + // application internal error + case authenticationMissing + case badCredentials + case badRequest + case badResponse + case requestThrottle + + // Server API error + case mastodonAPIError(Mastodon.API.Error) + } + + private var errorReason: ErrorReason { + switch self { + case .implicit(let errorReason): return errorReason + case .explicit(let errorReason): return errorReason + } + } + + } +} + +// MARK: - LocalizedError +extension APIService.APIError: LocalizedError { + + var errorDescription: String? { + switch errorReason { + case .authenticationMissing: return "Fail to Authenticatie" + case .badCredentials: return "Bad Credentials" + case .badRequest: return "Bad Request" + case .badResponse: return "Bad Response" + case .requestThrottle: return "Request Throttled" + case .mastodonAPIError(let error): + guard let responseError = error.mastodonError else { + guard error.httpResponseStatus != .ok else { + return "Unknown Error" + } + return error.httpResponseStatus.reasonPhrase + } + + return responseError.errorDescription + } + } + + var failureReason: String? { + switch errorReason { + case .authenticationMissing: return "Account credential not found." + case .badCredentials: return "Credentials invalid." + case .badRequest: return "Request invalid." + case .badResponse: return "Response invalid." + case .requestThrottle: return "Request too frequency." + case .mastodonAPIError(let error): + guard let responseError = error.mastodonError else { + return nil + } + return responseError.failureReason + } + } + + var helpAnchor: String? { + switch errorReason { + case .authenticationMissing: return "Please request after authenticated." + case .badCredentials: return "Please try again.." + case .badRequest: return "Please try again." + case .badResponse: return "Please try again." + case .requestThrottle: return "Please try again later." + case .mastodonAPIError(let error): + guard let responseError = error.mastodonError else { + return nil + } + return responseError.helpAnchor + } + } + +} diff --git a/Mastodon/Service/APIService/APIService+Account.swift b/Mastodon/Service/APIService/APIService+Account.swift new file mode 100644 index 000000000..1fcf212b6 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Account.swift @@ -0,0 +1,46 @@ +// +// APIService+Account.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/2. +// + +import Foundation +import Combine +import CommonOSLog +import MastodonSDK + +extension APIService { + + func accountVerifyCredentials( + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + return Mastodon.API.Account.verifyCredentials( + session: session, + domain: domain, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let log = OSLog.api + let account = response.value + + return self.backgroundManagedObjectContext.performChanges { + let (mastodonUser, isCreated) = APIService.CoreData.createOrMergeMastodonUser( + into: self.backgroundManagedObjectContext, + for: nil, + in: domain, + entity: account, + networkDate: response.networkDate, + log: log) + let flag = isCreated ? "+" : "-" + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService+App.swift b/Mastodon/Service/APIService/APIService+App.swift similarity index 100% rename from Mastodon/Service/APIService+App.swift rename to Mastodon/Service/APIService/APIService+App.swift diff --git a/Mastodon/Service/APIService+Authentication.swift b/Mastodon/Service/APIService/APIService+Authentication.swift similarity index 100% rename from Mastodon/Service/APIService+Authentication.swift rename to Mastodon/Service/APIService/APIService+Authentication.swift diff --git a/Mastodon/Service/APIService+PublicTimeline.swift b/Mastodon/Service/APIService/APIService+PublicTimeline.swift similarity index 100% rename from Mastodon/Service/APIService+PublicTimeline.swift rename to Mastodon/Service/APIService/APIService+PublicTimeline.swift diff --git a/Mastodon/Service/APIService.swift b/Mastodon/Service/APIService/APIService.swift similarity index 94% rename from Mastodon/Service/APIService.swift rename to Mastodon/Service/APIService/APIService.swift index e79fb1176..36778a3b2 100644 --- a/Mastodon/Service/APIService.swift +++ b/Mastodon/Service/APIService/APIService.swift @@ -25,6 +25,8 @@ final class APIService { // input let backgroundManagedObjectContext: NSManagedObjectContext + // output + let error = PassthroughSubject() init(backgroundManagedObjectContext: NSManagedObjectContext) { self.backgroundManagedObjectContext = backgroundManagedObjectContext diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonAuthentication.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonAuthentication.swift new file mode 100644 index 000000000..15624f71d --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonAuthentication.swift @@ -0,0 +1,76 @@ +// +// APIService+CoreData+MastodonAuthentication.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeMastodonAuthentication( + into managedObjectContext: NSManagedObjectContext, + for authenticateMastodonUser: MastodonUser, + in domain: String, + property: MastodonAuthentication.Property, + networkDate: Date + ) -> (mastodonAuthentication: MastodonAuthentication, isCreated: Bool) { + // fetch old mastodon authentication + let oldMastodonAuthentication: MastodonAuthentication? = { + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(domain: domain, userID: property.userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let oldMastodonAuthentication = oldMastodonAuthentication { + // merge old mastodon authentication + APIService.CoreData.mergeMastodonAuthentication( + for: authenticateMastodonUser, + old: oldMastodonAuthentication, + in: domain, + property: property, + networkDate: networkDate + ) + return (oldMastodonAuthentication, false) + } else { + let mastodonAuthentication = MastodonAuthentication.insert( + into: managedObjectContext, + property: property, + user: authenticateMastodonUser + ) + return (mastodonAuthentication, true) + } + } + + static func mergeMastodonAuthentication( + for authenticateMastodonUser: MastodonUser, + old authentication: MastodonAuthentication, + in domain: String, + property: MastodonAuthentication.Property, + networkDate: Date + ) { + guard networkDate > authentication.updatedAt else { return } + + + authentication.update(username: property.username) + authentication.update(appAccessToken: property.appAccessToken) + authentication.update(userAccessToken: property.userAccessToken) + authentication.update(clientID: property.clientID) + authentication.update(clientSecret: property.clientSecret) + + authentication.didUpdate(at: networkDate) + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift new file mode 100644 index 000000000..4f35a54c1 --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -0,0 +1,86 @@ +// +// APIService+CoreData+MastodonUser.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeMastodonUser( + into managedObjectContext: NSManagedObjectContext, + for requestMastodonUser: MastodonUser?, + in domain: String, + entity: Mastodon.Entity.Account, + networkDate: Date, + log: OSLog + ) -> (user: MastodonUser, isCreated: Bool) { + let processEntityTaskSignpostID = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "process mastodon user %{public}s", entity.id) + defer { + os_signpost(.end, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "process msstodon user %{public}s", entity.id) + } + + // fetch old mastodon user + let oldMastodonUser: MastodonUser? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: entity.id) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let oldMastodonUser = oldMastodonUser { + // merge old mastodon usre + APIService.CoreData.mergeMastodonUser( + for: requestMastodonUser, + old: oldMastodonUser, + in: domain, + entity: entity, + networkDate: networkDate + ) + return (oldMastodonUser, false) + } else { + let mastodonUserProperty = MastodonUser.Property(entity: entity, domain: domain, networkDate: networkDate) + let mastodonUser = MastodonUser.insert( + into: managedObjectContext, + property: mastodonUserProperty + ) + + os_signpost(.event, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "did insert new mastodon user %{public}s: name %s", mastodonUser.identifier, mastodonUser.username) + return (mastodonUser, true) + } + } + + static func mergeMastodonUser( + for requestMastodonUser: MastodonUser?, + old user: MastodonUser, + in domain: String, + entity: Mastodon.Entity.Account, + networkDate: Date + ) { + guard networkDate > user.updatedAt else { return } + let property = MastodonUser.Property(entity: entity, domain: domain, networkDate: networkDate) + + // only fulfill API supported fields + user.update(acct: property.acct) + user.update(username: property.username) + user.update(displayName: property.displayName) + user.update(avatar: property.avatar) + user.update(avatarStatic: property.avatarStatic) + + user.didUpdate(at: networkDate) + } + +} diff --git a/Mastodon/Service/Persist/APIService+Persist+Timeline.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift similarity index 100% rename from Mastodon/Service/Persist/APIService+Persist+Timeline.swift rename to Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift new file mode 100644 index 000000000..16969b8aa --- /dev/null +++ b/Mastodon/Service/AuthenticationService.swift @@ -0,0 +1,148 @@ +// +// AuthenticationService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021/2/3. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +class AuthenticationService: NSObject { + + var disposeBag = Set() + // input + weak var apiService: APIService? + let managedObjectContext: NSManagedObjectContext // read-only + let backgroundManagedObjectContext: NSManagedObjectContext + let mastodonAuthenticationFetchedResultsController: NSFetchedResultsController + + // output + let mastodonAuthentications = CurrentValueSubject<[MastodonAuthentication], Never>([]) + let activeMastodonAuthentication = CurrentValueSubject(nil) + let activeMastodonAuthenticationBox = CurrentValueSubject(nil) + + init( + managedObjectContext: NSManagedObjectContext, + backgroundManagedObjectContext: NSManagedObjectContext, + apiService: APIService + ) { + self.managedObjectContext = managedObjectContext + self.backgroundManagedObjectContext = backgroundManagedObjectContext + self.apiService = apiService + self.mastodonAuthenticationFetchedResultsController = { + let fetchRequest = MastodonAuthentication.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + return controller + }() + super.init() + + mastodonAuthenticationFetchedResultsController.delegate = self + + // TODO: verify credentials for active authentication + + // bind data + mastodonAuthentications + .map { $0.sorted(by: { $0.activedAt > $1.activedAt }).first } + .assign(to: \.value, on: activeMastodonAuthentication) + .store(in: &disposeBag) + + activeMastodonAuthentication + .map { authentication -> AuthenticationService.MastodonAuthenticationBox? in + guard let authentication = authentication else { return nil } + return AuthenticationService.MastodonAuthenticationBox( + userID: authentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) + ) + } + .assign(to: \.value, on: activeMastodonAuthenticationBox) + .store(in: &disposeBag) + + do { + try mastodonAuthenticationFetchedResultsController.performFetch() + mastodonAuthentications.value = mastodonAuthenticationFetchedResultsController.fetchedObjects ?? [] + } catch { + assertionFailure(error.localizedDescription) + } + } + +} + +extension AuthenticationService { + struct MastodonAuthenticationBox { + let userID: MastodonUser.ID + let appAuthorization: Mastodon.API.OAuth.Authorization + let userAuthorization: Mastodon.API.OAuth.Authorization + } +} + +extension AuthenticationService { + + func activeMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher, Never> { + var isActived = false + + return backgroundManagedObjectContext.performChanges { + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) + request.fetchLimit = 1 + guard let mastodonAutentication = try? self.backgroundManagedObjectContext.fetch(request).first else { + return + } + mastodonAutentication.update(activedAt: Date()) + isActived = true + } + .map { result in + return result.map { isActived } + } + .eraseToAnyPublisher() + } + + func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher, Never> { + var isSignOut = false + + return backgroundManagedObjectContext.performChanges { + let request = MastodonAuthentication.sortedFetchRequest + request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) + request.fetchLimit = 1 + guard let mastodonAutentication = try? self.backgroundManagedObjectContext.fetch(request).first else { + return + } + self.backgroundManagedObjectContext.delete(mastodonAutentication) + isSignOut = true + } + .map { result in + return result.map { isSignOut } + } + .eraseToAnyPublisher() + } + +} + + +// MARK: - NSFetchedResultsControllerDelegate +extension AuthenticationService: NSFetchedResultsControllerDelegate { + + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + if controller === mastodonAuthenticationFetchedResultsController { + mastodonAuthentications.value = mastodonAuthenticationFetchedResultsController.fetchedObjects ?? [] + } + } + +} + diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 19cb4757d..08918496b 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -22,6 +22,7 @@ class AppContext: ObservableObject { let backgroundManagedObjectContext: NSManagedObjectContext let apiService: APIService + let authenticationService: AuthenticationService let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -39,6 +40,11 @@ class AppContext: ObservableObject { let _apiService = APIService(backgroundManagedObjectContext: _backgroundManagedObjectContext) apiService = _apiService + authenticationService = AuthenticationService( + managedObjectContext: _managedObjectContext, + backgroundManagedObjectContext: _backgroundManagedObjectContext, + apiService: _apiService + ) documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 6aeb17d17..82bc645b7 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -6,6 +6,7 @@ // import UIKit +import CoreDataStack class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -25,12 +26,25 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { sceneCoordinator.setup() - #if DEBUG - DispatchQueue.main.async { - let authenticationViewModel = AuthenticationViewModel(context: appContext, coordinator: sceneCoordinator) - sceneCoordinator.present(scene: .authentication(viewModel: authenticationViewModel), from: nil, transition: .modal(animated: false, completion: nil)) + do { + let request = MastodonAuthentication.sortedFetchRequest + if try appContext.managedObjectContext.fetch(request).isEmpty { + DispatchQueue.main.async { + let authenticationViewModel = AuthenticationViewModel( + context: appContext, + coordinator: sceneCoordinator, + isAuthenticationExist: false + ) + sceneCoordinator.present( + scene: .authentication(viewModel: authenticationViewModel), + from: nil, + transition: .modal(animated: false, completion: nil) + ) + } + } + } catch { + assertionFailure(error.localizedDescription) } - #endif window.makeKeyAndVisible() } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error+MastodonAPIError.swift b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error+MastodonAPIError.swift index edf078109..61e4c98aa 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error+MastodonAPIError.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error+MastodonAPIError.swift @@ -16,3 +16,22 @@ extension Mastodon.API.Error { } } } + +// MARK: - LocalizedError +extension Mastodon.API.Error.MastodonError: LocalizedError { + + public var errorDescription: String? { + switch self { + case .generic(let error): + return error.error + } + } + + public var failureReason: String? { + switch self { + case .generic(let error): + return error.errorDescription + } + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift index 4929b0bb9..81fe9cd00 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -11,7 +11,7 @@ import Combine extension Mastodon.API.Account { static func verifyCredentialsEndpointURL(domain: String) -> URL { - return Mastodon.API.endpointURL(domain: domain) + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/verify_credentials") } public static func verifyCredentials( diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Source.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Source.swift index 5d0884377..d6e174574 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Source.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Source.swift @@ -13,7 +13,7 @@ extension Mastodon.Entity { /// - Since: 1.5.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/1/28 + /// 2021/2/3 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/source/) public struct Source: Codable { @@ -25,7 +25,7 @@ extension Mastodon.Entity { public let privacy: Privacy? public let sensitive: Bool? public let language: String? // (ISO 639-1 language two-letter code) - public let followRequestsCount: String + public let followRequestsCount: Int? enum CodingKeys: String, CodingKey { case note From 09501cf2ded16cc11743f3d6f272e0039fd3913d Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 3 Feb 2021 16:20:16 +0800 Subject: [PATCH 08/10] chore: remove not used CodingKeys --- .../Sources/MastodonSDK/API/Mastodon+API+OAuth.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift index fc64c10d0..f6fa76bc2 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+OAuth.swift @@ -115,14 +115,6 @@ extension Mastodon.API.OAuth { self.scope = scope } - enum CodingKeys: String, CodingKey { - case forceLogin = "force_login" - case responseType = "response_type" - case clientID - case redirectURI = "redirect_uri" - case scope - } - var queryItems: [URLQueryItem]? { var items: [URLQueryItem] = [] forceLogin.flatMap { items.append(URLQueryItem(name: "force_login", value: $0)) } From 018d64660925955f22535c9ea8d7da13948f4621 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 3 Feb 2021 16:20:37 +0800 Subject: [PATCH 09/10] chore: remove not not used typealias --- .../Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift | 5 +---- .../MastodonSDK/Entity/Mastodon+Entity+Instance.swift | 2 +- .../Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift index f794dabb9..82fc9502b 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift @@ -9,9 +9,6 @@ import Foundation extension Mastodon.Entity { - // FIXME: prefer `Account`. `User` will be deprecated - public typealias User = Account - /// Account /// /// - Since: 0.1.0 @@ -48,7 +45,7 @@ extension Mastodon.Entity { public let followersCount: Int public let followingCount: Int - public let moved: User? + public let moved: Account? public let fields: [Field]? public let bot: Bool? public let source: Source? diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift index 6cdef7fe6..73a573f2c 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift @@ -32,7 +32,7 @@ extension Mastodon.Entity { public let statistics: Statistics? public let thumbnail: String? - public let contactAccount: User? + public let contactAccount: Account? enum CodingKeys: String, CodingKey { case uri diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift index 210a8e1d8..a8843666f 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift @@ -9,7 +9,6 @@ import Foundation extension Mastodon.Entity { - // FIXME: prefer `Status`. `Toot` will be deprecated public typealias Toot = Status /// Status From c4765e9a8a259fae66fddb68e2af454be93b9e8c Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 3 Feb 2021 16:31:26 +0800 Subject: [PATCH 10/10] fix: authorize URL unit test case --- .../Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift index b027578f1..8c5f42603 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+OAuthTests.swift @@ -22,7 +22,7 @@ extension MastodonSDKTests { os_log("%{public}s[%{public}ld], %{public}s: (%s) authorizeURL %s", ((#file as NSString).lastPathComponent), #line, #function, domain, authorizeURL.absoluteString) XCTAssertEqual( authorizeURL.absoluteString, - "https://\(domain)/oauth/authorize?response_type=code&clientID=StubClientID&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=read%20write%20follow%20push" + "https://\(domain)/oauth/authorize?response_type=code&client_id=StubClientID&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=read%20write%20follow%20push" ) }