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

Commit 72645b1

Browse files
authored
Improve Mobile Site Breakage Report Quality (#2926)
Task/Issue URL: https://app.asana.com/0/72649045549333/1207379730878832/f Description: Implement a mobile experiment to validate the new design for the updated Site Breakage Report flow. This experiment aims to generate better data with more actionable reports, categorize breakages, and reduce triage times.
1 parent 01fa05c commit 72645b1

21 files changed

Lines changed: 441 additions & 71 deletions

‎Core/PixelEvent.swift‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,15 @@ extension Pixel {
724724
case secureVaultL2KeyMigration
725725
case secureVaultL2KeyPasswordMigration
726726

727+
// MARK: Experimental report broken site flows
728+
case reportBrokenSiteShown
729+
case reportBrokenSiteBreakageCategorySelected
730+
case reportBrokenSiteSent
731+
case reportBrokenSiteOverallCategorySelected
732+
case reportBrokenSiteFeedbackCategorySubmitted
733+
case reportBrokenSiteTogglePromptNo
734+
case reportBrokenSiteTogglePromptYes
735+
727736
}
728737

729738
}
@@ -1422,10 +1431,19 @@ extension Pixel.Event {
14221431
case .networkProtectionWidgetDisconnectAttempt: return "m_netp_widget_disconnect_attempt"
14231432
case .networkProtectionWidgetDisconnectSuccess: return "m_netp_widget_disconnect_success"
14241433

1425-
// MARK: Secure Vault
1434+
// MARK: Secure Vault
14261435
case .secureVaultL1KeyMigration: return "m_secure-vault_keystore_event_l1-key-migration"
14271436
case .secureVaultL2KeyMigration: return "m_secure-vault_keystore_event_l2-key-migration"
14281437
case .secureVaultL2KeyPasswordMigration: return "m_secure-vault_keystore_event_l2-key-password-migration"
1438+
1439+
// MARK: Experimental report broken site flows
1440+
case .reportBrokenSiteShown: return "m_report-broken-site_shown"
1441+
case .reportBrokenSiteBreakageCategorySelected: return "m_report-broken-site_breakage-category-selected"
1442+
case .reportBrokenSiteSent: return "m_report-broken-site_sent"
1443+
case .reportBrokenSiteOverallCategorySelected: return "m_report-broken-site_overall-category-selected"
1444+
case .reportBrokenSiteFeedbackCategorySubmitted: return "m_report-broken-site_feedback-category-submitted"
1445+
case .reportBrokenSiteTogglePromptNo: return "m_report-broken-site_toggle-prompt-no"
1446+
case .reportBrokenSiteTogglePromptYes: return "m_report-broken-site_toggle-prompt-yes"
14291447
}
14301448
}
14311449
}

‎Core/PixelExperiment.swift‎

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,14 @@ public enum PixelExperiment: String, CaseIterable {
5858
logic.cleanup()
5959
}
6060

61-
// These are the variants. Rename or add/remove them as needed. If you change the string value
62-
// remember to keep it clear for privacy triage.
61+
// These are the experiment variants. If you need to change the string value,
62+
// ensure it remains clear for privacy triage. Before making any changes,
63+
// save your spot in the queue using this link:
64+
// https://app.asana.com/0/1202500774821704/1207478569595510/f
65+
6366
case control
64-
case newSettings
67+
case breakageSiteReportingFlowA
68+
case breakageSiteReportingFlowB
6569

6670
// Internal state for users not included in any variant
6771
case noVariant
@@ -97,15 +101,8 @@ final internal class PixelExperimentLogic {
97101
return cohort
98102
}
99103

100-
let randomNumber = Int.random(in: 0..<100)
101-
102-
// Allocate user to a cohort based on the random number
103-
let cohort: PixelExperiment
104-
if randomNumber < 50 {
105-
cohort = .control
106-
} else {
107-
cohort = .newSettings
108-
}
104+
let variants: [PixelExperiment] = [.control, .breakageSiteReportingFlowA, .breakageSiteReportingFlowB]
105+
let cohort: PixelExperiment = variants[Int.random(in: variants.indices)]
109106

110107
// Store and use the selected cohort
111108
allocatedCohort = cohort.rawValue

‎DuckDuckGo.xcodeproj/project.pbxproj‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,8 @@
729729
CB5516D0286500290079B175 /* TrackerRadarIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85519124247468580010FDD0 /* TrackerRadarIntegrationTests.swift */; };
730730
CB5516D1286500290079B175 /* ContentBlockingRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CA904C24FD2DB000D41DDF /* ContentBlockingRulesTests.swift */; };
731731
CB5516D2286500290079B175 /* AtbServerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F21DBD21121147002631A6 /* AtbServerTests.swift */; };
732+
CB825C922C071B1400BCC586 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB825C912C071B1400BCC586 /* AlertView.swift */; };
733+
CB825C962C071C9300BCC586 /* AlertViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB825C952C071C9300BCC586 /* AlertViewPresenter.swift */; };
732734
CB84C7BD29A3EF530088A5B8 /* AppConfigurationURLProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB24F70E29A3EB15006DCC58 /* AppConfigurationURLProvider.swift */; };
733735
CB941A6E2B96AB08000F9E7A /* PrivacyDashboard in Frameworks */ = {isa = PBXBuildFile; productRef = CB941A6D2B96AB08000F9E7A /* PrivacyDashboard */; };
734736
CB9B8739278C8E72001F4906 /* WidgetEducationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9B8738278C8E72001F4906 /* WidgetEducationViewController.swift */; };
@@ -2341,6 +2343,8 @@
23412343
CB6CE65B2AF6D4EE00119848 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = "<group>"; };
23422344
CB7407BC2AF6D56D0090A41C /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
23432345
CB75AA132AF6D5AA00AED266 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
2346+
CB825C912C071B1400BCC586 /* AlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = "<group>"; };
2347+
CB825C952C071C9300BCC586 /* AlertViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertViewPresenter.swift; sourceTree = "<group>"; };
23442348
CB84C7C029A3F0280088A5B8 /* ConfigurationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationStore.swift; sourceTree = "<group>"; };
23452349
CB8EF4A32AF6D4C200EF158D /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/InfoPlist.strings; sourceTree = "<group>"; };
23462350
CB8F1F7D2AF6D5370024BF0E /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -2867,6 +2871,8 @@
28672871
1E162604296840D80004127F /* Triangle.swift */,
28682872
1E1626062968413B0004127F /* ViewExtension.swift */,
28692873
EE4BE0082A740BED00CD6AA8 /* ClearTextField.swift */,
2874+
CB825C912C071B1400BCC586 /* AlertView.swift */,
2875+
CB825C952C071C9300BCC586 /* AlertViewPresenter.swift */,
28702876
);
28712877
name = SwiftUI;
28722878
sourceTree = "<group>";
@@ -6455,6 +6461,7 @@
64556461
37FCAABC2992F592000E420A /* MultilineScrollableTextFix.swift in Sources */,
64566462
85DFEDED24C7CCA500973FE7 /* AppWidthObserver.swift in Sources */,
64576463
4B6484F327FD1E350050A7A1 /* MenuControllerView.swift in Sources */,
6464+
CB825C962C071C9300BCC586 /* AlertViewPresenter.swift in Sources */,
64586465
1EE7C299294227EC0026C8CB /* AutoconsentSettingsViewController.swift in Sources */,
64596466
1E8AD1D527C2E22900ABA377 /* DownloadsListSectionViewModel.swift in Sources */,
64606467
EE0798C52B179936000A4F64 /* NetworkProtectionVPNCountryLabelsModel.swift in Sources */,
@@ -6604,6 +6611,7 @@
66046611
85C8E61D2B0E47380029A6BD /* BookmarksDatabaseSetup.swift in Sources */,
66056612
3132FA2C27A07A1B00DD7A12 /* FilePreview.swift in Sources */,
66066613
85C861E628FF1B5F00189466 /* HomeViewSectionRenderersExtension.swift in Sources */,
6614+
CB825C922C071B1400BCC586 /* AlertView.swift in Sources */,
66076615
1DDF40292BA04FCD006850D9 /* SettingsPrivacyProtectionsView.swift in Sources */,
66086616
CB5418632BD90CD000C2CD26 /* BrokenSitePromptViewModel.swift in Sources */,
66096617
F1D477C61F2126CC0031ED49 /* OmniBarState.swift in Sources */,
@@ -9770,7 +9778,7 @@
97709778
repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit";
97719779
requirement = {
97729780
kind = exactVersion;
9773-
version = 154.1.0;
9781+
version = 155.0.0;
97749782
};
97759783
};
97769784
9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = {

‎DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎DuckDuckGo/AlertView.swift‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//
2+
// AlertView.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 Foundation
21+
import SwiftUI
22+
23+
struct AlertButtonStyle: ButtonStyle {
24+
25+
func makeBody(configuration: Configuration) -> some View {
26+
configuration.label
27+
.padding()
28+
.frame(maxWidth: .infinity)
29+
.background(Color(.white).opacity(0.18))
30+
.foregroundColor(.white)
31+
.font(Font(uiFont: UIFont.daxBodyBold()))
32+
.cornerRadius(8)
33+
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
34+
.animation(.easeInOut(duration: 0.2), value: configuration.isPressed)
35+
}
36+
37+
}
38+
39+
struct AlertView: View {
40+
let title: String
41+
let image: String
42+
let leftButton: (title: String, action: () -> Void)
43+
let rightButton: (title: String, action: () -> Void)
44+
45+
var body: some View {
46+
VStack(alignment: .leading, spacing: 10) {
47+
HStack(spacing: 5) {
48+
Image(image)
49+
.resizable()
50+
.aspectRatio(contentMode: .fit)
51+
.frame(width: 53)
52+
Text(title)
53+
.font(Font(uiFont: UIFont.appFont(ofSize: 16)))
54+
.foregroundColor(.white)
55+
}
56+
57+
HStack(spacing: 10) {
58+
Group {
59+
Button(leftButton.title) {
60+
leftButton.action()
61+
}
62+
Button(rightButton.title) {
63+
rightButton.action()
64+
}
65+
}
66+
.buttonStyle(AlertButtonStyle())
67+
}
68+
69+
}
70+
.padding(EdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20))
71+
.background(Color.black)
72+
.cornerRadius(10)
73+
}
74+
}
75+
76+
struct AlertView_Previews: PreviewProvider {
77+
static var previews: some View {
78+
AlertView(title: "Did turning Privacy Protections off resolve the issue on this site?",
79+
image: "SiteBreakage",
80+
leftButton: ("Yes", {}),
81+
rightButton: ("No", {}))
82+
}
83+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//
2+
// AlertViewPresenter.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 Foundation
21+
import SwiftUI
22+
import UIKit
23+
24+
final class AlertViewPresenter {
25+
26+
private enum Constants {
27+
28+
static let animationDuration: Double = 0.2
29+
static let bottomPadding: Double = 28.0
30+
static let horizontalPadding: Double = 20.0
31+
static let maxWidth: Double = 358.0
32+
33+
}
34+
35+
let title: String
36+
let image: String
37+
let leftButton: (title: String, action: () -> Void)
38+
let rightButton: (title: String, action: () -> Void)
39+
40+
private lazy var alertView: AlertView = {
41+
AlertView(title: title,
42+
image: image,
43+
leftButton: (leftButton.title, { [weak self] in self?.leftButton.action(); self?.hide() }),
44+
rightButton: (rightButton.title, { [weak self] in self?.rightButton.action(); self?.hide() }))
45+
}()
46+
private lazy var hostingController: UIHostingController<AlertView> = {
47+
let hostingController = UIHostingController(rootView: alertView)
48+
hostingController.view.backgroundColor = .clear
49+
return hostingController
50+
}()
51+
52+
init(title: String,
53+
image: String,
54+
leftButton: (title: String, action: () -> Void),
55+
rightButton: (title: String, action: () -> Void)) {
56+
self.title = title
57+
self.image = image
58+
self.leftButton = leftButton
59+
self.rightButton = rightButton
60+
}
61+
62+
func present(in viewController: UIViewController, animated: Bool) {
63+
guard let view = viewController.view, let window = view.window else { return }
64+
65+
viewController.addChild(hostingController)
66+
view.addSubview(hostingController.view)
67+
hostingController.didMove(toParent: viewController)
68+
hostingController.view.alpha = 0.0
69+
70+
let alertViewWidth = min(window.frame.width - 2 * Constants.horizontalPadding, Constants.maxWidth)
71+
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
72+
NSLayoutConstraint.activate([
73+
hostingController.view.centerXAnchor.constraint(equalTo: view.centerXAnchor),
74+
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -Constants.bottomPadding),
75+
hostingController.view.widthAnchor.constraint(equalToConstant: alertViewWidth)
76+
])
77+
hostingController.view.layoutIfNeeded()
78+
79+
UIView.animate(withDuration: animated ? Constants.animationDuration : 0) {
80+
self.hostingController.view.alpha = 1.0
81+
}
82+
}
83+
84+
func hide() {
85+
hostingController.willMove(toParent: nil)
86+
hostingController.view.removeFromSuperview()
87+
hostingController.removeFromParent()
88+
}
89+
90+
}

‎DuckDuckGo/AppDelegate.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ import WebKit
225225
}
226226

227227
PixelExperimentForBrokenSites.install()
228-
PixelExperiment.cleanup()
228+
PixelExperiment.install()
229229

230230
// MARK: Sync initialisation
231231
#if DEBUG
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "SiteBreakage.pdf",
5+
"idiom" : "universal"
6+
}
7+
],
8+
"info" : {
9+
"author" : "xcode",
10+
"version" : 1
11+
},
12+
"properties" : {
13+
"preserves-vector-representation" : true
14+
}
15+
}
Binary file not shown.

‎DuckDuckGo/FeedbackFormViewController.swift‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ class FeedbackFormViewController: UIViewController {
6060
@IBOutlet weak var submitFeedbackButton: UIButton!
6161

6262
private var model: FormModel?
63-
63+
private var isFromBrokenSiteReportFlow: Bool = false
64+
6465
static func loadFromStoryboard() -> FeedbackFormViewController {
6566
let storyboard = UIStoryboard(name: "Feedback", bundle: nil)
6667
guard let controller = storyboard.instantiateViewController(withIdentifier: "FeedbackForm") as? FeedbackFormViewController else {
@@ -93,12 +94,14 @@ class FeedbackFormViewController: UIViewController {
9394
}
9495

9596
func configureForNegativeSentiment(for type: FormType,
96-
with feedbackModel: Feedback.Model) {
97+
with feedbackModel: Feedback.Model,
98+
isFromBrokenSiteReportFlow: Bool) {
9799
guard let category = feedbackModel.category else {
98100
fatalError("Feedback model is incomplete!")
99101
}
100102
model = .negative(feedbackModel)
101-
103+
self.isFromBrokenSiteReportFlow = isFromBrokenSiteReportFlow
104+
102105
loadViewIfNeeded()
103106

104107
headerImage.image = UIImage(named: "sadFace")
@@ -160,9 +163,11 @@ class FeedbackFormViewController: UIViewController {
160163
if message.trimmingWhitespace().isEmpty == false {
161164
feedbackSender.submitPositiveSentiment(message: message)
162165
}
163-
164166
case .negative(let feedbackModel):
165167
feedbackSender.fireNegativeSentimentPixel(with: feedbackModel)
168+
if isFromBrokenSiteReportFlow {
169+
feedbackSender.fireBrokenSiteReportPixel(with: feedbackModel)
170+
}
166171
if message.trimmingWhitespace().isEmpty == false {
167172
feedbackSender.submitNegativeSentiment(message: message,
168173
url: websiteTextField.text,

0 commit comments

Comments
 (0)