COVIDSafe code from version 1.1

This commit is contained in:
covidsafe-support 2020-05-08 17:49:14 +10:00
commit 3640e52eb2
330 changed files with 261540 additions and 0 deletions

View file

@ -0,0 +1,46 @@
// Copyright © 2020 Australian Government All rights reserved.
import UIKit
let ADGTintColor = UIColor(0x0052CC)
let ADGChromeColor = UIColor(0xF4F5F7)
let ADGHairlineColor = UIColor(0xEBECF0)
let ADGTextColor = UIColor(0x172B4D)
let ADGTextColorSecondary = UIColor(0xA5ADBA)
public enum NavigationBarStyle {
case blue
case white
var backgroundColor: UIColor {
switch self {
case .blue: return ADGTintColor
case .white: return UIColor.white
}
}
var tintColor: UIColor {
switch self {
case .blue: return UIColor.white
case .white: return ADGTintColor
}
}
var titleTextColor: UIColor {
switch self {
case .blue: return UIColor.white
case .white: return ADGTextColor
}
}
var titleFont: UIFont { return UIFont.systemFont(ofSize: 17.0, weight: UIFont.Weight.medium) }
var statusBarStyle: UIStatusBarStyle {
switch self {
case .blue: return .lightContent
case .white: return .default
}
}
public static var defaultStyle: NavigationBarStyle { return .blue }
}

View file

@ -0,0 +1,18 @@
// Copyright © 2020 Australian Government All rights reserved.
import Foundation
class Action: Operation {
override func main() {
if isCancelled {
return
}
autoreleasepool {
self.run()
}
}
func run() {
preconditionFailure("This abstract method must be overridden.")
}
}

View file

@ -0,0 +1,21 @@
// Copyright © 2020 Australian Government All rights reserved.
import UIKit
class AlertController: UIAlertController {
var lastPresentingViewController: UIViewController?
var useCustomTransition: Bool = true
var feedbackSettings: FeedbackSettings?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let viewController = presentingViewController {
lastPresentingViewController = viewController
}
}
override func viewDidLoad() {
super.viewDidLoad()
view.tintColor = ADGTintColor
}
}

View file

@ -0,0 +1,72 @@
//Copyright © 2015 Australian Government All rights reserved.
import Foundation
open class AsyncAction: Operation {
fileprivate var _executing = false
fileprivate var _finished = false
override fileprivate(set) open var isExecuting: Bool {
get {
return _executing
}
set {
willChangeValue(forKey: "isExecuting")
_executing = newValue
didChangeValue(forKey: "isExecuting")
}
}
override fileprivate(set) open var isFinished: Bool {
get {
return _finished
}
set {
willChangeValue(forKey: "isFinished")
_finished = newValue
didChangeValue(forKey: "isFinished")
}
}
override open var completionBlock: (() -> Void)? {
set {
super.completionBlock = newValue
}
get {
return {
super.completionBlock?()
self.actionCompleted()
}
}
}
override open var isAsynchronous: Bool {
return true
}
override open func start() {
if isCancelled {
isFinished = true
return
}
isExecuting = true
autoreleasepool {
self.run()
}
}
func run() {
preconditionFailure("This abstract method must be overridden.")
}
func actionCompleted() {
//optional
}
func finishedExecutingOperation() {
isExecuting = false
isFinished = true
}
}

View file

@ -0,0 +1,38 @@
// Copyright © 2020 Australian Government All rights reserved.
import Foundation
private class JMCBundleHandle: NSObject { }
extension Foundation.Bundle {
var version: String? {
return infoDictionary?["CFBundleVersion"] as? String
}
var versionShort: String? {
return infoDictionary?["CFBundleShortVersionString"] as? String
}
var name: String? {
return infoDictionary?["CFBundleName"] as? String
}
var displayName: String? {
return infoDictionary?["CFBundleDisplayName"] as? String
}
var identifier: String? {
return bundleIdentifier
}
var infoAsDictionary: [String: NSObject] {
return [
"appVersion": (version ?? "") as NSObject,
"appVersionShort": versionShort as NSObject? ?? "" as NSObject,
"appName": (name ?? "") as NSObject,
"appDisplayName": (displayName ?? "") as NSObject,
"appId": (identifier ?? "") as NSObject
]
}
}

View file

@ -0,0 +1,19 @@
// Copyright © 2020 Australian Government All rights reserved.
import UIKit
extension UIDevice {
var infoAsDictionary: [String: NSObject] {
return ["devName": name as NSObject,
"systemName": systemName as NSObject,
"systemVersion": systemVersion as NSObject,
"model": model as NSObject,
"uuid": identifierForVendorString as NSObject]
}
}
extension UIDevice {
var identifierForVendorString: String {
return identifierForVendor?.uuidString ?? ""
}
}

View file

@ -0,0 +1,8 @@
// Copyright © 2020 Australian Government All rights reserved.
import Foundation
enum JMCError: Error {
case httpResponseError
case nilHTTPResponseError
}

View file

@ -0,0 +1,72 @@
// Copyright © 2020 Australian Government All rights reserved.
import CoreGraphics
import Foundation
public var defaultFeedbackSettings: FeedbackSettings?
public struct FeedbackSettings {
let JIRATarget: JMCTarget
let issueType: String
let issueComponents: [String]
let customFields: [String: AnyObject]
let reporterAvatarImage: CGImage?
var reporterUsernameOrEmail: String?
let getReporterInfoAsynchronously: ((@escaping (String?, URL?) -> Void) -> Void)?
let navigationBarStyle: NavigationBarStyle
public init(
JIRATarget: JMCTarget,
issueType: String? = nil,
issueComponents: [String]? = nil,
customFields: [String: AnyObject]? = nil,
navigationBarStyle: NavigationBarStyle,
reporterAvatarImage: CGImage? = nil,
reporterUsernameOrEmail: String? = nil,
getReporterInfoAsynchronously: ((@escaping (_ usernameOrEmail: String?, _ avatarImageURL: URL?) -> Void) -> Void)? = nil
) {
let defaultIssueType = JIRAIssueType.support.rawValue
let defaultIssueComponents = ["iOS"]
let defaultCustomFields = [String: AnyObject]()
self.JIRATarget = JIRATarget
self.issueType = (issueType ?? defaultFeedbackSettings?.issueType) ?? defaultIssueType
self.issueComponents = (issueComponents ?? defaultFeedbackSettings?.issueComponents) ?? defaultIssueComponents
self.customFields = (customFields ?? defaultFeedbackSettings?.customFields) ?? defaultCustomFields
self.reporterAvatarImage = reporterAvatarImage ?? defaultFeedbackSettings?.reporterAvatarImage
self.reporterUsernameOrEmail = reporterUsernameOrEmail ?? defaultFeedbackSettings?.reporterUsernameOrEmail
self.getReporterInfoAsynchronously = getReporterInfoAsynchronously ?? defaultFeedbackSettings?.getReporterInfoAsynchronously
self.navigationBarStyle = navigationBarStyle
}
public init(
issueType: String? = nil,
issueComponents: [String]? = nil,
customFields: [String: AnyObject]? = nil,
navigationBarStyle: NavigationBarStyle = .defaultStyle,
reporterAvatarImage: CGImage? = nil,
reporterUsernameOrEmail: String? = nil,
getReporterInfoAsynchronously: ((@escaping (_ usernameOrEmail: String?, _ avatarImageURL: URL?) -> Void) -> Void)? = nil
) throws {
let target = try defaultFeedbackSettings?.JIRATarget ?? JMCTarget.createTargetFromJSONOnDisk()
self.init(
JIRATarget: target,
issueType: issueType,
issueComponents: issueComponents,
customFields: customFields,
navigationBarStyle: navigationBarStyle,
reporterAvatarImage: reporterAvatarImage,
reporterUsernameOrEmail: reporterUsernameOrEmail,
getReporterInfoAsynchronously: getReporterInfoAsynchronously
)
}
}
public enum JIRAIssueType: String {
case support = "Support"
case bug = "Bug"
case task = "Task"
case improvement = "Improvement"
case story = "Story"
case epic = "Epic"
}

View file

@ -0,0 +1,274 @@
// Copyright © 2020 Australian Government All rights reserved.
import UIKit
final class FeedbackViewController: UIViewController {
@IBOutlet var issueTextView: UITextView!
@IBOutlet var issuePlaceholderLabel: UILabel!
@IBOutlet var emailTextField: UITextField!
@IBOutlet var scrollView: UIScrollView!
@IBOutlet var thankYouView: UIView!
private var sendFeebackAction: SendFeedbackAction?
var settings: FeedbackSettings?
var onDidFinish: (() -> Void)?
var flowNavBarStyle: UIStatusBarStyle = UIApplication.shared.statusBarStyle
enum State {
case idle
case sending
case sent
}
private var state: State = .idle {
didSet {
updateUI()
}
}
private lazy var sendBarButtonItem: UIBarButtonItem = {
let item = UIBarButtonItem(title: "Send", style: .done, target: self, action: #selector(sendButtonTapped))
item.tintColor = .covidSafeColor
return item
}()
private lazy var doneButtonItem: UIBarButtonItem = {
let item = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(doneButtonTapped))
item.tintColor = .covidSafeColor
return item
}()
override func viewDidLoad() {
super.viewDidLoad()
if #available(iOS 13.0, *) {
isModalInPresentation = true
}
setup()
}
@objc func cancel() {
if issueTextView.isFirstResponder {
issueTextView.resignFirstResponder()
}
if emailTextField.isFirstResponder {
emailTextField.resignFirstResponder()
}
}
func presentKeyboard() {
issueTextView.becomeFirstResponder()
}
private func updateUI() {
switch state {
case .idle:
dismissKeyboard()
showSendButton()
issueTextView.isEditable = true
emailTextField.isEnabled = true
case .sending:
showSpinner()
issueTextView.isEditable = false
emailTextField.isEnabled = false
case .sent:
hideCancelButton()
showDoneButton()
showThankYouView()
}
}
private func showSendButton() {
navigationItem.rightBarButtonItem = sendBarButtonItem
}
private func showSpinner() {
let activityView = UIActivityIndicatorView(style: .gray)
activityView.startAnimating()
let spinnerBarItem = UIBarButtonItem(customView: activityView)
navigationItem.rightBarButtonItem = spinnerBarItem
}
private func showDoneButton() {
navigationItem.rightBarButtonItem = doneButtonItem
}
private func hideCancelButton() {
navigationItem.leftBarButtonItem = nil
}
private func showThankYouView() {
UIView.animate(withDuration: 0.3) { [weak self] in
self?.scrollView.isHidden = true
self?.thankYouView.isHidden = false
}
}
private func setup() {
self.title = NSLocalizedString("newFeedbackFlow_navigationTitle",
tableName: "Feedback",
bundle: Bundle.main,
comment: "Title for feedback flow navigation"
)
issueTextView.textContainer.lineFragmentPadding = 0.0
setupDelegates()
setupKeyboardNotifications()
setupBarButtonItems()
}
private func setupDelegates() {
issueTextView.delegate = self
emailTextField.addTarget(self, action: #selector(updateSendButton), for: .editingChanged)
}
private func setupKeyboardNotifications() {
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil)
}
private func setupBarButtonItems() {
navigationItem.rightBarButtonItem = sendBarButtonItem
sendBarButtonItem.isEnabled = false
}
@objc private func updateSendButton() {
sendBarButtonItem.isEnabled = !issueTextView.text.isEmpty && !(emailTextField.text?.isEmpty ?? true)
}
private func updatePlaceholder() {
issuePlaceholderLabel.isHidden = !issueTextView.text.isEmpty
}
@objc private func sendButtonTapped(_ sender: Any) {
guard emailTextField.isValid else {
let errorMessage = "Please enter a valid email address!"
showErrorMessage(errorMessage)
return
}
state = .sending
send()
}
@objc private func doneButtonTapped(_ sender: Any) {
finish()
}
private func finish() {
onDidFinish?()
// Make sure we call the closure only once
onDidFinish = nil
}
private func send() {
guard let settings = settings else {
assertionFailure("Feedback settings not provided, feedback will be lost")
state = .idle
return
}
let deviceInfo = UIDevice.current.infoAsDictionary
let modelName = UIDevice.modelName
let bundleInfo = Bundle.main.infoAsDictionary
var customFields = settings.customFields
if let email = emailTextField.text {
customFields["E-mail"] = email as AnyObject
}
if let osVersion = deviceInfo["systemVersion"] {
customFields["OS version"] = osVersion
}
if let appVersion = bundleInfo["appVersion"] {
customFields["App version"] = appVersion
}
customFields["Phone model"] = modelName as AnyObject
let issue = Issue(
feedback: issueTextView.text,
components: settings.issueComponents,
type: settings.issueType,
customFields: customFields,
reporterUsernameOrEmail: nil
)
let action = SendFeedbackAction(issue: issue, screenshotImageOrNil: nil) { outcome in
switch outcome {
case .success:
self.state = .sent
delayOnMainQueue(2) {
self.finish()
}
case.error:
self.state = .idle
let errorMessage = NSLocalizedString("newFeedback_send_errorMessage",
tableName: "Feedback",
bundle: Bundle.main,
comment: "Generic error message shown when feedback could not be sent"
)
self.showErrorMessage(errorMessage)
case .cancelled:
break
}
}
action.start()
sendFeebackAction = action
}
private func showErrorMessage(_ message: String) {
let alert = AlertController(title: "COVIDSafe", message: message, preferredStyle: .alert)
let okActionTitle = NSLocalizedString("global_ok_button_title",
tableName: "Feedback",
bundle: Bundle.main,
comment: "Title for ok button")
let okAction = UIAlertAction(title: okActionTitle, style: .default)
alert.addAction(okAction)
present(alert, animated: true)
}
@objc func adjustForKeyboard(notification: Notification) {
guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }
let keyboardScreenEndFrame = keyboardValue.cgRectValue
let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window)
if notification.name == UIResponder.keyboardWillHideNotification {
scrollView.contentInset = .zero
} else {
if #available(iOS 11.0, *) {
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardViewEndFrame.height - view.safeAreaInsets.bottom, right: 0)
} else {
scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardViewEndFrame.height, right: 0)
}
}
scrollView.scrollIndicatorInsets = scrollView.contentInset
}
}
func delayOnMainQueue(_ delay: Double, closure:@escaping () -> Void) {
DispatchQueue.main.asyncAfter(
deadline: DispatchTime.now() + Double(Int64(delay * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), execute: closure)
}
extension FeedbackViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
updatePlaceholder()
updateSendButton()
}
}
private extension UITextField {
var isValid: Bool {
let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx)
return emailPred.evaluate(with: text)
}
}

View file

@ -0,0 +1,26 @@
// Copyright © 2020 Australian Government All rights reserved.
import Foundation
class GetJMCTargeAction: AsyncAction {
let onComplete: (Outcome<JMCTarget>) -> Void
init(onComplete: @escaping (Outcome<JMCTarget>) -> Void) {
self.onComplete = onComplete
super.init()
}
override func run() {
do {
let target = try JMCTarget.createTargetFromJSONOnDisk()
finishedExecutingOperationWithOutcome(.success(target))
} catch {
finishedExecutingOperationWithOutcome(.error(error))
}
}
func finishedExecutingOperationWithOutcome(_ outcome: Outcome<JMCTarget>) {
finishedExecutingOperation()
onComplete(outcome)
}
}

View file

@ -0,0 +1,156 @@
// Copyright © 2020 Australian Government All rights reserved.
import UIKit
open class HTTPPostFeedbackAction: AsyncAction {
let issue: Issue
let screenshotImageOrNil: UIImage?
let target: JMCTarget
let onComplete: (Outcome<Void>) -> Void
var issueData: Data!
var customFieldsData: Data!
var issueJSONInfo: [String: NSObject] {
// Any other interesting pieces of data to grab?
let issueInfo = issue.infoAsDictionary
let deviceInfo = UIDevice.current.infoAsDictionary
let languageInfo = NSLocale.infoAsDictionary
let bundleInfo = Foundation.Bundle.main.infoAsDictionary
return [issueInfo, deviceInfo, languageInfo, bundleInfo].reduce([:], +)
}
var customFieldsDataOrNil: Data? {
if issue.customFields.isEmpty {
return nil
} else {
return customFieldsData
}
}
var screenshotImageDataOrNil: Data? {
switch screenshotImageOrNil {
case .some(let screenshotImage):
return screenshotImage.jpegData(compressionQuality: 1.0)
default:
return nil
}
}
public init(issue: Issue, screenshotImageOrNil: UIImage? = nil, target: JMCTarget, onComplete: @escaping (Outcome<Void>) -> Void) {
self.issue = issue
self.screenshotImageOrNil = screenshotImageOrNil
self.target = target
self.onComplete = onComplete
super.init()
}
override open func run() {
// IMPORTANT: Encoding memory threshold is 10 mb by default.
do {
issueData = try serializeIssueData()
customFieldsData = try serializeCustomFieldsData()
} catch {
finishedExecutingOperationWithOutcome(.error(error))
return
}
guard let url = URL(string: target.postIssueURLString) else {
assertionFailure("Cannot create URL from host & path")
return
}
let boundary = UUID().uuidString
var request = URLRequest(url: url)
var data = Data()
data.append(multipartFormData: issueData, withName: "issue", fileName: "issue.json", boundary: boundary, mimeType: "application/json")
if let customFieldData = customFieldsDataOrNil {
data.append(multipartFormData: customFieldData, withName: "customfields", fileName: "customfields.json", boundary: boundary, mimeType: "application/json")
}
if let screenshotImageData = screenshotImageDataOrNil {
data.append(multipartFormData: screenshotImageData, withName: "screenshot", fileName: "screenshot.jpg", boundary: boundary, mimeType: "image/jpeg")
}
data.appendString("--\(boundary)--\r\n")
request.httpMethod = "POST"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("\(data.count)", forHTTPHeaderField:"Content-Length")
request.setValue("-x-jmc-requestid", forHTTPHeaderField: UIDevice.current.identifierForVendorString)
request.httpBody = data
let dataTask = URLSession.shared.dataTask(with: request) { (data, response, error) in
DispatchQueue.main.async {
self.onHTTPResponse(response as? HTTPURLResponse)
}
}
dataTask.resume()
}
func onHTTPResponse(_ HTTPResponseOrNil: HTTPURLResponse?) {
guard let HTTPResponse = HTTPResponseOrNil else {
finishedExecutingOperationWithOutcome(.error(JMCError.nilHTTPResponseError))
return
}
switch HTTPResponse.statusCode {
case let statusCode where statusCode.isSuccessHTTPStatuCode:
finishedExecutingOperationWithOutcome(.success(()))
default:
finishedExecutingOperationWithOutcome(.error(JMCError.httpResponseError))
}
}
func serializeIssueData() throws -> Data {
return try JSONSerialization.data(withJSONObject: issueJSONInfo, options: [])
}
func serializeCustomFieldsData() throws -> Data {
return try JSONSerialization.data(withJSONObject: issue.customFields, options: [])
}
func finishedExecutingOperationWithOutcome(_ outcome: Outcome<Void>) {
finishedExecutingOperation()
onComplete(outcome)
}
}
extension JMCTarget {
var postIssueURLString: String {
return "https://" + host + "/" + "rest/jconnect/" + "1.0" + "/issue/create?" + "project=" + projectKey + "&apikey=" + apiKey
}
}
extension Int {
fileprivate var isSuccessHTTPStatuCode: Bool {
return 200...299 ~= self
}
}
// Add function for combining dictionaries.
private func + <K, V>(lhs: [K : V], rhs: [K : V]) -> [K : V] {
var combined = lhs
for (k, v) in rhs {
combined[k] = v
}
return combined
}
extension Data {
mutating func append(multipartFormData data: Data, withName name: String, fileName: String, boundary: String, mimeType: String) {
appendString("--\(boundary)\r\n")
appendString("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(fileName)\"\r\n")
appendString("Content-Type: \(mimeType)\r\n\r\n")
append(data)
appendString("\r\n")
}
mutating func appendString(_ string: String) {
append(string.data(using: .utf8)!)
}
}

View file

@ -0,0 +1,54 @@
// Copyright © 2020 Australian Government All rights reserved.
import Foundation
let maxSummaryLength = 240
public struct Issue {
let summary: String
let description: String
let components: [String]
let type: String
let customFields: [String: AnyObject]
public init(summary: String, description: String, components: [String], type: String, customFields: [String: AnyObject] = [:], reporterUsernameOrEmail: String? = nil) {
self.summary = summary
self.description = description.withReporterUsernameOrEmailAppended(reporterUsernameOrEmail)
self.components = components
self.type = type
self.customFields = customFields
}
public init(feedback: String, components: [String], type: String, customFields: [String: AnyObject] = [:], reporterUsernameOrEmail: String? = nil) {
switch feedback.unicodeScalars.count {
case let count where count > maxSummaryLength:
let truncatedSummary = String(feedback.unicodeScalars.prefix(maxSummaryLength))
self.init(
summary: truncatedSummary,
description: feedback,
components: components,
type: type,
customFields: customFields,
reporterUsernameOrEmail: reporterUsernameOrEmail
)
default:
self.init(
summary: feedback,
description: feedback,
components: components,
type: type,
customFields: customFields,
reporterUsernameOrEmail: reporterUsernameOrEmail
)
}
}
}
extension String {
fileprivate func withReporterUsernameOrEmailAppended(_ reporterUsernameOrEmail: String?) -> String {
guard let reporterUsernameOrEmail = reporterUsernameOrEmail else {
return self
}
return "\(self) \n\n Submitted by: \(reporterUsernameOrEmail)"
}
}

View file

@ -0,0 +1,12 @@
// Copyright © 2020 Australian Government All rights reserved.
import Foundation
extension Issue {
var infoAsDictionary: [String: NSObject] {
return ["summary": summary as NSObject,
"description": description as NSObject,
"components": components as NSObject,
"type": type as NSObject]
}
}

View file

@ -0,0 +1,15 @@
// Copyright © 2020 Australian Government All rights reserved.
import Foundation
public struct JMCTarget {
let host: String
let apiKey: String
let projectKey: String
public init(host: String, apiKey: String, projectKey: String) {
self.host = host
self.apiKey = apiKey
self.projectKey = projectKey
}
}

View file

@ -0,0 +1,45 @@
// Copyright © 2020 Australian Government All rights reserved.
import Foundation
extension JMCTarget {
static let JSONFileName = "JMCTarget"
public static func createTargetFromJSONOnDisk() throws -> JMCTarget {
guard let JMCTargetJSONPath = Foundation.Bundle.main.path(forResource: JSONFileName, ofType: "json") else {
throw JMCTargetJSONOnDiskError.jsonFileMissingFromBundleError
}
return try JMCTarget(JSONFilePath: JMCTargetJSONPath)
}
init(JSONFilePath: String) throws {
guard let JMCTargetData = try? Data(contentsOf: URL(fileURLWithPath: JSONFilePath)) else {
throw JMCTargetJSONOnDiskError.readJSONFileError
}
try self.init(JSONData: JMCTargetData)
}
init(JSONData: Data) throws {
let JSONObject = try JSONSerialization.jsonObject(with: JSONData, options: [])
guard let JMCTargetDictionary = JSONObject as? [String : NSObject] else {
throw JMCTargetJSONOnDiskError.jsonDictionaryToInstanceError
}
try self.init(JSONDictionary: JMCTargetDictionary )
}
init(JSONDictionary: [String: NSObject]) throws {
guard let host = JSONDictionary["host"] as? String,
let apiKey = JSONDictionary["apiKey"] as? String,
let projectKey = JSONDictionary["projectKey"] as? String else {
throw JMCTargetJSONOnDiskError.jsonDictionaryToInstanceError
}
self.init(host: host, apiKey: apiKey, projectKey: projectKey)
}
}
public enum JMCTargetJSONOnDiskError: Error {
case jsonFileMissingFromBundleError
case readJSONFileError
case jsonDictionaryToInstanceError
}

View file

@ -0,0 +1,31 @@
// Copyright © 2020 Australian Government All rights reserved.
import Foundation
extension NSLocale {
class var languageDisplayNameOrNil: String? {
// Consider going to NSBundle preferredLocalizations to get language the user is actually seeing in the running app.
guard let languageCode = preferredLanguages[safe: 0] else {
return nil
}
return current.localizedString(forLanguageCode: languageCode)
}
class var preferredLanguageDisplayName: String? {
guard let languageDisplayName = languageDisplayNameOrNil else {
return nil
}
return languageDisplayName
}
class var infoAsDictionary: [String: NSObject] {
return ["language": preferredLanguageDisplayName as NSObject? ?? "" as NSObject]
}
}
extension Array {
fileprivate subscript (safe index: UInt) -> Element? {
return Int(index) < count ? self[Int(index)] : nil
}
}

View file

@ -0,0 +1,9 @@
// Copyright © 2020 Australian Government All rights reserved.
import Foundation
extension String {
var formattedLoggingStatement: String {
return "JIRA Mobile Connect: \(self)"
}
}

View file

@ -0,0 +1,168 @@
// Copyright © 2020 Australian Government All rights reserved.
import UIKit
class NewFeedbackFlowController: UINavigationController {
let newFeedbackViewController: FeedbackViewController
var leftBarButtonItem: UIBarButtonItem!
var originalStatusBarStyle: UIStatusBarStyle?
var onDidFinish: (() -> Void)?
let navBarStyle: NavigationBarStyle
let shouldUseCustomTransition: Bool
init(screenshot: UIImage?, settings: FeedbackSettings? = nil) throws {
newFeedbackViewController = try NewFeedbackFlowViewControllerFactory()
.createNewFeedbackViewControllerWithScreenshotImage(screenshot, settings: settings)
navBarStyle = settings?.navigationBarStyle ?? .defaultStyle
shouldUseCustomTransition = screenshot != nil
super.init(nibName: nil, bundle: nil)
newFeedbackViewController.onDidFinish = {
self.dismissFeedbackFlow()
}
viewControllers = [newFeedbackViewController]
leftBarButtonItem = self.createCancelBarButtonItem()
newFeedbackViewController.navigationItem.leftBarButtonItem = leftBarButtonItem
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) is not supported by NewFeedbackFlowController")
}
}
extension NewFeedbackFlowController {
// MARK: View Lifecycle & UIKit
override func viewDidLoad() {
super.viewDidLoad()
transitioningDelegate = self
newFeedbackViewController.flowNavBarStyle = UIApplication.shared.statusBarStyle
navigationBar.style(navigationBarStyle: navBarStyle)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
applyStatusBarStyleIfNeeded()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
returnStatusBarStyleToOriginalValueIfNeeded()
}
// Feedback flow considered finished when this view controller's view has disappeared.
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.onDidFinish?()
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return navBarStyle.statusBarStyle
}
func applyStatusBarStyleIfNeeded() {
if Foundation.Bundle.main.viewControllerBasedStatusBarAppearance == false {
originalStatusBarStyle = UIApplication.shared.statusBarStyle
newFeedbackViewController.flowNavBarStyle = navBarStyle.statusBarStyle
}
}
func returnStatusBarStyleToOriginalValueIfNeeded() {
if Foundation.Bundle.main.viewControllerBasedStatusBarAppearance == false {
if let originalStatusBarStyle = originalStatusBarStyle {
newFeedbackViewController.flowNavBarStyle = originalStatusBarStyle
}
}
}
}
extension NewFeedbackFlowController {
// MARK: User Actions
@objc func cancel() {
newFeedbackViewController.cancel()
dismissFeedbackFlow()
}
func presentKeyboard() {
newFeedbackViewController.presentKeyboard()
}
func dismissFeedbackFlow() {
if let actionController = self.presentingViewController as? UIAlertController {
if let presentingViewController = actionController.presentingViewController {
actionController.view.isHidden = true
presentingViewController.dismiss(animated: false) {
self.onDidFinish?()
actionController.view.isHidden = false
presentingViewController.present(actionController, animated: true, completion: nil)
}
return
}
}
presentingViewController?.dismiss(animated: true) {
self.onDidFinish?()
}
}
}
extension NewFeedbackFlowController {
// MARK: Factories
func createCancelBarButtonItem() -> UIBarButtonItem {
let buttonTitle = NSLocalizedString("global_cancel_button_title",
tableName: "Feedback",
bundle: Bundle.main,
comment: "Cancel button title"
)
let item = UIBarButtonItem(
title: buttonTitle,
style: .plain,
target: self,
action: #selector(NewFeedbackFlowController.cancel)
)
item.tintColor = .covidSafeColor
return item
}
}
extension NewFeedbackFlowController: UIViewControllerTransitioningDelegate {
// MARK: Custom Transition Presentation
func animationController(
forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return nil
}
}
private class NewFeedbackFlowViewControllerFactory {
let flowStoryboard = UIStoryboard(name: "NewFeedbackFlow", bundle: Bundle.main)
func createNewFeedbackViewControllerWithScreenshotImage(
_ screenshotImage: UIImage?,
settings: FeedbackSettings? = nil) throws -> FeedbackViewController {
let id = "FeedbackViewController"
// swiftlint:disable:next force_cast
let vc = flowStoryboard.instantiateViewController(withIdentifier: id) as! FeedbackViewController
vc.settings = try settings ?? FeedbackSettings()
return vc
}
}
extension Foundation.Bundle {
fileprivate var viewControllerBasedStatusBarAppearance: Bool {
return (infoDictionary?["UIViewControllerBasedStatusBarAppearance"] as? Bool) ?? true
}
}

View file

@ -0,0 +1,35 @@
// Copyright © 2020 Australian Government All rights reserved.
import Foundation
public typealias VoidOutcome = Outcome<Void>
public enum Outcome<T> {
case success(T)
case error(Error)
case cancelled
public init(resultOrNil: T?, errorOrNil: Error?) {
if let error = errorOrNil {
self = .error(error)
return
}
if let result = resultOrNil {
self = .success(result)
return
}
self = .error(ProgrammerError.encounteredNilResultAndNilErrorOutcome)
}
public init<A>(somethingOrNothing: A?, resultIfSomething: T, errorIfNothing: Error) {
if somethingOrNothing != nil {
self = .success(resultIfSomething)
} else {
self = .error(errorIfNothing)
}
}
}
enum ProgrammerError: Error {
case encounteredNilResultAndNilErrorOutcome
}

View file

@ -0,0 +1,150 @@
// Copyright © 2020 Australian Government All rights reserved.
import UIKit
private var feedbackNotOnScreen = true
extension UIWindow {
public func presentFeedback(_ promptUser: Bool = false, settings: FeedbackSettings? = nil) {
guard let presentedViewController = rootViewController?.topmostPresentedViewController else {
print("\(self), Cannot present feedback prompt because window does not have a presented view controller.".formattedLoggingStatement)
return
}
guard feedbackNotOnScreen else {
return
}
presentedViewController.presentFeedback( promptUser, settings: settings)
}
}
extension UIViewController {
func presentFeedbackWithPromptAndScreenshotTransition(_ settings: FeedbackSettings? = nil) {
guard feedbackNotOnScreen else {
return
}
let vc = ViewControllerFactory().createPropmtController(true, settings: settings)
self.topmostPresentedViewController.present(vc, animated: true, completion: nil)
}
func presentNewFeedbackFlowWithScreenshotTransition(_ settings: FeedbackSettings? = nil) {
guard let window = view.window else {
return
}
guard feedbackNotOnScreen else {
return
}
feedbackNotOnScreen = false
ViewControllerFactory().createNewFeedbackFlowControllerForScreenshotView(window, settings: settings, onFlowDidFinish: { [weak self] in
self?.flowDidFinish()
}) { flowController in
self.topmostPresentedViewController.present(flowController, animated: true) {
if !flowController.shouldUseCustomTransition {
flowController.presentKeyboard()
}
}
}
}
@objc // This method should only be used from ObjC
public func presentFeedback(
_ promptUser: Bool = false,
issueType: String? = nil,
issueComponents: [String]? = nil,
reporterAvatarImage: CGImage? = nil,
reporterUsernameOrEmail: String? = nil
) {
guard let settings = try? FeedbackSettings(
issueType: issueType,
issueComponents: issueComponents,
reporterAvatarImage:
reporterAvatarImage,
reporterUsernameOrEmail:
reporterUsernameOrEmail
) else {
return
}
guard feedbackNotOnScreen else {
return
}
presentFeedback( promptUser, settings: settings)
}
public func presentFeedback(_ promptUser: Bool = false, settings: FeedbackSettings? = nil) {
guard feedbackNotOnScreen else {
return
}
if promptUser {
let vc = ViewControllerFactory().createPropmtController(false, settings: settings)
self.topmostPresentedViewController.present(vc, animated: true, completion: nil)
} else {
self.presentNewFeedbackFlow(settings)
}
}
func presentNewFeedbackFlow(_ settings: FeedbackSettings? = nil) {
guard feedbackNotOnScreen else {
return
}
feedbackNotOnScreen = false
ViewControllerFactory().createNewFeedbackFlowController(settings, onFlowDidFinish: { [weak self] in
self?.flowDidFinish()
}) { flowController in
self.topmostPresentedViewController.present(flowController, animated: true) {
flowController.presentKeyboard()
}
}
}
private func flowDidFinish() {
guard Thread.isMainThread else {
DispatchQueue.main.async(execute: flowDidFinish)
return
}
feedbackNotOnScreen = true
}
}
extension AlertController {
func addNewFeedbackFlowAction() {
let title = NSLocalizedString("entryPrompt_newFeedback_button_title",
tableName: "Feedback",
bundle: Bundle.main,
comment: "Button title for button that launches new feedback flow"
)
self.addDefaultAction(localizedTitle: title) { [weak self] _ in
if let strongSelf = self {
if strongSelf.useCustomTransition {
strongSelf.presentNewFeedbackFlowWithScreenshotTransition_1()
} else {
strongSelf.presentNewFeedbackFlow_1()
}
}
}
}
func presentNewFeedbackFlowWithScreenshotTransition_1() {
guard let presentingViewController = self.lastPresentingViewController else {
return
}
presentingViewController.presentNewFeedbackFlowWithScreenshotTransition(self.feedbackSettings)
}
func presentNewFeedbackFlow_1() {
guard let presentingViewController = self.lastPresentingViewController else {
return
}
presentingViewController.presentNewFeedbackFlow(self.feedbackSettings)
}
}

View file

@ -0,0 +1,46 @@
// Copyright © 2020 Australian Government All rights reserved.
import UIKit
open class SendFeedbackAction: AsyncAction {
let issue: Issue
let screenshotImageOrNil: UIImage?
let onComplete: (Outcome<Void>) -> Void
var getTargetAction: GetJMCTargeAction!
var postFeedbackAction: HTTPPostFeedbackAction!
public init(issue: Issue, screenshotImageOrNil: UIImage? = nil, onComplete: @escaping (Outcome<Void>) -> Void) {
self.issue = issue
self.screenshotImageOrNil = screenshotImageOrNil
self.onComplete = onComplete
super.init()
}
override open func run() {
getTargetAction = GetJMCTargeAction { outcome in
switch outcome {
case .success(let JMCTarget):
self.postFeedbackToTarget(JMCTarget)
case .error(let error):
self.finishedExecutingOperationWithOutcome(.error(error))
case .cancelled:
break
}
}
getTargetAction.start()
}
open func postFeedbackToTarget(_ target: JMCTarget) {
postFeedbackAction = HTTPPostFeedbackAction(issue: issue, screenshotImageOrNil: screenshotImageOrNil, target: target) { outcome in
self.finishedExecutingOperationWithOutcome(outcome)
}
postFeedbackAction.start()
}
func finishedExecutingOperationWithOutcome(_ outcome: Outcome<Void>) {
finishedExecutingOperation()
onComplete(outcome)
}
}

View file

@ -0,0 +1,41 @@
//Copyright © 2016 Australian Government All rights reserved.
import UIKit
extension UIColor {
/// Hex sRGB color initializer.
///
/// - parameter hex: Pass in a sRGB color integer using hex notation, i.e. 0xFFFFFF. Make sure to only include 6 hex digits.
///
/// - returns: Initialized opaque UIColor, i.e. alpha is set to 1.0.
public convenience init(_ hex: Int) {
assert(
0...0xFFFFFF ~= hex,
"Hex value given to UIColor initializer should only include RGB values, i.e. the hex value should have six digits." //swiftlint:disable:this line_length
)
let red = (hex & 0xFF0000) >> 16
let green = (hex & 0x00FF00) >> 8
let blue = (hex & 0x0000FF)
self.init(red: red, green: green, blue: blue)
}
/// RGB integer color initializer.
///
/// - parameter red: Red component as integer. In iOS 9 or below, this value should be between 0 and 255. iOS 10
/// and above uses an extended color space to support wide color.
/// - parameter green: Green component as integer. In iOS 9 or below, this value should be between 0 and 255. iOS 10
/// and above uses an extended color space to support wide color.
/// - parameter blue: Blue component as integer. In iOS 9 or below, this value should be between 0 and 255. iOS 10
/// and above uses an extended color space to support wide color.
///
/// - returns: Initialized opaque UIColor, i.e. alpha is set to 1.0.
public convenience init(red: Int, green: Int, blue: Int) {
self.init(
red: CGFloat(red) / 255.0,
green: CGFloat(green) / 255.0,
blue: CGFloat(blue) / 255.0,
alpha: 1.0
)
}
}

View file

@ -0,0 +1,38 @@
// Copyright © 2020 Australian Government All rights reserved.
import UIKit
// MARK: Localization extensions
extension AlertController {
static func createAlertSheetController(localizedTitle: String, localizedMessage: String) -> AlertController {
return AlertController(title: localizedTitle, message: localizedMessage, preferredStyle: .actionSheet)
}
static func createAlertController(localizedTitle: String, localizedMessage: String) -> AlertController {
return AlertController(title: localizedTitle, message: localizedMessage, preferredStyle: .alert)
}
}
extension UIAlertAction {
convenience init(localizedTitle: String, style: UIAlertAction.Style, handler: ((UIAlertAction) -> Void)?) {
self.init(title: localizedTitle, style: style, handler: handler)
}
}
// MARK: UIAlertController cancel extension
extension UIAlertController {
func addCancelAction() {
let cancelActionTitle = NSLocalizedString("global_cancel_button_title",
tableName: "Feedback",
bundle: Bundle.main,
comment: "Cancel button title"
)
let cancelAction = UIAlertAction(title: cancelActionTitle, style: .cancel, handler: nil)
self.addAction(cancelAction)
}
func addDefaultAction(localizedTitle: String, handler: @escaping ((UIAlertAction) -> Void)) {
let defaultAction = UIAlertAction(localizedTitle: localizedTitle, style: .default, handler: handler)
self.addAction(defaultAction)
}
}

View file

@ -0,0 +1,16 @@
// Copyright © 2020 Australian Government All rights reserved.
import UIKit
extension UINavigationBar {
func style(navigationBarStyle: NavigationBarStyle) {
isTranslucent = false
barTintColor = navigationBarStyle.backgroundColor
tintColor = navigationBarStyle.tintColor
backgroundColor = navigationBarStyle.backgroundColor
titleTextAttributes = [
NSAttributedString.Key.foregroundColor: navigationBarStyle.titleTextColor,
NSAttributedString.Key.font: navigationBarStyle.titleFont
]
}
}

View file

@ -0,0 +1,15 @@
// Copyright © 2017 Australian Government All rights reserved.
import UIKit
extension UIWindow {
public var topmostPresentedViewController: UIViewController? {
return rootViewController?.topmostPresentedViewController
}
}
extension UIViewController {
public var topmostPresentedViewController: UIViewController {
return presentedViewController?.topmostPresentedViewController ?? self
}
}

View file

@ -0,0 +1,57 @@
// Copyright © 2020 Australian Government All rights reserved.
import UIKit
class ViewControllerFactory {
func createPropmtController(_ useCustomTransition: Bool, settings: FeedbackSettings? = nil) -> UIViewController {
let title = NSLocalizedString("entryPrompt_alert_title",
tableName: "Feedback",
bundle: Bundle.main,
comment: "Title for initial alert when feedback is launched"
)
let message = NSLocalizedString("entryPrompt_alert_message",
tableName: "Feedback",
bundle: Bundle.main,
comment: "Prompt message for initial alert when feedback is launched"
)
let alertController: AlertController
if UIScreen.main.traitCollection.horizontalSizeClass == .regular {
alertController = AlertController.createAlertController(localizedTitle: title, localizedMessage: message)
} else {
alertController = AlertController.createAlertSheetController(localizedTitle: title, localizedMessage: message)
}
alertController.feedbackSettings = settings
alertController.addNewFeedbackFlowAction()
alertController.addCancelAction()
alertController.useCustomTransition = useCustomTransition
return alertController
}
func createNewFeedbackFlowControllerForScreenshotView(
_ viewForScreenshot: UIView,
settings: FeedbackSettings? = nil,
onFlowDidFinish: (() -> Void)? = nil,
onComplete: @escaping (NewFeedbackFlowController) -> Void
) {
// No-op
}
func createNewFeedbackFlowController(
_ settings: FeedbackSettings? = nil,
onFlowDidFinish: (() -> Void)? = nil,
onComplete: (NewFeedbackFlowController) -> Void
) {
do {
let flowController = try NewFeedbackFlowController(screenshot: nil, settings: settings)
flowController.onDidFinish = onFlowDidFinish
onComplete(flowController)
} catch {
assertionFailure("\(error)".formattedLoggingStatement)
onFlowDidFinish?()
}
}
}