Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RUMM-1765 Fix foreground app state reporting in AppStateListener - part 5 🏁 #696

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@
import Foundation
import Datadog
import CoreLocation
import UIKit.UIApplication

internal var backgroundLocationMonitor: BackgroundLocationMonitor?

/// Location monitor used in "Example" app for debugging and testing iOS SDK features in background.
internal class BackgroundLocationMonitor: NSObject, CLLocationManagerDelegate {
private struct Constants {
static let locationMonitoringUserDefaultsKey = "is-location-monitoring-started"
static let crashOnNextBackgroundEventUserDefaultsKey = "crash-on-next-background-event"
static let crashDuringNextBackgroundLaunchUserDefaultsKey = "crash-during-next-background-launch"
}

private let locationManager = CLLocationManager()
Expand All @@ -28,6 +31,24 @@ internal class BackgroundLocationMonitor: NSObject, CLLocationManagerDelegate {
set { UserDefaults.standard.set(newValue, forKey: Constants.locationMonitoringUserDefaultsKey) }
}

/// If enabled, the Example app will crash on receiving next event in background.
/// This setting is preserved between application launches. Defaults to `false` and is reset to `false` shortly before crash.
private(set) var shouldCrashOnNextBackgroundEvent: Bool {
get { UserDefaults.standard.bool(forKey: Constants.crashOnNextBackgroundEventUserDefaultsKey) }
set { UserDefaults.standard.set(newValue, forKey: Constants.crashOnNextBackgroundEventUserDefaultsKey) }
}

/// If enabled, the Example app will crash during next launch in background.
/// This setting is preserved between application launches. Defaults to `false` and is reset to `false` shortly before crash.
private(set) var shouldCrashDuringNextBackgroundLaunch: Bool {
get { UserDefaults.standard.bool(forKey: Constants.crashDuringNextBackgroundLaunchUserDefaultsKey) }
set { UserDefaults.standard.set(newValue, forKey: Constants.crashDuringNextBackgroundLaunchUserDefaultsKey) }
}

private var isAppInBackground: Bool {
return UIApplication.shared.applicationState == .background
}

/// Current authorization status for location monitoring.
var currentAuthorizationStatus: String { authorizationStatusDescription(for: locationManager) }

Expand All @@ -41,6 +62,13 @@ internal class BackgroundLocationMonitor: NSObject, CLLocationManagerDelegate {
// This will keep location tracking when the app is woken up in background due to significant location change.
startMonitoring()
}

if isAppInBackground && shouldCrashDuringNextBackgroundLaunch {
shouldCrashDuringNextBackgroundLaunch = false
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
fatalError("Crash during application launch in background")
}
}
}

func startMonitoring() {
Expand All @@ -63,6 +91,14 @@ internal class BackgroundLocationMonitor: NSObject, CLLocationManagerDelegate {
isStarted = false
}

func setCrashOnNextBackgroundEvent(_ enabled: Bool) {
shouldCrashOnNextBackgroundEvent = enabled
}

func setCrashDuringNextBackgroundLaunch(_ enabled: Bool) {
shouldCrashDuringNextBackgroundLaunch = enabled
}

// MARK: - CLLocationManagerDelegate

func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
Expand Down Expand Up @@ -95,6 +131,13 @@ internal class BackgroundLocationMonitor: NSObject, CLLocationManagerDelegate {
"speed": recentLocation.speed,
]
)

if isAppInBackground && shouldCrashOnNextBackgroundEvent {
shouldCrashOnNextBackgroundEvent = false
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
fatalError("Crash on receiving event in background")
}
}
}

func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ private class DebugBackgroundEventsViewModel: ObservableObject {
private let locationMonitor: BackgroundLocationMonitor

@Published var isLocationMonitoringON = false
@Published var willCrashDuringNextBackgroundLaunch = false
@Published var willCrashOnNextBackgroundEvent = false
@Published var authorizationStatus = ""

init() {
Expand All @@ -28,6 +30,8 @@ private class DebugBackgroundEventsViewModel: ObservableObject {
locationMonitor.onAuthorizationStatusChange = { [weak self] newStatus in
self?.authorizationStatus = newStatus
}
willCrashDuringNextBackgroundLaunch = locationMonitor.shouldCrashDuringNextBackgroundLaunch
willCrashOnNextBackgroundEvent = locationMonitor.shouldCrashOnNextBackgroundEvent
}

func startLocationMonitoring() {
Expand All @@ -39,6 +43,16 @@ private class DebugBackgroundEventsViewModel: ObservableObject {
locationMonitor.stopMonitoring()
isLocationMonitoringON = locationMonitor.isStarted
}

func toggleCrashDuringNextBackgroundLaunch() {
locationMonitor.setCrashDuringNextBackgroundLaunch(!locationMonitor.shouldCrashDuringNextBackgroundLaunch)
willCrashDuringNextBackgroundLaunch = locationMonitor.shouldCrashDuringNextBackgroundLaunch
}

func toggleCrashOnNextBackgroundEvent() {
locationMonitor.setCrashOnNextBackgroundEvent(!locationMonitor.shouldCrashOnNextBackgroundEvent)
willCrashOnNextBackgroundEvent = locationMonitor.shouldCrashOnNextBackgroundEvent
}
}

@available(iOS 13.0, *)
Expand Down Expand Up @@ -76,6 +90,21 @@ internal struct DebugBackgroundEventsView: View {
}
}
Divider()
HStack {
Text("Crash during next background launch:").font(.footnote).fontWeight(.light)
Spacer()
Button(viewModel.willCrashDuringNextBackgroundLaunch ? "πŸ”₯ ENABLED" : "DISABLED") {
viewModel.toggleCrashDuringNextBackgroundLaunch()
}
}
HStack {
Text("Crash on next background event:").font(.footnote).fontWeight(.light)
Spacer()
Button(viewModel.willCrashOnNextBackgroundEvent ? "πŸ”₯ ENABLED" : "DISABLED") {
viewModel.toggleCrashOnNextBackgroundEvent()
}
}
Divider()
Text("Above settings are preserved between application launches, so they are also effective when app is launched in the background due to **significant** location change.")
.font(.footnote)
Spacer()
Expand Down
141 changes: 102 additions & 39 deletions Sources/Datadog/Core/System/AppStateListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,57 +7,91 @@
import Foundation
import class UIKit.UIApplication

/// Application state, constructed from `UIApplication.State`.
internal enum AppState: Equatable {
/// The app is running in the foreground and currently receiving events.
case active
/// The app is running in the foreground but is not receiving events.
/// This might happen as a result of an interruption or because the app is transitioning to or from the background.
case inactive
/// The app is running in the background.
case background

/// If the app is running in the foreground - no matter if receiving events or not (i.e. being interrupted because of transitioning from background).
var isRunningInForeground: Bool {
switch self {
case .active, .inactive:
return true
case .background:
return false
}
}

init(uiApplicationState: UIApplication.State) {
switch uiApplicationState {
case .active:
self = .active
case .inactive:
self = .inactive
case .background:
self = .background
@unknown default:
self = .active // in case a new state is introduced, we rather want to fallback to most expected state
}
}
}

/// A data structure to represent recorded app states in a given period of time
internal struct AppStateHistory: Equatable {
/// Snapshot of the app state at `date`
struct Snapshot: Equatable {
/// If the app is running in the foreground and currently receiving events.
let isActive: Bool
/// The app state at this `date`.
let state: AppState
/// Date of recording this snapshot.
let date: Date
}

fileprivate(set) var initialState: Snapshot
fileprivate(set) var changes = [Snapshot]()
fileprivate(set) var initialSnapshot: Snapshot
fileprivate(set) var snapshots = [Snapshot]()

/// Date of last the update to `AppStateHistory`.
fileprivate(set) var recentDate: Date

/// The most recent app state `Snapshot`.
var currentState: Snapshot {
var currentSnapshot: Snapshot {
return Snapshot(
isActive: (changes.last ?? initialState).isActive,
state: (snapshots.last ?? initialSnapshot).state,
date: recentDate
)
}

/// Limits or extrapolates app state history to the given range
/// This is useful when you record between 0...3t but you are concerned of t...2t only
/// - Parameter range: if outside of `initialState` and `finalState`, it extrapolates; otherwise it limits
/// - Parameter range: if outside of initial and final states, it extrapolates; otherwise it limits
/// - Returns: a history instance spanning the given range
func take(between range: ClosedRange<Date>) -> AppStateHistory {
var taken = self
// move initial state to lowerBound
taken.initialState = Snapshot(
isActive: isActive(at: range.lowerBound),
taken.initialSnapshot = Snapshot(
state: state(at: range.lowerBound),
date: range.lowerBound
)
// move final state to upperBound
taken.recentDate = range.upperBound
// filter changes outside of the range
taken.changes = taken.changes.filter { range.contains($0.date) }
taken.snapshots = taken.snapshots.filter { range.contains($0.date) }
return taken
}

var foregroundDuration: TimeInterval {
var duration: TimeInterval = 0.0
var lastActiveStartDate: Date?
let allEvents = [initialState] + changes + [currentState]
let allEvents = [initialSnapshot] + snapshots + [currentSnapshot]
for event in allEvents {
if let startDate = lastActiveStartDate {
duration += event.date.timeIntervalSince(startDate)
}
if event.isActive {
if event.state.isRunningInForeground {
lastActiveStartDate = event.date
} else {
lastActiveStartDate = nil
Expand All @@ -66,26 +100,22 @@ internal struct AppStateHistory: Equatable {
return duration
}

var didRunInBackground: Bool {
return !initialState.isActive || !currentState.isActive
}

private func isActive(at date: Date) -> Bool {
if date <= initialState.date {
private func state(at date: Date) -> AppState {
if date <= initialSnapshot.date {
// we assume there was no change before initial state
return initialState.isActive
} else if currentState.date <= date {
return initialSnapshot.state
} else if currentSnapshot.date <= date {
// and no change after final state
return currentState.isActive
return currentSnapshot.state
}
var active = initialState
for change in changes {
var active = initialSnapshot
for change in snapshots {
if date < change.date {
break
}
active = change
}
return active.isActive
return active.state
}
}

Expand All @@ -106,49 +136,82 @@ internal class AppStateListener: AppStateListening {

private let dateProvider: DateProvider
private let publisher: ValuePublisher<AppStateHistory>
/// The notification center where this listener observes following `UIApplication` notifications:
/// - `.didBecomeActiveNotification`
/// - `.willResignActiveNotification`
/// - `.didEnterBackgroundNotification`
/// - `.willEnterForegroundNotification`
private weak var notificationCenter: NotificationCenter?

var history: AppStateHistory {
var current = publisher.currentValue
current.recentDate = dateProvider.currentDate()
return current
}

private static var isAppActive: Bool {
return UIApplication.managedShared?.applicationState == .active
convenience init(dateProvider: DateProvider) {
self.init(
dateProvider: dateProvider,
initialAppState: UIApplication.managedShared?.applicationState ?? .active, // fallback to most expected state,
notificationCenter: .default
)
}

init(
dateProvider: DateProvider,
notificationCenter: NotificationCenter = .default
initialAppState: UIApplication.State,
notificationCenter: NotificationCenter
) {
self.dateProvider = dateProvider
let currentState = Snapshot(
isActive: AppStateListener.isAppActive,
let currentSnapshot = Snapshot(
state: AppState(uiApplicationState: initialAppState),
date: dateProvider.currentDate()
)
self.dateProvider = dateProvider
self.notificationCenter = notificationCenter
self.publisher = ValuePublisher(
initialValue: AppStateHistory(
initialState: currentState,
recentDate: currentState.date
initialSnapshot: currentSnapshot,
recentDate: currentSnapshot.date
)
)

notificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(appDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(appWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
}

deinit {
notificationCenter?.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil)
notificationCenter?.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil)
notificationCenter?.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
notificationCenter?.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil)
}

@objc
private func appDidBecomeActive() {
registerChange(to: .active)
}

@objc
private func appWillResignActive() {
let now = dateProvider.currentDate()
var value = publisher.currentValue
value.changes.append(Snapshot(isActive: false, date: now))
publisher.publishAsync(value)
registerChange(to: .inactive)
}

@objc
private func appDidBecomeActive() {
private func appDidEnterBackground() {
registerChange(to: .background)
}

@objc
private func appWillEnterForeground() {
registerChange(to: .inactive)
}

private func registerChange(to newState: AppState) {
let now = dateProvider.currentDate()
var value = publisher.currentValue
value.changes.append(Snapshot(isActive: true, date: now))
value.snapshots.append(Snapshot(state: newState, date: now))
publisher.publishAsync(value)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ internal class CrashContextProvider: CrashContextProviderType {

/// Updates `CrashContext` with last app foreground / background state information.
private lazy var isAppInForegroundUpdater = ContextValueUpdater<AppStateHistory>(queue: queue) { newValue in
self.unsafeCrashContext.lastIsAppInForeground = newValue.currentState.isActive
self.unsafeCrashContext.lastIsAppInForeground = newValue.currentSnapshot.state.isRunningInForeground
}

// MARK: - Initializer
Expand All @@ -96,7 +96,7 @@ internal class CrashContextProvider: CrashContextProviderType {
lastNetworkConnectionInfo: networkConnectionInfoProvider.current,
lastCarrierInfo: carrierInfoProvider.current,
lastRUMSessionState: rumSessionStateProvider.currentValue,
lastIsAppInForeground: appStateListener.history.currentState.isActive
lastIsAppInForeground: appStateListener.history.currentSnapshot.state.isRunningInForeground
)

// Subscribe for context updates
Expand Down
Loading