Skip to content

Commit

Permalink
Make a simple state machine to identify incorrect transitions (#3660)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/0/1208878879182791/f
Tech Design URL:
https://app.asana.com/0/481882893211075/1208859623176995/f
CC: @bwaresiak 

**Description**:
1. Implement simple state machine with empty states and AppDelegate
lifecycle event triggers
2. Fire a pixel on incorrect transition

**Steps to test this PR**:
1. Duplicate any event, e.g. write
appStateMachine.handle(.backgrounding(application)) twice
2. Go to background and see the pixel
`m_debug_app-did-transition-to-unexpected-state` is being sent
  • Loading branch information
jaceklyp authored Dec 3, 2024
1 parent d3a068a commit 414460b
Show file tree
Hide file tree
Showing 11 changed files with 366 additions and 0 deletions.
3 changes: 3 additions & 0 deletions Core/Pixel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ public struct PixelParameters {
public static let retriedPixel = "retriedPixel"

public static let time = "time"

public static let appState = "state"
public static let appEvent = "event"
}

public struct PixelValues {
Expand Down
6 changes: 6 additions & 0 deletions Core/PixelEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,9 @@ extension Pixel {
case appDidShowUITime(time: BucketAggregation)
case appDidBecomeActiveTime(time: BucketAggregation)

// MARK: Lifecycle
case appDidTransitionToUnexpectedState

}

}
Expand Down Expand Up @@ -1784,6 +1787,9 @@ extension Pixel.Event {
case .appDidShowUITime(let time): return "m_debug_app-did-show-ui-time-\(time)"
case .appDidBecomeActiveTime(let time): return "m_debug_app-did-become-active-time-\(time)"

// MARK: Lifecycle
case .appDidTransitionToUnexpectedState: return "m_debug_app-did-transition-to-unexpected-state"

}
}
}
Expand Down
44 changes: 44 additions & 0 deletions DuckDuckGo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,13 @@
CB9B873C278C8FEA001F4906 /* WidgetEducationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9B873B278C8FEA001F4906 /* WidgetEducationView.swift */; };
CB9B873E278C93C2001F4906 /* HomeMessage.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CB9B873D278C93C2001F4906 /* HomeMessage.xcassets */; };
CBAA195A27BFE15600A4BD49 /* NSManagedObjectContextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAA195927BFE15600A4BD49 /* NSManagedObjectContextExtension.swift */; };
CBAD0EF92CFE1D3B006267B8 /* Init.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EF82CFE1D35006267B8 /* Init.swift */; };
CBAD0EFB2CFE1D41006267B8 /* Launched.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EFA2CFE1D3F006267B8 /* Launched.swift */; };
CBAD0EFD2CFE1D4B006267B8 /* Active.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EFC2CFE1D48006267B8 /* Active.swift */; };
CBAD0EFF2CFE1D50006267B8 /* Inactive.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EFE2CFE1D4E006267B8 /* Inactive.swift */; };
CBAD0F012CFE1D57006267B8 /* Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F002CFE1D54006267B8 /* Background.swift */; };
CBAD0F062CFE2711006267B8 /* AppStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F052CFE270D006267B8 /* AppStateMachine.swift */; };
CBAD0F082CFE27E2006267B8 /* AppStateTransitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F072CFE27D5006267B8 /* AppStateTransitions.swift */; };
CBC83E3429B631780008E19C /* Configuration in Frameworks */ = {isa = PBXBuildFile; productRef = CBC83E3329B631780008E19C /* Configuration */; };
CBC88EE12C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC88EE02C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift */; };
CBC88EE52C8097B500F0F8C5 /* URLCredentialCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC88EE42C8097B500F0F8C5 /* URLCredentialCreator.swift */; };
Expand Down Expand Up @@ -2824,6 +2831,13 @@
CBA1DE942AF6D579007C9457 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/InfoPlist.strings; sourceTree = "<group>"; };
CBAA195927BFE15600A4BD49 /* NSManagedObjectContextExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContextExtension.swift; sourceTree = "<group>"; };
CBAA195B27C3982A00A4BD49 /* PrivacyFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyFeatures.swift; sourceTree = "<group>"; };
CBAD0EF82CFE1D35006267B8 /* Init.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Init.swift; sourceTree = "<group>"; };
CBAD0EFA2CFE1D3F006267B8 /* Launched.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Launched.swift; sourceTree = "<group>"; };
CBAD0EFC2CFE1D48006267B8 /* Active.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Active.swift; sourceTree = "<group>"; };
CBAD0EFE2CFE1D4E006267B8 /* Inactive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Inactive.swift; sourceTree = "<group>"; };
CBAD0F002CFE1D54006267B8 /* Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Background.swift; sourceTree = "<group>"; };
CBAD0F052CFE270D006267B8 /* AppStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateMachine.swift; sourceTree = "<group>"; };
CBAD0F072CFE27D5006267B8 /* AppStateTransitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTransitions.swift; sourceTree = "<group>"; };
CBB6B2542AF6D543006B777C /* lt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lt; path = lt.lproj/InfoPlist.strings; sourceTree = "<group>"; };
CBC7AB542AF6D583008CB798 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = "<group>"; };
CBC88EE02C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageUserScriptTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -5447,6 +5461,28 @@
name = Resources;
sourceTree = "<group>";
};
CBAD0EF72CFE1D14006267B8 /* AppStates */ = {
isa = PBXGroup;
children = (
CBAD0EF82CFE1D35006267B8 /* Init.swift */,
CBAD0EFA2CFE1D3F006267B8 /* Launched.swift */,
CBAD0EFC2CFE1D48006267B8 /* Active.swift */,
CBAD0EFE2CFE1D4E006267B8 /* Inactive.swift */,
CBAD0F002CFE1D54006267B8 /* Background.swift */,
);
path = AppStates;
sourceTree = "<group>";
};
CBAD0F042CFE1DA2006267B8 /* AppLifecycle */ = {
isa = PBXGroup;
children = (
CBAD0F052CFE270D006267B8 /* AppStateMachine.swift */,
CBAD0F072CFE27D5006267B8 /* AppStateTransitions.swift */,
CBAD0EF72CFE1D14006267B8 /* AppStates */,
);
path = AppLifecycle;
sourceTree = "<group>";
};
D62EC3B72C24695800FC9D04 /* DuckPlayer */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -6336,6 +6372,7 @@
F1C5ECF31E37812900C599A4 /* Application */ = {
isa = PBXGroup;
children = (
CBAD0F042CFE1DA2006267B8 /* AppLifecycle */,
83BE9BC2215D69C1009844D9 /* AppConfigurationFetch.swift */,
CB24F70E29A3EB15006DCC58 /* AppConfigurationURLProvider.swift */,
84E341951E2F7EFB00BDBA6F /* AppDelegate.swift */,
Expand Down Expand Up @@ -7518,6 +7555,7 @@
BDE91CDE2C62B90F0005CB74 /* UnifiedFeedbackRootView.swift in Sources */,
D65625A12C232F5E006EF297 /* SettingsDuckPlayerView.swift in Sources */,
D6FEB8B52B74994000C3615F /* HeadlessWebViewCoordinator.swift in Sources */,
CBAD0EF92CFE1D3B006267B8 /* Init.swift in Sources */,
9F96F73F2C914C57009E45D5 /* OnboardingGradient.swift in Sources */,
6FE1273D2C204C2500EB5724 /* FavoritesView.swift in Sources */,
8528AE81212F15D600D0BD74 /* AppRatingPrompt.xcdatamodeld in Sources */,
Expand Down Expand Up @@ -7555,6 +7593,7 @@
8590CB69268A4E190089F6BF /* DebugEtagStorage.swift in Sources */,
C1CDA3162AFB9C7F006D1476 /* AutofillNeverPromptWebsitesManager.swift in Sources */,
D668D9272B6937D2008E2FF2 /* SubscriptionITPViewModel.swift in Sources */,
CBAD0F012CFE1D57006267B8 /* Background.swift in Sources */,
F1CA3C371F045878005FADB3 /* PrivacyStore.swift in Sources */,
31DE43C42C2C60E800F8C51F /* DuckPlayerModalPresenter.swift in Sources */,
37FCAAC029930E26000E420A /* FailedAssertionView.swift in Sources */,
Expand Down Expand Up @@ -7621,6 +7660,7 @@
BDFF031D2BA3D2BD00F324C9 /* DefaultNetworkProtectionVisibility.swift in Sources */,
F1BE54581E69DE1000FCF649 /* TutorialSettings.swift in Sources */,
1EE52ABB28FB1D6300B750C1 /* UIImageExtension.swift in Sources */,
CBAD0EFF2CFE1D50006267B8 /* Inactive.swift in Sources */,
858650D12469BCDE00C36F8A /* DaxDialogs.swift in Sources */,
9F5E5AB02C3E4C6000165F54 /* ContextualOnboardingPresenter.swift in Sources */,
310D091B2799F54900DC0060 /* DownloadManager.swift in Sources */,
Expand Down Expand Up @@ -7661,6 +7701,7 @@
859DB8132CE6263C001F7210 /* TextZoomStorage.swift in Sources */,
D65625952C22D382006EF297 /* TabViewController.swift in Sources */,
8C4838B5221C8F7F008A6739 /* GestureToolbarButton.swift in Sources */,
CBAD0EFD2CFE1D4B006267B8 /* Active.swift in Sources */,
310ECFDD282A8BB0005029B3 /* EnableAutofillSettingsTableViewCell.swift in Sources */,
859DB8172CE6263C001F7210 /* TextZoomLevel.swift in Sources */,
BDE91CD62C6294020005CB74 /* FeedbackCategoryProviding.swift in Sources */,
Expand Down Expand Up @@ -7756,6 +7797,7 @@
D6F93E3E2B50A8A0004C268D /* SubscriptionSettingsView.swift in Sources */,
1D200C9B2BA31A6A00108701 /* AboutView.swift in Sources */,
851B12CC22369931004781BC /* AtbAndVariantCleanup.swift in Sources */,
CBAD0F062CFE2711006267B8 /* AppStateMachine.swift in Sources */,
D668D92B2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift in Sources */,
85F2FFCF2211F8E5006BB258 /* TabSwitcherViewController+KeyCommands.swift in Sources */,
3157B43327F497E90042D3D7 /* SaveLoginView.swift in Sources */,
Expand Down Expand Up @@ -7948,6 +7990,7 @@
311BD1B12836C0CA00AEF6C1 /* AutofillLoginListAuthenticator.swift in Sources */,
B652DF13287C373A00C12A9C /* ScriptSourceProviding.swift in Sources */,
854A012B2A54412600FCC628 /* ActivityViewController.swift in Sources */,
CBAD0F082CFE27E2006267B8 /* AppStateTransitions.swift in Sources */,
F1CA3C391F045885005FADB3 /* PrivacyUserDefaults.swift in Sources */,
6F655BE22BAB289E00AC3597 /* DefaultTheme.swift in Sources */,
6FE1274B2C20943500EB5724 /* ShortcutItemView.swift in Sources */,
Expand Down Expand Up @@ -8034,6 +8077,7 @@
983D71B12A286E810072E26D /* SyncDebugViewController.swift in Sources */,
6FDA1FB32B59584400AC962A /* AddressDisplayHelper.swift in Sources */,
F103073B1E7C91330059FEC7 /* BookmarksDataSource.swift in Sources */,
CBAD0EFB2CFE1D41006267B8 /* Launched.swift in Sources */,
6FD3F80F2C3EF4F000DA5797 /* DeviceOrientationEnvironmentValue.swift in Sources */,
85864FBC24D31EF300E756FF /* SuggestionTrayViewController.swift in Sources */,
D64648AF2B5993890033090B /* SubscriptionEmailViewModel.swift in Sources */,
Expand Down
8 changes: 8 additions & 0 deletions DuckDuckGo/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ import os.log

private var didFinishLaunchingStartTime: CFAbsoluteTime?

private let appStateMachine = AppStateMachine()

override init() {
super.init()

Expand All @@ -131,6 +133,7 @@ import os.log
// swiftlint:disable:next cyclomatic_complexity
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

appStateMachine.handle(.launching(application, launchOptions: launchOptions))
didFinishLaunchingStartTime = CFAbsoluteTimeGetCurrent()
defer {
if let didFinishLaunchingStartTime {
Expand Down Expand Up @@ -597,6 +600,8 @@ import os.log
func applicationDidBecomeActive(_ application: UIApplication) {
guard !testing else { return }

appStateMachine.handle(.activating(application))

defer {
if let didFinishLaunchingStartTime {
let launchTime = CFAbsoluteTimeGetCurrent() - didFinishLaunchingStartTime
Expand Down Expand Up @@ -700,6 +705,7 @@ import os.log
}

func applicationWillResignActive(_ application: UIApplication) {
appStateMachine.handle(.suspending(application))
Task { @MainActor in
await refreshShortcuts()
await vpnWorkaround.removeRedditSessionWorkaround()
Expand Down Expand Up @@ -792,6 +798,7 @@ import os.log
}

func applicationDidEnterBackground(_ application: UIApplication) {
appStateMachine.handle(.backgrounding(application))
displayBlankSnapshotWindow()
autoClear?.startClearingTimer()
lastBackgroundDate = Date()
Expand Down Expand Up @@ -837,6 +844,7 @@ import os.log

func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
Logger.sync.debug("App launched with url \(url.absoluteString)")
appStateMachine.handle(.openURL(url))

// If showing the onboarding intro ignore deeplinks
guard mainViewController?.needsToShowOnboardingIntro() == false else {
Expand Down
53 changes: 53 additions & 0 deletions DuckDuckGo/AppLifecycle/AppStateMachine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// AppStateMachine.swift
// DuckDuckGo
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import UIKit

enum AppEvent {

case launching(UIApplication, launchOptions: [UIApplication.LaunchOptionsKey: Any]?)
case activating(UIApplication)
case backgrounding(UIApplication)
case suspending(UIApplication)

case openURL(URL)

}

protocol AppState {

func apply(event: AppEvent) -> any AppState

}

protocol AppEventHandler {

func handle(_ event: AppEvent)

}

final class AppStateMachine: AppEventHandler {

private(set) var currentState: any AppState = Init()

func handle(_ event: AppEvent) {
currentState = currentState.apply(event: event)
}

}
118 changes: 118 additions & 0 deletions DuckDuckGo/AppLifecycle/AppStateTransitions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//
// AppStateTransitions.swift
// DuckDuckGo
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import os.log
import Core

extension Init {

func apply(event: AppEvent) -> any AppState {
switch event {
case .launching(let application, let launchOptions):
return Launched(application: application, launchOptions: launchOptions)
default:
return handleUnexpectedEvent(event)
}
}

}

extension Launched {

func apply(event: AppEvent) -> any AppState {
switch event {
case .activating(let application):
return Active(application: application)
case .openURL:
return self
case .launching, .suspending, .backgrounding:
return handleUnexpectedEvent(event)
}
}

}

extension Active {

func apply(event: AppEvent) -> any AppState {
switch event {
case .suspending(let application):
return Inactive(application: application)
case .launching, .activating, .backgrounding, .openURL:
return handleUnexpectedEvent(event)
}
}

}

extension Inactive {

func apply(event: AppEvent) -> any AppState {
switch event {
case .backgrounding(let application):
return Background(application: application)
case .activating(let application):
return Active(application: application)
case .launching, .suspending, .openURL:
return handleUnexpectedEvent(event)
}
}

}

extension Background {

func apply(event: AppEvent) -> any AppState {
switch event {
case .activating(let application):
return Active(application: application)
case .openURL:
return self
case .launching, .suspending, .backgrounding:
return handleUnexpectedEvent(event)
}
}

}

extension AppEvent {

var rawValue: String {
switch self {
case .launching: return "launching"
case .activating: return "activating"
case .backgrounding: return "backgrounding"
case .suspending: return "suspending"
case .openURL: return "openURL"
}
}

}

extension AppState {

func handleUnexpectedEvent(_ event: AppEvent) -> Self {
Logger.lifecycle.error("Invalid transition (\(event.rawValue)) for state (\(type(of: self)))")
DailyPixel.fireDailyAndCount(pixel: .appDidTransitionToUnexpectedState,
withAdditionalParameters: [PixelParameters.appState: String(describing: type(of: self)),
PixelParameters.appEvent: event.rawValue])
return self
}

}
Loading

0 comments on commit 414460b

Please sign in to comment.