Skip to content
This repository was archived by the owner on Feb 24, 2025. It is now read-only.

Commit 414460b

Browse files
authored
Make a simple state machine to identify incorrect transitions (#3660)
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
1 parent d3a068a commit 414460b

File tree

11 files changed

+366
-0
lines changed

11 files changed

+366
-0
lines changed

Core/Pixel.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,9 @@ public struct PixelParameters {
161161
public static let retriedPixel = "retriedPixel"
162162

163163
public static let time = "time"
164+
165+
public static let appState = "state"
166+
public static let appEvent = "event"
164167
}
165168

166169
public struct PixelValues {

Core/PixelEvent.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,9 @@ extension Pixel {
895895
case appDidShowUITime(time: BucketAggregation)
896896
case appDidBecomeActiveTime(time: BucketAggregation)
897897

898+
// MARK: Lifecycle
899+
case appDidTransitionToUnexpectedState
900+
898901
}
899902

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

1790+
// MARK: Lifecycle
1791+
case .appDidTransitionToUnexpectedState: return "m_debug_app-did-transition-to-unexpected-state"
1792+
17871793
}
17881794
}
17891795
}

DuckDuckGo.xcodeproj/project.pbxproj

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -987,6 +987,13 @@
987987
CB9B873C278C8FEA001F4906 /* WidgetEducationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9B873B278C8FEA001F4906 /* WidgetEducationView.swift */; };
988988
CB9B873E278C93C2001F4906 /* HomeMessage.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CB9B873D278C93C2001F4906 /* HomeMessage.xcassets */; };
989989
CBAA195A27BFE15600A4BD49 /* NSManagedObjectContextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAA195927BFE15600A4BD49 /* NSManagedObjectContextExtension.swift */; };
990+
CBAD0EF92CFE1D3B006267B8 /* Init.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EF82CFE1D35006267B8 /* Init.swift */; };
991+
CBAD0EFB2CFE1D41006267B8 /* Launched.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EFA2CFE1D3F006267B8 /* Launched.swift */; };
992+
CBAD0EFD2CFE1D4B006267B8 /* Active.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EFC2CFE1D48006267B8 /* Active.swift */; };
993+
CBAD0EFF2CFE1D50006267B8 /* Inactive.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0EFE2CFE1D4E006267B8 /* Inactive.swift */; };
994+
CBAD0F012CFE1D57006267B8 /* Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F002CFE1D54006267B8 /* Background.swift */; };
995+
CBAD0F062CFE2711006267B8 /* AppStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F052CFE270D006267B8 /* AppStateMachine.swift */; };
996+
CBAD0F082CFE27E2006267B8 /* AppStateTransitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAD0F072CFE27D5006267B8 /* AppStateTransitions.swift */; };
990997
CBC83E3429B631780008E19C /* Configuration in Frameworks */ = {isa = PBXBuildFile; productRef = CBC83E3329B631780008E19C /* Configuration */; };
991998
CBC88EE12C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC88EE02C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift */; };
992999
CBC88EE52C8097B500F0F8C5 /* URLCredentialCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC88EE42C8097B500F0F8C5 /* URLCredentialCreator.swift */; };
@@ -2824,6 +2831,13 @@
28242831
CBA1DE942AF6D579007C9457 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/InfoPlist.strings; sourceTree = "<group>"; };
28252832
CBAA195927BFE15600A4BD49 /* NSManagedObjectContextExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContextExtension.swift; sourceTree = "<group>"; };
28262833
CBAA195B27C3982A00A4BD49 /* PrivacyFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyFeatures.swift; sourceTree = "<group>"; };
2834+
CBAD0EF82CFE1D35006267B8 /* Init.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Init.swift; sourceTree = "<group>"; };
2835+
CBAD0EFA2CFE1D3F006267B8 /* Launched.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Launched.swift; sourceTree = "<group>"; };
2836+
CBAD0EFC2CFE1D48006267B8 /* Active.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Active.swift; sourceTree = "<group>"; };
2837+
CBAD0EFE2CFE1D4E006267B8 /* Inactive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Inactive.swift; sourceTree = "<group>"; };
2838+
CBAD0F002CFE1D54006267B8 /* Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Background.swift; sourceTree = "<group>"; };
2839+
CBAD0F052CFE270D006267B8 /* AppStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateMachine.swift; sourceTree = "<group>"; };
2840+
CBAD0F072CFE27D5006267B8 /* AppStateTransitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTransitions.swift; sourceTree = "<group>"; };
28272841
CBB6B2542AF6D543006B777C /* lt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lt; path = lt.lproj/InfoPlist.strings; sourceTree = "<group>"; };
28282842
CBC7AB542AF6D583008CB798 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = "<group>"; };
28292843
CBC88EE02C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageUserScriptTests.swift; sourceTree = "<group>"; };
@@ -5447,6 +5461,28 @@
54475461
name = Resources;
54485462
sourceTree = "<group>";
54495463
};
5464+
CBAD0EF72CFE1D14006267B8 /* AppStates */ = {
5465+
isa = PBXGroup;
5466+
children = (
5467+
CBAD0EF82CFE1D35006267B8 /* Init.swift */,
5468+
CBAD0EFA2CFE1D3F006267B8 /* Launched.swift */,
5469+
CBAD0EFC2CFE1D48006267B8 /* Active.swift */,
5470+
CBAD0EFE2CFE1D4E006267B8 /* Inactive.swift */,
5471+
CBAD0F002CFE1D54006267B8 /* Background.swift */,
5472+
);
5473+
path = AppStates;
5474+
sourceTree = "<group>";
5475+
};
5476+
CBAD0F042CFE1DA2006267B8 /* AppLifecycle */ = {
5477+
isa = PBXGroup;
5478+
children = (
5479+
CBAD0F052CFE270D006267B8 /* AppStateMachine.swift */,
5480+
CBAD0F072CFE27D5006267B8 /* AppStateTransitions.swift */,
5481+
CBAD0EF72CFE1D14006267B8 /* AppStates */,
5482+
);
5483+
path = AppLifecycle;
5484+
sourceTree = "<group>";
5485+
};
54505486
D62EC3B72C24695800FC9D04 /* DuckPlayer */ = {
54515487
isa = PBXGroup;
54525488
children = (
@@ -6336,6 +6372,7 @@
63366372
F1C5ECF31E37812900C599A4 /* Application */ = {
63376373
isa = PBXGroup;
63386374
children = (
6375+
CBAD0F042CFE1DA2006267B8 /* AppLifecycle */,
63396376
83BE9BC2215D69C1009844D9 /* AppConfigurationFetch.swift */,
63406377
CB24F70E29A3EB15006DCC58 /* AppConfigurationURLProvider.swift */,
63416378
84E341951E2F7EFB00BDBA6F /* AppDelegate.swift */,
@@ -7518,6 +7555,7 @@
75187555
BDE91CDE2C62B90F0005CB74 /* UnifiedFeedbackRootView.swift in Sources */,
75197556
D65625A12C232F5E006EF297 /* SettingsDuckPlayerView.swift in Sources */,
75207557
D6FEB8B52B74994000C3615F /* HeadlessWebViewCoordinator.swift in Sources */,
7558+
CBAD0EF92CFE1D3B006267B8 /* Init.swift in Sources */,
75217559
9F96F73F2C914C57009E45D5 /* OnboardingGradient.swift in Sources */,
75227560
6FE1273D2C204C2500EB5724 /* FavoritesView.swift in Sources */,
75237561
8528AE81212F15D600D0BD74 /* AppRatingPrompt.xcdatamodeld in Sources */,
@@ -7555,6 +7593,7 @@
75557593
8590CB69268A4E190089F6BF /* DebugEtagStorage.swift in Sources */,
75567594
C1CDA3162AFB9C7F006D1476 /* AutofillNeverPromptWebsitesManager.swift in Sources */,
75577595
D668D9272B6937D2008E2FF2 /* SubscriptionITPViewModel.swift in Sources */,
7596+
CBAD0F012CFE1D57006267B8 /* Background.swift in Sources */,
75587597
F1CA3C371F045878005FADB3 /* PrivacyStore.swift in Sources */,
75597598
31DE43C42C2C60E800F8C51F /* DuckPlayerModalPresenter.swift in Sources */,
75607599
37FCAAC029930E26000E420A /* FailedAssertionView.swift in Sources */,
@@ -7621,6 +7660,7 @@
76217660
BDFF031D2BA3D2BD00F324C9 /* DefaultNetworkProtectionVisibility.swift in Sources */,
76227661
F1BE54581E69DE1000FCF649 /* TutorialSettings.swift in Sources */,
76237662
1EE52ABB28FB1D6300B750C1 /* UIImageExtension.swift in Sources */,
7663+
CBAD0EFF2CFE1D50006267B8 /* Inactive.swift in Sources */,
76247664
858650D12469BCDE00C36F8A /* DaxDialogs.swift in Sources */,
76257665
9F5E5AB02C3E4C6000165F54 /* ContextualOnboardingPresenter.swift in Sources */,
76267666
310D091B2799F54900DC0060 /* DownloadManager.swift in Sources */,
@@ -7661,6 +7701,7 @@
76617701
859DB8132CE6263C001F7210 /* TextZoomStorage.swift in Sources */,
76627702
D65625952C22D382006EF297 /* TabViewController.swift in Sources */,
76637703
8C4838B5221C8F7F008A6739 /* GestureToolbarButton.swift in Sources */,
7704+
CBAD0EFD2CFE1D4B006267B8 /* Active.swift in Sources */,
76647705
310ECFDD282A8BB0005029B3 /* EnableAutofillSettingsTableViewCell.swift in Sources */,
76657706
859DB8172CE6263C001F7210 /* TextZoomLevel.swift in Sources */,
76667707
BDE91CD62C6294020005CB74 /* FeedbackCategoryProviding.swift in Sources */,
@@ -7756,6 +7797,7 @@
77567797
D6F93E3E2B50A8A0004C268D /* SubscriptionSettingsView.swift in Sources */,
77577798
1D200C9B2BA31A6A00108701 /* AboutView.swift in Sources */,
77587799
851B12CC22369931004781BC /* AtbAndVariantCleanup.swift in Sources */,
7800+
CBAD0F062CFE2711006267B8 /* AppStateMachine.swift in Sources */,
77597801
D668D92B2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift in Sources */,
77607802
85F2FFCF2211F8E5006BB258 /* TabSwitcherViewController+KeyCommands.swift in Sources */,
77617803
3157B43327F497E90042D3D7 /* SaveLoginView.swift in Sources */,
@@ -7948,6 +7990,7 @@
79487990
311BD1B12836C0CA00AEF6C1 /* AutofillLoginListAuthenticator.swift in Sources */,
79497991
B652DF13287C373A00C12A9C /* ScriptSourceProviding.swift in Sources */,
79507992
854A012B2A54412600FCC628 /* ActivityViewController.swift in Sources */,
7993+
CBAD0F082CFE27E2006267B8 /* AppStateTransitions.swift in Sources */,
79517994
F1CA3C391F045885005FADB3 /* PrivacyUserDefaults.swift in Sources */,
79527995
6F655BE22BAB289E00AC3597 /* DefaultTheme.swift in Sources */,
79537996
6FE1274B2C20943500EB5724 /* ShortcutItemView.swift in Sources */,
@@ -8034,6 +8077,7 @@
80348077
983D71B12A286E810072E26D /* SyncDebugViewController.swift in Sources */,
80358078
6FDA1FB32B59584400AC962A /* AddressDisplayHelper.swift in Sources */,
80368079
F103073B1E7C91330059FEC7 /* BookmarksDataSource.swift in Sources */,
8080+
CBAD0EFB2CFE1D41006267B8 /* Launched.swift in Sources */,
80378081
6FD3F80F2C3EF4F000DA5797 /* DeviceOrientationEnvironmentValue.swift in Sources */,
80388082
85864FBC24D31EF300E756FF /* SuggestionTrayViewController.swift in Sources */,
80398083
D64648AF2B5993890033090B /* SubscriptionEmailViewModel.swift in Sources */,

DuckDuckGo/AppDelegate.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ import os.log
117117

118118
private var didFinishLaunchingStartTime: CFAbsoluteTime?
119119

120+
private let appStateMachine = AppStateMachine()
121+
120122
override init() {
121123
super.init()
122124

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

136+
appStateMachine.handle(.launching(application, launchOptions: launchOptions))
134137
didFinishLaunchingStartTime = CFAbsoluteTimeGetCurrent()
135138
defer {
136139
if let didFinishLaunchingStartTime {
@@ -597,6 +600,8 @@ import os.log
597600
func applicationDidBecomeActive(_ application: UIApplication) {
598601
guard !testing else { return }
599602

603+
appStateMachine.handle(.activating(application))
604+
600605
defer {
601606
if let didFinishLaunchingStartTime {
602607
let launchTime = CFAbsoluteTimeGetCurrent() - didFinishLaunchingStartTime
@@ -700,6 +705,7 @@ import os.log
700705
}
701706

702707
func applicationWillResignActive(_ application: UIApplication) {
708+
appStateMachine.handle(.suspending(application))
703709
Task { @MainActor in
704710
await refreshShortcuts()
705711
await vpnWorkaround.removeRedditSessionWorkaround()
@@ -792,6 +798,7 @@ import os.log
792798
}
793799

794800
func applicationDidEnterBackground(_ application: UIApplication) {
801+
appStateMachine.handle(.backgrounding(application))
795802
displayBlankSnapshotWindow()
796803
autoClear?.startClearingTimer()
797804
lastBackgroundDate = Date()
@@ -837,6 +844,7 @@ import os.log
837844

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

841849
// If showing the onboarding intro ignore deeplinks
842850
guard mainViewController?.needsToShowOnboardingIntro() == false else {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//
2+
// AppStateMachine.swift
3+
// DuckDuckGo
4+
//
5+
// Copyright © 2024 DuckDuckGo. All rights reserved.
6+
//
7+
// Licensed under the Apache License, Version 2.0 (the "License");
8+
// you may not use this file except in compliance with the License.
9+
// You may obtain a copy of the License at
10+
//
11+
// http://www.apache.org/licenses/LICENSE-2.0
12+
//
13+
// Unless required by applicable law or agreed to in writing, software
14+
// distributed under the License is distributed on an "AS IS" BASIS,
15+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
// See the License for the specific language governing permissions and
17+
// limitations under the License.
18+
//
19+
20+
import UIKit
21+
22+
enum AppEvent {
23+
24+
case launching(UIApplication, launchOptions: [UIApplication.LaunchOptionsKey: Any]?)
25+
case activating(UIApplication)
26+
case backgrounding(UIApplication)
27+
case suspending(UIApplication)
28+
29+
case openURL(URL)
30+
31+
}
32+
33+
protocol AppState {
34+
35+
func apply(event: AppEvent) -> any AppState
36+
37+
}
38+
39+
protocol AppEventHandler {
40+
41+
func handle(_ event: AppEvent)
42+
43+
}
44+
45+
final class AppStateMachine: AppEventHandler {
46+
47+
private(set) var currentState: any AppState = Init()
48+
49+
func handle(_ event: AppEvent) {
50+
currentState = currentState.apply(event: event)
51+
}
52+
53+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//
2+
// AppStateTransitions.swift
3+
// DuckDuckGo
4+
//
5+
// Copyright © 2024 DuckDuckGo. All rights reserved.
6+
//
7+
// Licensed under the Apache License, Version 2.0 (the "License");
8+
// you may not use this file except in compliance with the License.
9+
// You may obtain a copy of the License at
10+
//
11+
// http://www.apache.org/licenses/LICENSE-2.0
12+
//
13+
// Unless required by applicable law or agreed to in writing, software
14+
// distributed under the License is distributed on an "AS IS" BASIS,
15+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
// See the License for the specific language governing permissions and
17+
// limitations under the License.
18+
//
19+
20+
import os.log
21+
import Core
22+
23+
extension Init {
24+
25+
func apply(event: AppEvent) -> any AppState {
26+
switch event {
27+
case .launching(let application, let launchOptions):
28+
return Launched(application: application, launchOptions: launchOptions)
29+
default:
30+
return handleUnexpectedEvent(event)
31+
}
32+
}
33+
34+
}
35+
36+
extension Launched {
37+
38+
func apply(event: AppEvent) -> any AppState {
39+
switch event {
40+
case .activating(let application):
41+
return Active(application: application)
42+
case .openURL:
43+
return self
44+
case .launching, .suspending, .backgrounding:
45+
return handleUnexpectedEvent(event)
46+
}
47+
}
48+
49+
}
50+
51+
extension Active {
52+
53+
func apply(event: AppEvent) -> any AppState {
54+
switch event {
55+
case .suspending(let application):
56+
return Inactive(application: application)
57+
case .launching, .activating, .backgrounding, .openURL:
58+
return handleUnexpectedEvent(event)
59+
}
60+
}
61+
62+
}
63+
64+
extension Inactive {
65+
66+
func apply(event: AppEvent) -> any AppState {
67+
switch event {
68+
case .backgrounding(let application):
69+
return Background(application: application)
70+
case .activating(let application):
71+
return Active(application: application)
72+
case .launching, .suspending, .openURL:
73+
return handleUnexpectedEvent(event)
74+
}
75+
}
76+
77+
}
78+
79+
extension Background {
80+
81+
func apply(event: AppEvent) -> any AppState {
82+
switch event {
83+
case .activating(let application):
84+
return Active(application: application)
85+
case .openURL:
86+
return self
87+
case .launching, .suspending, .backgrounding:
88+
return handleUnexpectedEvent(event)
89+
}
90+
}
91+
92+
}
93+
94+
extension AppEvent {
95+
96+
var rawValue: String {
97+
switch self {
98+
case .launching: return "launching"
99+
case .activating: return "activating"
100+
case .backgrounding: return "backgrounding"
101+
case .suspending: return "suspending"
102+
case .openURL: return "openURL"
103+
}
104+
}
105+
106+
}
107+
108+
extension AppState {
109+
110+
func handleUnexpectedEvent(_ event: AppEvent) -> Self {
111+
Logger.lifecycle.error("Invalid transition (\(event.rawValue)) for state (\(type(of: self)))")
112+
DailyPixel.fireDailyAndCount(pixel: .appDidTransitionToUnexpectedState,
113+
withAdditionalParameters: [PixelParameters.appState: String(describing: type(of: self)),
114+
PixelParameters.appEvent: event.rawValue])
115+
return self
116+
}
117+
118+
}

0 commit comments

Comments
 (0)