From 8e91500f9a123f6a7335a3095712e2ae840b010c Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Mon, 29 Jul 2024 15:24:54 +0200 Subject: [PATCH] feat: adds action to open app in app store --- Examples/ExampleSwiftUI/ContentView.swift | 2 +- Examples/ExampleSwiftUI/MainApp.swift | 5 +- Examples/ExampleUIKit/AppDelegate.swift | 8 ++- Examples/ExampleUIKit/SceneDelegate.swift | 7 +- Examples/ExampleUIKit/ViewController.swift | 6 +- README.md | 6 +- .../OnLaunch/API/ApiMessageResponseDto.swift | 25 ++++--- Sources/OnLaunch/Models/Action.swift | 13 ++-- Sources/OnLaunch/Models/Message.swift | 13 ++-- Sources/OnLaunch/OnLaunch.swift | 65 +++++++++++-------- Sources/OnLaunch/Storage/LocalStorage.swift | 3 +- Sources/OnLaunch/SwiftUI/View+OnLaunch.swift | 8 +-- .../OnLaunch/Theme/Environment+Theme.swift | 3 +- Sources/OnLaunch/Theme/Theme.swift | 7 +- .../UI/Components/MessageActionView.swift | 63 ++++++++++++++++++ Sources/OnLaunch/UI/MessageView.swift | 55 +++++++++------- Sources/OnLaunch/Utils/OSLog+OnLaunch.swift | 4 +- Sources/OnLaunch/Utils/OnLaunchError.swift | 5 +- Tests/OnLaunchTests/RequestContextTests.swift | 3 +- 19 files changed, 188 insertions(+), 113 deletions(-) create mode 100644 Sources/OnLaunch/UI/Components/MessageActionView.swift diff --git a/Examples/ExampleSwiftUI/ContentView.swift b/Examples/ExampleSwiftUI/ContentView.swift index 2de6405..e5cdeed 100644 --- a/Examples/ExampleSwiftUI/ContentView.swift +++ b/Examples/ExampleSwiftUI/ContentView.swift @@ -1,5 +1,5 @@ -import SwiftUI import OnLaunch +import SwiftUI struct ContentView: View { var body: some View { diff --git a/Examples/ExampleSwiftUI/MainApp.swift b/Examples/ExampleSwiftUI/MainApp.swift index 8c32872..2b685c3 100644 --- a/Examples/ExampleSwiftUI/MainApp.swift +++ b/Examples/ExampleSwiftUI/MainApp.swift @@ -1,5 +1,5 @@ -import SwiftUI import OnLaunch +import SwiftUI @main struct MainApp: App { @@ -12,6 +12,9 @@ struct MainApp: App { // (Optional) Configure a custom base URL to your API host // options.baseURL = "https://your-domain.com/api" + + // (Optional) Configure the App Store id, required by the action "OPEN_IN_APP_STORE" + options.appStoreId = 409_201_541 } } } diff --git a/Examples/ExampleUIKit/AppDelegate.swift b/Examples/ExampleUIKit/AppDelegate.swift index 89a482a..f67c0d8 100644 --- a/Examples/ExampleUIKit/AppDelegate.swift +++ b/Examples/ExampleUIKit/AppDelegate.swift @@ -2,9 +2,11 @@ import UIKit @main class AppDelegate: UIResponder, UIApplicationDelegate { - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + func application( + _: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options _: UIScene.ConnectionOptions + ) -> UISceneConfiguration { return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } } - diff --git a/Examples/ExampleUIKit/SceneDelegate.swift b/Examples/ExampleUIKit/SceneDelegate.swift index 09b944e..7c4de52 100644 --- a/Examples/ExampleUIKit/SceneDelegate.swift +++ b/Examples/ExampleUIKit/SceneDelegate.swift @@ -1,8 +1,7 @@ -import UIKit import OnLaunch +import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? func sceneDidBecomeActive(_ scene: UIScene) { @@ -17,7 +16,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // (Optional) Configure a custom base URL to your API host // options.baseURL = "https://your-domain.com/api" + + // (Optional) Configure the App Store id, required by the action 'OPEN_IN_APP_STORE' + options.appStoreId = 409_201_541 } } } - diff --git a/Examples/ExampleUIKit/ViewController.swift b/Examples/ExampleUIKit/ViewController.swift index 5cf2fb1..c5b2d90 100644 --- a/Examples/ExampleUIKit/ViewController.swift +++ b/Examples/ExampleUIKit/ViewController.swift @@ -1,10 +1,8 @@ -import UIKit import OnLaunch +import UIKit class ViewController: UIViewController { - - @IBAction func checkForMessagesActions(_ sender: Any) { + @IBAction func checkForMessagesActions(_: Any) { OnLaunch.check() } } - diff --git a/README.md b/README.md index ef3b43a..ee35632 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Created and maintained by kula.app and all the amazing contributors.

-[OnLaunch](https://github.com/kula-app/OnLaunch) is a service allowing app developers to notify app users about updates, warnings and maintenance +[OnLaunch](https://github.com/kula-app/OnLaunch) is a service allowing app developers to notify app users about updates, warnings and maintenance. Our open-source framework provides an easy-to-integrate client to communicate with the backend and display the user interface.

@@ -135,6 +135,10 @@ The OnLaunch iOS client provides a couple of configuration options: | `hostScene` | Scene used to host the OnLaunch client UI. Required if you use UIKit with scenes | | | `hostViewController` | An instance of `UIViewController` used to host the OnLaunch client UI. Required if you use UIKit without scenes | | | `theme` | Custom theme used by the OnLaunch client UI. Adapt the values to change the theme to match your preferences. To see all possible configuration values, see [`Theme.swift`](https://github.com/kula-app/OnLaunch-iOS-Client/blob/main/Sources/OnLaunch/Theme/Theme.swift) | Default values as defined in `Theme.standard` in [Theme.swift](https://github.com/kula-app/OnLaunch-iOS-Client/blob/main/Sources/OnLaunch/Theme/Theme.swift) | +| `bundleId` | Bundle identifier used by server-side rules. | Uses value set in `Bundle.main.bundleIdentifier` | +| `bundleVersion` | Version of the build that identifies an iteration of the bundle. | `CFBundleVersion` defined in `Info.plist` | +| `releaseVersion` | Release or version number of the bundle. | `CFBundleShortVersionString` defined in `Info.plist` | +| `appStoreId` | ID of the app in the App Store. If not defined, the `ActionType.openInAppStore` will throw a non-crashing assertion | | ## Contributing Guide diff --git a/Sources/OnLaunch/API/ApiMessageResponseDto.swift b/Sources/OnLaunch/API/ApiMessageResponseDto.swift index 38ab2c6..8cf3f2b 100644 --- a/Sources/OnLaunch/API/ApiMessageResponseDto.swift +++ b/Sources/OnLaunch/API/ApiMessageResponseDto.swift @@ -1,23 +1,20 @@ import Foundation -internal struct ApiMessageResponseDto: Decodable { - - internal struct Action: Decodable { - - internal enum ActionType: String, Decodable { +struct ApiMessageResponseDto: Decodable { + struct Action: Decodable { + enum ActionType: String, Decodable { case button = "BUTTON" case dismissButton = "DISMISS" + case openInAppStore = "OPEN_IN_APP_STORE" } - internal let actionType: ActionType - internal let title: String - + let actionType: ActionType + let title: String } - internal let id: Int - internal let blocking: Bool - internal let title: String - internal let body: String - internal let actions: [Action] - + let id: Int + let blocking: Bool + let title: String + let body: String + let actions: [Action] } diff --git a/Sources/OnLaunch/Models/Action.swift b/Sources/OnLaunch/Models/Action.swift index 2a73b67..14106e2 100644 --- a/Sources/OnLaunch/Models/Action.swift +++ b/Sources/OnLaunch/Models/Action.swift @@ -1,19 +1,20 @@ /// Structure used to define an user-interactable action -internal struct Action { - +struct Action { /// Different kinds of actions, used to implement different behaviour - internal enum Kind { - + enum Kind { /// Action is implemented as a generic button with an associated action case button /// Action is represented as a button which dismisses the UI case dismissButton + + /// Action is dedicated to open the current app's App Store Page + case openAppInAppStore } /// Kind of the action - internal let kind: Kind + let kind: Kind /// Title of the action used to display in the UI - internal let title: String + let title: String } diff --git a/Sources/OnLaunch/Models/Message.swift b/Sources/OnLaunch/Models/Message.swift index 8025d62..bb2786c 100644 --- a/Sources/OnLaunch/Models/Message.swift +++ b/Sources/OnLaunch/Models/Message.swift @@ -1,22 +1,21 @@ import Foundation /// Structure of a message to be displayed to the user -internal struct Message: Identifiable { - +struct Message: Identifiable { /// Unique identifier of this message. /// /// This identifier is used to track if the message has already been presented to the user - internal let id: Int + let id: Int /// Title of the message - internal let title: String + let title: String /// Content of the message - internal let body: String + let body: String /// Flag indicating if this message is blocking the user - internal let isBlocking: Bool + let isBlocking: Bool /// Actions for the user to interact with - internal let actions: [Action] + let actions: [Action] } diff --git a/Sources/OnLaunch/OnLaunch.swift b/Sources/OnLaunch/OnLaunch.swift index 2e685ab..0e089a8 100644 --- a/Sources/OnLaunch/OnLaunch.swift +++ b/Sources/OnLaunch/OnLaunch.swift @@ -1,15 +1,13 @@ +import Combine import OSLog import SwiftUI import UIKit -import Combine public class OnLaunch: NSObject { - // MARK: - Types /// Options used to control the behaviour of OnLaunch public class Options { - /// Base URL where the OnLaunch API is hosted at. public var baseURL = "https://onlaunch.kula.app/api/" @@ -31,23 +29,27 @@ public class OnLaunch: NSObject { public var theme = Theme.standard /// Internal flag used to indicate that the SwiftUI host system is used - internal var isSwiftUIHost = false + var isSwiftUIHost = false /// Bundle identifier used by server-side rules. /// /// If not defined, it will fallback to `Bundle.main.bundleIdentifier` public var bundleId: String? - /// The version of the build that identifies an iteration of the bundle. + /// Version of the build that identifies an iteration of the bundle. /// /// If not defined, it will fallback to the `CFBundleVersion` defined in `Info.plist` public var bundleVersion: String? - /// The release or version number of the bundle. + /// Release or version number of the bundle. /// /// If not defined, it will fallback to the `CFBundleShortVersionString` defined in `Info.plist` public var releaseVersion: String? + /// ID of the app in the App Store + /// + /// If not defined, the ``ActionType.openInAppStore`` will throw a non-crashing assertion + public var appStoreId: Int? } /// Closure used to modify the given options instance @@ -69,7 +71,7 @@ public class OnLaunch: NSObject { let client = try OnLaunch(options: options) try client.configure() - self.shared = client + shared = client if options.shouldCheckOnConfigure { check() @@ -94,18 +96,18 @@ public class OnLaunch: NSObject { // MARK: - Internal Static State - internal static var shared: OnLaunch? + static var shared: OnLaunch? // MARK: - Internal State /// Options as defined by the library user - internal var options: Options + var options: Options /// Instance used to store data - internal let storage: LocalStorage + let storage: LocalStorage /// The `URLSession` used to send API requests - internal let session: URLSession + let session: URLSession /// URL used as the reference for all API calls, e.g. `https://onlaunch.kula.app/api` private let baseURL: URL @@ -114,14 +116,14 @@ public class OnLaunch: NSObject { private var messageQueue: [Message] = [] /// The message which is currently presented - internal var currentMessage = CurrentValueSubject(nil) + var currentMessage = CurrentValueSubject(nil) /// Completion handler used by the SwiftUI implementation - private var swiftUIDismissCompletionHandler: () -> Void = { } + private var swiftUIDismissCompletionHandler: () -> Void = {} // MARK: - Internal Initializer - internal init(options: Options, storage: LocalStorage = LocalStorage(), session: URLSession = URLSession(configuration: .ephemeral)) throws { + init(options: Options, storage: LocalStorage = LocalStorage(), session: URLSession = URLSession(configuration: .ephemeral)) throws { self.options = options guard let baseURL = URL(string: options.baseURL) else { throw OnLaunchError.invalidBaseURL(options.baseURL) @@ -135,14 +137,21 @@ public class OnLaunch: NSObject { // MARK: - Internal Methods /// Helper function used to setup the completion handler for SwiftUI hosted views - internal func swiftUIContainerViewFor(message: Message) -> some View { - containerViewFor(message: message, completionHandler: swiftUIDismissCompletionHandler) + func swiftUIContainerViewFor(message: Message) -> some View { + containerViewFor( + message: message, + completionHandler: swiftUIDismissCompletionHandler + ) } /// Creates the fully configured message view for the given `message` private func containerViewFor(message: Message, completionHandler: @escaping () -> Void) -> some View { - MessageView(message: message, completionHandler: completionHandler) - .environment(\.theme, options.theme) + MessageView( + message: message, + options: options, + completionHandler: completionHandler + ) + .environment(\.theme, options.theme) } // MARK: - Private Methods @@ -209,15 +218,17 @@ public class OnLaunch: NSObject { body: message.body, isBlocking: message.blocking, actions: message.actions.compactMap { action in - let kind: Action.Kind - switch action.actionType { - case .button: - kind = .button - case .dismissButton: - kind = .dismissButton - } - return Action(kind: kind, title: action.title) - }) + let kind: Action.Kind + switch action.actionType { + case .button: + kind = .button + case .dismissButton: + kind = .dismissButton + case .openInAppStore: + kind = .openAppInAppStore + } + return Action(kind: kind, title: action.title) + }) } let filteredMessages = messages.filter { message in // Only include messages which are blocking or have not already been presented diff --git a/Sources/OnLaunch/Storage/LocalStorage.swift b/Sources/OnLaunch/Storage/LocalStorage.swift index 1cd7b47..e11487e 100644 --- a/Sources/OnLaunch/Storage/LocalStorage.swift +++ b/Sources/OnLaunch/Storage/LocalStorage.swift @@ -1,8 +1,7 @@ import Foundation import os.log -internal class LocalStorage { - +class LocalStorage { private let defaults: UserDefaults init(defaults: UserDefaults = .init(suiteName: "OnLaunch")!) { diff --git a/Sources/OnLaunch/SwiftUI/View+OnLaunch.swift b/Sources/OnLaunch/SwiftUI/View+OnLaunch.swift index 4ea0077..f7e4ef6 100644 --- a/Sources/OnLaunch/SwiftUI/View+OnLaunch.swift +++ b/Sources/OnLaunch/SwiftUI/View+OnLaunch.swift @@ -2,7 +2,6 @@ import SwiftUI /// Modifier used to integrate OnLaunch with SwiftUI public struct OnLaunchModifier: ViewModifier { - /// Environment value used to track for scene updates @Environment(\.scenePhase) private var scenePhase @@ -55,10 +54,9 @@ public struct OnLaunchModifier: ViewModifier { } } -extension View { - +public extension View { /// Configures OnLaunch with the given `configurationHandler` and conditionally presents the OnLaunch UI - public func configureOnLaunch(_ configurationHandler: @escaping OnLaunch.ConfigurationHandler) -> some View { - self.modifier(OnLaunchModifier(configurationHandler: configurationHandler)) + func configureOnLaunch(_ configurationHandler: @escaping OnLaunch.ConfigurationHandler) -> some View { + modifier(OnLaunchModifier(configurationHandler: configurationHandler)) } } diff --git a/Sources/OnLaunch/Theme/Environment+Theme.swift b/Sources/OnLaunch/Theme/Environment+Theme.swift index 1225b1c..8ace739 100644 --- a/Sources/OnLaunch/Theme/Environment+Theme.swift +++ b/Sources/OnLaunch/Theme/Environment+Theme.swift @@ -5,8 +5,7 @@ private struct ThemeEnvironmentKey: EnvironmentKey { } extension EnvironmentValues { - - internal var theme: Theme { + var theme: Theme { get { self[ThemeEnvironmentKey.self] } diff --git a/Sources/OnLaunch/Theme/Theme.swift b/Sources/OnLaunch/Theme/Theme.swift index ff720b5..3cc760a 100644 --- a/Sources/OnLaunch/Theme/Theme.swift +++ b/Sources/OnLaunch/Theme/Theme.swift @@ -18,10 +18,8 @@ public struct Theme { } public struct Action { - /// Font used for the title of the action buttons public var font: Font - } /// Theme configuration of the message title @@ -34,10 +32,9 @@ public struct Theme { public var action: Action } -extension Theme { - +public extension Theme { /// Default theme unless something different is configured - public static let standard = Theme( + static let standard = Theme( title: .init( font: Font.system(size: 34, weight: .bold), color: Color(UIColor.label) diff --git a/Sources/OnLaunch/UI/Components/MessageActionView.swift b/Sources/OnLaunch/UI/Components/MessageActionView.swift new file mode 100644 index 0000000..9fbe9b6 --- /dev/null +++ b/Sources/OnLaunch/UI/Components/MessageActionView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct MessageActionView: View { + @Environment(\.theme) private var theme: Theme + + let action: Action + let dismiss: () -> Void + let openAppInAppStore: () -> Void + + init(action: Action, dismiss: @escaping () -> Void, openAppInAppStore: @escaping () -> Void) { + self.action = action + self.dismiss = dismiss + self.openAppInAppStore = openAppInAppStore + } + + @ViewBuilder + var body: some View { + switch action.kind { + case .button: + buttonView + case .dismissButton: + dismissButtonView + case .openAppInAppStore: + openAppInAppStoreButtonView + } + } + + var buttonView: some View { + Button(action: { + dismiss() + }, label: { + Text(action.title) + .font(theme.action.font) + .frame(maxWidth: .infinity) + .frame(minHeight: 50) + }) + .buttonStyle(.borderedProminent) + } + + var dismissButtonView: some View { + Button(action: { + dismiss() + }, label: { + Text(action.title) + .font(theme.action.font) + .frame(maxWidth: .infinity) + .frame(minHeight: 50) + }) + .buttonStyle(.borderedProminent) + } + + var openAppInAppStoreButtonView: some View { + Button(action: { + openAppInAppStore() + }, label: { + Text(action.title) + .font(theme.action.font) + .frame(maxWidth: .infinity) + .frame(minHeight: 50) + }) + .buttonStyle(.borderedProminent) + } +} diff --git a/Sources/OnLaunch/UI/MessageView.swift b/Sources/OnLaunch/UI/MessageView.swift index 58a2aa4..736bb8e 100644 --- a/Sources/OnLaunch/UI/MessageView.swift +++ b/Sources/OnLaunch/UI/MessageView.swift @@ -1,7 +1,6 @@ import SwiftUI -internal struct MessageView: View { - +struct MessageView: View { // MARK: - Environment @Environment(\.theme) private var theme: Theme @@ -9,12 +8,13 @@ internal struct MessageView: View { // MARK: - State - internal let message: Message - internal let completionHandler: () -> Void + let message: Message + let options: OnLaunch.Options + let completionHandler: () -> Void // MARK: - View Body - internal var body: some View { + var body: some View { VStack(alignment: .leading, spacing: 0) { HStack { Spacer() @@ -45,25 +45,7 @@ internal struct MessageView: View { .multilineTextAlignment(.leading) } .padding(.vertical, 20) - VStack(spacing: 12) { - ForEach(Array(message.actions.enumerated()), id: \.0) { _, action in - Button(action: { - switch action.kind { - case .button: - dismiss() - case .dismissButton: - dismiss() - } - completionHandler() - }, label: { - Text(action.title) - .font(theme.action.font) - .frame(maxWidth: .infinity) - .frame(minHeight: 50) - }) - .buttonStyle(.borderedProminent) - } - } + actionsView } .frame(maxWidth: .infinity) .padding(.bottom, 12) @@ -71,4 +53,29 @@ internal struct MessageView: View { } .padding(.vertical, 12) } + + var actionsView: some View { + VStack(spacing: 12) { + ForEach(Array(message.actions.enumerated()), id: \.0) { _, action in + MessageActionView( + action: action, + dismiss: { + dismiss() + completionHandler() + }, + openAppInAppStore: { + guard let appStoreId = options.appStoreId, + let url = URL(string: "https://apps.apple.com/app/id\(appStoreId)") else { + return assertionFailure("Failed to create App Store store page url") + } + UIApplication.shared.open(url) { success in + if !success { + assertionFailure("Failed to open URL: \(url)") + } + } + } + ) + } + } + } } diff --git a/Sources/OnLaunch/Utils/OSLog+OnLaunch.swift b/Sources/OnLaunch/Utils/OSLog+OnLaunch.swift index 817be5b..41de578 100644 --- a/Sources/OnLaunch/Utils/OSLog+OnLaunch.swift +++ b/Sources/OnLaunch/Utils/OSLog+OnLaunch.swift @@ -1,8 +1,6 @@ import OSLog extension OSLog { - /// `OSLog` used internally to log messages - internal static let onlaunch = OSLog(subsystem: "app.kula.OnLaunch", category: "OnLaunch") - + static let onlaunch = OSLog(subsystem: "app.kula.OnLaunch", category: "OnLaunch") } diff --git a/Sources/OnLaunch/Utils/OnLaunchError.swift b/Sources/OnLaunch/Utils/OnLaunchError.swift index 8b37bb9..b05e3a0 100644 --- a/Sources/OnLaunch/Utils/OnLaunchError.swift +++ b/Sources/OnLaunch/Utils/OnLaunchError.swift @@ -1,18 +1,17 @@ import Foundation public enum OnLaunchError: LocalizedError { - case invalidBaseURL(String) case appIdNotConfigured case failedToFetchMessages(response: HTTPURLResponse, data: String?) public var errorDescription: String? { switch self { - case .invalidBaseURL(let url): + case let .invalidBaseURL(url): return "The provided base URL '\(url)' is not valid" case .appIdNotConfigured: return "You must configure an app ID" - case .failedToFetchMessages(response: let response, data: let data): + case let .failedToFetchMessages(response: response, data: data): return "Failed to fetch messages, reason: \(response.statusCode) \(data ?? "")" } } diff --git a/Tests/OnLaunchTests/RequestContextTests.swift b/Tests/OnLaunchTests/RequestContextTests.swift index 615c13a..a467270 100644 --- a/Tests/OnLaunchTests/RequestContextTests.swift +++ b/Tests/OnLaunchTests/RequestContextTests.swift @@ -1,8 +1,7 @@ -import XCTest @testable import OnLaunch +import XCTest final class RequestContextTests: XCTestCase { - func testApplyTo_bundleIdIsNotDefined_headerShouldNotBeSet() throws { // -- Arrange -- var request = URLRequest(url: URL(string: "http://testing.local")!)