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

[MOB-3221] Native SRPV Analytics #873

Merged
merged 12 commits into from
Mar 24, 2025
Merged
4 changes: 3 additions & 1 deletion firefox-ios/Client.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -25526,10 +25526,11 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to change this to run EcosiaTests locally since it was with Automatic signing enabled.

COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
Expand All @@ -25556,6 +25557,7 @@
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.ecosia.framework.EcosiaTests;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = NO;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ extension AppSettingsTableViewController {
FasterInactiveTabs(settings: self, settingsDelegate: self),
UnleashBrazeIntegrationSetting(settings: self),
UnleashAPNConsent(settings: self),
UnleashNativeSRPVAnalyticsSetting(settings: self),
UnleashSeedCounterNTPSetting(settings: self),
]

Expand Down
10 changes: 10 additions & 0 deletions firefox-ios/Client/Ecosia/Settings/EcosiaDebugSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,16 @@ final class UnleashAPNConsent: UnleashVariantResetSetting {
}
}

final class UnleashNativeSRPVAnalyticsSetting: UnleashVariantResetSetting {
override var titleName: String? {
"Native SRPV Analytics"
}

override var unleashEnabled: Bool? {
Unleash.isEnabled(.nativeSRPVAnalytics)
}
}

final class AnalyticsIdentifierSetting: HiddenSetting {
override var title: NSAttributedString? {
return NSAttributedString(string: "Debug: Analytics Identifier", attributes: [NSAttributedString.Key.foregroundColor: theme.colors.ecosia.tableViewRowText])
Expand Down
16 changes: 12 additions & 4 deletions firefox-ios/Client/Frontend/Toolbar+URLBar/URLBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Common
import Shared
import SnapKit
import UIKit
import Ecosia

private struct URLBarViewUX {
static let LocationLeftPadding: CGFloat = 8
Expand Down Expand Up @@ -248,15 +249,22 @@ class URLBarView: UIView,
}

set(newURL) {
// Ecosia: Update URL accordingly
// locationView.url = newURL
/* Ecosia: Change setter to update URL accordingly and track search event when needed
locationView.url = newURL
*/
let oldURL = currentURL
var updatedUrl = newURL
// Ecosia: Ecosify if needed
if updatedUrl?.shouldEcosify() ?? false {
updatedUrl = newURL?.ecosified(isIncognitoEnabled: isPrivate)
}
locationView.url = updatedUrl

// Ecosia: update visibility of reload/multi-state button
// Ecosia: Track search if url changed and is Ecosia's vertical
// (this has to be done after ecosifying so we properly track only if changed)
if let updatedUrl = updatedUrl, oldURL != updatedUrl, updatedUrl.isEcosiaSearchVertical() {
Analytics.shared.inappSearch(url: updatedUrl)
}
// Ecosia: Update constraints if needed
if !inOverlayMode {
setNeedsUpdateConstraints()
}
Expand Down
18 changes: 18 additions & 0 deletions firefox-ios/Ecosia/Analytics/Analytics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ open class Analytics {
private static let abTestSchema = "iglu:org.ecosia/abtest_context/jsonschema/1-0-1"
private static let consentSchema = "iglu:org.ecosia/eccc_context/jsonschema/1-0-2"
static let userSchema = "iglu:org.ecosia/app_user_state_context/jsonschema/1-0-0"
static let inappSearchSchema = "iglu:org.ecosia/inapp_search_event/jsonschema/1-0-0"
private static let abTestRoot = "ab_tests"
private static let namespace = "ios_sp"

Expand Down Expand Up @@ -254,6 +255,23 @@ open class Analytics {
.label(label?.rawValue))
}

// MARK: In-App Search
public func inappSearch(url: URL) {
guard NativeSRPVAnalyticsExperiment.isEnabled,
let query = url.getEcosiaSearchQuery() else {
return
}
let payload: [String: Any?] = [
"query": query,
"page_num": url.getEcosiaSearchPage(),
"plt_name": "ios",
"plt_v": Bundle.version as NSObject,
"search_type": url.getEcosiaSearchVerticalPath()
]
track(SelfDescribing(schema: Self.inappSearchSchema,
payload: payload.compactMapValues({ $0 })))
}

// MARK: Settings
public func searchbarChanged(to position: String) {
track(Structured(category: Category.settings.rawValue,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ extension Unleash {
case brazeIntegration = "mob_ios_braze_integration"
case configTest = "mob_ios_staging_config"
case seedCounterNTP = "mob_ios_seed_counter_ntp"
case nativeSRPVAnalytics = "mob_ios_native_srpv_analytics"
case newsletterCard = "mob_ios_newsletter_card"
}

Expand Down
64 changes: 56 additions & 8 deletions firefox-ios/Ecosia/Core/URL+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,30 @@ import Foundation

extension URL {

public enum Key: String {
public enum EcosiaQueryItemName: String {
case
page = "p",
query = "q",
typeTag = "tt",
userId = "_sp"
}

public enum EcosiaSearchVertical: String, CaseIterable {
case search
case images
case news
case videos

init?(path: String) {
let pathWithNoLeadingSlash = String(path.dropFirst())
self.init(rawValue: pathWithNoLeadingSlash)
}
}

public static func ecosiaSearchWithQuery(_ query: String, urlProvider: URLProvider = Environment.current.urlProvider) -> URL {
var components = URLComponents(url: urlProvider.root, resolvingAgainstBaseURL: false)!
components.path = "/search"
components.queryItems = [item(key: .query, value: query), item(key: .typeTag, value: "iosapp")]
components.queryItems = [item(name: .query, value: query), item(name: .typeTag, value: "iosapp")]
return components.url!
}

Expand All @@ -29,6 +42,41 @@ extension URL {
return components.path == "/search"
}

public func isEcosiaSearchVertical(_ urlProvider: URLProvider = Environment.current.urlProvider) -> Bool {
getEcosiaSearchVerticalPath(urlProvider) != nil
}

public func getEcosiaSearchVerticalPath(_ urlProvider: URLProvider = Environment.current.urlProvider) -> String? {
guard isEcosia(urlProvider),
let components = components else {
return nil
}
return EcosiaSearchVertical(path: components.path)?.rawValue
}

public func getEcosiaSearchQuery(_ urlProvider: URLProvider = Environment.current.urlProvider) -> String? {
guard isEcosia(urlProvider),
let components = components else {
return nil
}
return components.queryItems?.first(where: {
$0.name == EcosiaQueryItemName.query.rawValue
})?.value
}

public func getEcosiaSearchPage(_ urlProvider: URLProvider = Environment.current.urlProvider) -> Int? {
guard isEcosia(urlProvider),
let components = components else {
return nil
}
if let pageNumber = components.queryItems?.first(where: {
$0.name == EcosiaQueryItemName.page.rawValue
})?.value {
return Int(pageNumber)
}
return nil
}

/// Check whether the URL should be Ecosified. At the moment this is true for every Ecosia URL.
public func shouldEcosify(_ urlProvider: URLProvider = Environment.current.urlProvider) -> Bool {
return isEcosia(urlProvider)
Expand All @@ -38,7 +86,7 @@ extension URL {
guard isEcosia(urlProvider),
var components = components
else { return self }
components.queryItems?.removeAll(where: { $0.name == Key.userId.rawValue })
components.queryItems?.removeAll(where: { $0.name == EcosiaQueryItemName.userId.rawValue })
var items = components.queryItems ?? .init()
/*
The `sendAnonymousUsageData` is set by the native UX component in settings
Expand All @@ -52,7 +100,7 @@ extension URL {
!User.shared.hasAnalyticsCookieConsent ||
!User.shared.sendAnonymousUsageData
let userId = shouldAnonymizeUserId ? UUID(uuid: UUID_NULL).uuidString : User.shared.analyticsId.uuidString
items.append(Self.item(key: .userId, value: userId))
items.append(Self.item(name: .userId, value: userId))
components.queryItems = items
return components.url!
}
Expand All @@ -63,8 +111,8 @@ extension URL {
.policy
}

private subscript(_ key: Key) -> String? {
components?.queryItems?.first { $0.name == key.rawValue }?.value
private subscript(_ itemName: EcosiaQueryItemName) -> String? {
components?.queryItems?.first { $0.name == itemName.rawValue }?.value
}

private func isEcosia(_ urlProvider: URLProvider = Environment.current.urlProvider) -> Bool {
Expand All @@ -78,7 +126,7 @@ extension URL {
URLComponents(url: self, resolvingAgainstBaseURL: false)
}

private static func item(key: Key, value: String) -> URLQueryItem {
.init(name: key.rawValue, value: value)
private static func item(name: EcosiaQueryItemName, value: String) -> URLQueryItem {
.init(name: name.rawValue, value: value)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import Foundation

struct NativeSRPVAnalyticsExperiment {

private init() {}

static var isEnabled: Bool {
Unleash.isEnabled(.nativeSRPVAnalytics)
}
}
41 changes: 41 additions & 0 deletions firefox-ios/EcosiaTests/Analytics/AnalyticsSpyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ final class AnalyticsSpy: Analytics {
}
}

var inappSearchUrlCalled: URL?
override func inappSearch(url: URL) {
inappSearchUrlCalled = url
}

var ntpTopSiteActionCalled: Action.TopSite?
var ntpTopSitePropertyCalled: Property.TopSite?
var ntpTopSitePositionCalled: NSNumber?
Expand Down Expand Up @@ -769,6 +774,42 @@ final class AnalyticsSpyTests: XCTestCase {
XCTAssertEqual(analyticsSpy.navigationLabelCalled, testSection.label, "Analytics should track navigationLabelCalled correctly.")
}

// MARK: - URL Bar Search Event

func testURLBarViewTracksSearchEventOnEcosiaVerticalURLChange() {
let urlBar = URLBarView(profile: profileMock, windowUUID: .XCTestDefaultUUID)

let rootURL = Environment.current.urlProvider.root
let testCases = [
("https://www.example.org", false, "Does not track external URLs"),
("\(rootURL)", false, "Does not track index page"),
("\(rootURL)/search?q=test", true, "Tracks search query"),
("\(rootURL)/search?q=test", false, "Does not track if url did not change"),
("\(rootURL)/images?q=test1", true, "Tracks images query"),
("\(rootURL)/news?q=test2&p=1", true, "Tracks news query"),
("\(rootURL)/videos?q=test3", true, "Tracks videos query"),
("\(rootURL)/settings", false, "Does not track non-search pages"),
("https://blog.ecosia.org/", false, "Does not track on other Ecosia urls"),
]

for (urlString, shouldTrack, message) in testCases {
analyticsSpy = AnalyticsSpy()
Analytics.shared = analyticsSpy
let url = URL(string: urlString)!
urlBar.currentURL = url

if shouldTrack {
XCTAssertEqual(analyticsSpy.inappSearchUrlCalled?.absoluteString,
url.ecosified(isIncognitoEnabled: false).absoluteString,
"Failure on: \(message)")
} else {
XCTAssertNil(analyticsSpy.inappSearchUrlCalled, "Failure on: \(message)")
}
analyticsSpy = nil
Analytics.shared = Analytics()
}
}

// MARK: - Analytics Context Tests

func testAddUserStateContextOnResumeEvent() {
Expand Down
Loading
Loading