Skip to content

Commit

Permalink
Merge pull request #126 from geteduroam/feature/119-verify-notificati…
Browse files Browse the repository at this point in the history
…on-handling-on-cold-launch

Handling notifications and their actions on cold launch
  • Loading branch information
johankool authored Jul 11, 2024
2 parents f50fd4d + 3eee31a commit d698638
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 54 deletions.
11 changes: 3 additions & 8 deletions geteduroam/GeteduroamApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,6 @@ struct GeteduroamApp: App {

@StateObject var theme = Theme.theme

var store: StoreOf<Main>!

@Environment(\.openURL) var openURL

init() {
Expand All @@ -72,24 +70,21 @@ struct GeteduroamApp: App {
#else
let initialState = Main.State()
#endif

store = .init(initialState: initialState, reducer: { Main() }, withDependencies: { [appDelegate] in
$0.authClient = appDelegate
})
appDelegate.createStore(initialState: initialState)
}

#if os(iOS)
var body: some Scene {
WindowGroup {
MainView(store: store)
MainView(store: appDelegate.store)
.environmentObject(theme)
}
}
#elseif os(macOS)
// On macOS 13 and up using Window and .defaultPosition and .defaultSize would be better, but can't use control flow statement with 'SceneBuilder'
var body: some Scene {
WindowGroup("geteduroam", id: "mainWindow") {
MainView(store: store)
MainView(store: appDelegate.store)
.environmentObject(theme)
.frame(minWidth: 300, idealWidth: 540, maxWidth: .infinity, minHeight: 460, idealHeight: 640, maxHeight: .infinity, alignment: .center)
.onDisappear {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import AppAuth
import AuthClient
import ComposableArchitecture
import Foundation

public enum StartAuthError: Error {
Expand All @@ -9,9 +11,30 @@ public enum StartAuthError: Error {

#if os(iOS)
public class GeteduroamAppDelegate: NSObject, UIApplicationDelegate, ObservableObject, AuthClient {

public func createStore(initialState: Main.State) {
assert(store == nil, "Call this method only once")
store = .init(
initialState: initialState,
reducer: {
Main()
},
withDependencies: {
$0.authClient = self
}
)
}

public private(set) var store: StoreOf<Main>!

private var currentAuthorizationFlow: OIDExternalUserAgentSession?

public func startAuth(request: OIDAuthorizationRequest) async throws -> OIDAuthState {
if UIApplication.shared.keyWindow == nil {
// Sleep a bit to wait for a window to be created, as we might get called right at startup
let duration = UInt64(2 * 1_000_000_000)
try await Task.sleep(nanoseconds: duration)
}
guard let window = UIApplication.shared.keyWindow else {
throw StartAuthError.noWindow
}
Expand All @@ -31,6 +54,13 @@ public class GeteduroamAppDelegate: NSObject, UIApplicationDelegate, ObservableO
}
}

public func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
store.send(.applicationDidFinishLaunching)
return true
}

public func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
// Sends the URL to the current authorization flow (if any) which will process it if it relates to an authorization response.
if let currentAuthorizationFlow, currentAuthorizationFlow.resumeExternalUserAgentFlow(with: url) {
Expand Down Expand Up @@ -60,6 +90,22 @@ extension UIApplication {
}
#elseif os(macOS)
public class GeteduroamAppDelegate: NSObject, NSApplicationDelegate, ObservableObject, AuthClient {

public func createStore(initialState: Main.State) {
assert(store == nil, "Call this method only once")
store = .init(
initialState: initialState,
reducer: {
Main()
},
withDependencies: {
$0.authClient = self
}
)
}

public private(set) var store: StoreOf<Main>!

private var currentAuthorizationFlow: OIDExternalUserAgentSession?

public func startAuth(request: OIDAuthorizationRequest) async throws -> OIDAuthState {
Expand Down
112 changes: 68 additions & 44 deletions geteduroam/GeteduroamPackage/Sources/Main/MainFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import DiscoveryClient
import Foundation
import Models
import NotificationClient
import OSLog

@Reducer
public struct Main: Reducer {
Expand All @@ -15,6 +16,11 @@ public struct Main: Reducer {
@Dependency(\.notificationClient) var notificationClient
@Dependency(\.date.now) var now

public struct PendingRenewAction: Equatable {
let organizationId: String
let profileId: String
}

@ObservableState
public struct State: Equatable {
public init(searchQuery: String = "", organizations: IdentifiedArrayOf<Organization> = .init(uniqueElements: []), loadingState: LoadingState = .initial, searchResults: IdentifiedArrayOf<Organization> = .init(uniqueElements: []), destination: Destination.State? = nil) {
Expand All @@ -37,6 +43,7 @@ public struct Main: Reducer {
var isSearching: Bool = false
var searchQuery: String
var searchResults: IdentifiedArrayOf<Organization>
fileprivate var pendingRenewAction: PendingRenewAction?

var isConnecting: Bool {
get {
Expand All @@ -58,6 +65,7 @@ public struct Main: Reducer {
}

public enum Action: BindableAction {
case applicationDidFinishLaunching
case binding(BindingAction<State>)
case destination(PresentationAction<Destination.Action>)
case discoveryResponse(TaskResult<DiscoveryResponse>)
Expand Down Expand Up @@ -101,44 +109,51 @@ public struct Main: Reducer {

public var body: some Reducer<State, Action> {
BindingReducer()
Reduce {
state,
action in
Reduce { state, action in
switch action {
case .onAppear,
.tryAgainTapped:
state.loadingState = .isLoading
return .merge(
.run { send in
for await event in notificationClient.delegate() {
switch event {
case .renewActionTriggered(organizationId: let organizationId, profileId: let profileId):
await send(.renewActionInReminderTapped(organizationId: organizationId, profileId: profileId))

case let .remindMeLaterActionTriggered(validUntil, organizationId, profileId):
guard validUntil.timeIntervalSince(now) > 0 else {
return
case .applicationDidFinishLaunching:
Logger.notifications.debug("Application did finish launching")
let delegate = notificationClient.delegate()
return .run { send in
await withThrowingTaskGroup(of: Void.self) { @MainActor group in
group.addTask {
for await event in delegate {
switch event {
case .renewActionTriggered(organizationId: let organizationId, profileId: let profileId):
await send(.renewActionInReminderTapped(organizationId: organizationId, profileId: profileId))

case let .remindMeLaterActionTriggered(validUntil, organizationId, profileId):
guard validUntil.timeIntervalSince(now) > 0 else {
return
}
try await notificationClient.scheduleRenewReminder(validUntil, organizationId, profileId)
}
try await notificationClient.scheduleRenewReminder(validUntil, organizationId, profileId)
}
}
},
.run { send in
await send(.discoveryResponse(TaskResult {
do {
let (value, _) = try await discoveryClient.decodedResponse(for: .discover, as: DiscoveryResponse.self)
cacheClient.cacheDiscovery(value)
return value
} catch {
let restoredValue = try cacheClient.restoreDiscovery()
return restoredValue
}
}))
})
}
}

case .onAppear, .tryAgainTapped:
state.loadingState = .isLoading
return .run { send in
await send(.discoveryResponse(TaskResult {
do {
let (value, _) = try await discoveryClient.decodedResponse(for: .discover, as: DiscoveryResponse.self)
cacheClient.cacheDiscovery(value)
return value
} catch {
let restoredValue = try cacheClient.restoreDiscovery()
return restoredValue
}
}))
}

case let .discoveryResponse(.success(response)):
state.loadingState = .success
state.organizations = .init(uniqueElements: response.content.organizations)
if let pendingRenewAction = state.pendingRenewAction {
return .send(.renewActionInReminderTapped(organizationId: pendingRenewAction.organizationId, profileId: pendingRenewAction.profileId))
}
return .none

case let .discoveryResponse(.failure(error)):
Expand All @@ -155,20 +170,29 @@ public struct Main: Reducer {
state.destination = .alert(alert)
return .none

case let .renewActionInReminderTapped(organizationId, profile):
if let organization = state.organizations[id: organizationId] {
state.destination = .connect(.init(organization: organization, selectedProfileId: profile, autoConnectOnAppear: true))
} else {
let alert = AlertState<AlertAction>(title: {
TextState(NSLocalizedString("Unknown organization", bundle: .module, comment: "Title when user asked to renew but the organization could not be found"))
}, actions: {
ButtonState(role: .cancel, action: .send(.okButtonTapped)) {
TextState(NSLocalizedString("OK", bundle: .module, comment: ""))
}
}, message: {
TextState(NSLocalizedString("The organization is no longer listed.", bundle: .module, comment: "Message when user asked to renew but the organization could not be found"))
})
state.destination = .alert(alert)
case let .renewActionInReminderTapped(organizationId, profileId):
switch state.loadingState {
case .initial, .isLoading:
// Perform action when organizations are known
state.pendingRenewAction = PendingRenewAction(organizationId: organizationId, profileId: profileId)

case .success, .failure:
state.pendingRenewAction = nil

if let organization = state.organizations[id: organizationId] {
state.destination = .connect(.init(organization: organization, selectedProfileId: profileId, autoConnectOnAppear: true))
} else {
let alert = AlertState<AlertAction>(title: {
TextState(NSLocalizedString("Unknown organization", bundle: .module, comment: "Title when user asked to renew but the organization could not be found"))
}, actions: {
ButtonState(role: .cancel, action: .send(.okButtonTapped)) {
TextState(NSLocalizedString("OK", bundle: .module, comment: ""))
}
}, message: {
TextState(NSLocalizedString("The organization is no longer listed.", bundle: .module, comment: "Message when user asked to renew but the organization could not be found"))
})
state.destination = .alert(alert)
}
}
return .none

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ extension NotificationClient {
}

extension Logger {
static var notifications = Logger(subsystem: Bundle.main.bundleIdentifier ?? "NotificationClient", category: "notifications")
public static var notifications = Logger(subsystem: Bundle.main.bundleIdentifier ?? "NotificationClient", category: "notifications")
}

extension NotificationClient {
Expand All @@ -63,7 +63,7 @@ extension NotificationClient {
guard granted else { return }

// Declare custom actions: Renew Now | Remind Me Later
let renewNowAction = UNNotificationAction(identifier: .renewNowActionId, title: NSLocalizedString("Renew Now", bundle: .module, comment: "Renew Now"), options: [.authenticationRequired], icon: UNNotificationActionIcon(systemImageName: "arrow.triangle.2.circlepath"))
let renewNowAction = UNNotificationAction(identifier: .renewNowActionId, title: NSLocalizedString("Renew Now", bundle: .module, comment: "Renew Now"), options: [.authenticationRequired, .foreground], icon: UNNotificationActionIcon(systemImageName: "arrow.triangle.2.circlepath"))
let remindMeAction = UNNotificationAction(identifier: .remindMeActionId, title: NSLocalizedString("Remind Me Later", bundle: .module, comment: "Remind Me Later"), options: [], icon: UNNotificationActionIcon(systemImageName: "alarm"))

let willExpireCategory = UNNotificationCategory(identifier: .willExpireCategoryId, actions: [renewNowAction, remindMeAction], intentIdentifiers: [], hiddenPreviewsBodyPlaceholder: NSLocalizedString("Renew your connection to extend your access.", bundle: .module, comment: "Renew your connection to extend your access."), categorySummaryFormat: nil, options: [.hiddenPreviewsShowTitle])
Expand Down

0 comments on commit d698638

Please sign in to comment.