// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/tips_notifications/model/tips_notification_client.h"
#import <UserNotifications/UserNotifications.h>
#import "base/test/metrics/histogram_tester.h"
#import "base/test/scoped_feature_list.h"
#import "base/test/scoped_mock_clock_override.h"
#import "base/test/task_environment.h"
#import "base/threading/thread_restrictions.h"
#import "components/prefs/scoped_user_pref_update.h"
#import "ios/chrome/browser/default_browser/model/promo_source.h"
#import "ios/chrome/browser/default_browser/model/utils.h"
#import "ios/chrome/browser/default_browser/model/utils_test_support.h"
#import "ios/chrome/browser/first_run/model/first_run.h"
#import "ios/chrome/browser/push_notification/model/constants.h"
#import "ios/chrome/browser/shared/coordinator/scene/scene_state.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
#import "ios/chrome/browser/shared/model/browser/browser_list.h"
#import "ios/chrome/browser/shared/model/browser/browser_list_factory.h"
#import "ios/chrome/browser/shared/model/browser/test/test_browser.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_manager_ios.h"
#import "ios/chrome/browser/shared/public/commands/application_commands.h"
#import "ios/chrome/browser/shared/public/commands/browser_coordinator_commands.h"
#import "ios/chrome/browser/shared/public/commands/command_dispatcher.h"
#import "ios/chrome/browser/shared/public/commands/docking_promo_commands.h"
#import "ios/chrome/browser/shared/public/commands/settings_commands.h"
#import "ios/chrome/browser/shared/public/commands/whats_new_commands.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/tips_notifications/model/utils.h"
#import "ios/chrome/browser/ui/content_suggestions/content_suggestions_commands.h"
#import "ios/chrome/test/ios_chrome_scoped_testing_local_state.h"
#import "ios/testing/scoped_block_swizzler.h"
#import "testing/gtest_mac.h"
#import "testing/platform_test.h"
#import "third_party/ocmock/OCMock/OCMock.h"
#import "third_party/ocmock/gtest_support.h"
#import "ui/base/device_form_factor.h"
using startup_metric_utils::FirstRunSentinelCreationResult;
// A simple class that stubs `PrepareToPresentModal:` by immediately calling
// the provided `completion` callback.
@interface PrepareToPresentModalStub : NSObject
@end
@implementation PrepareToPresentModalStub
- (void)prepareToPresentModal:(ProceduralBlock)completion {
completion();
}
@end
class TipsNotificationClientTest : public PlatformTest {
protected:
TipsNotificationClientTest() {
SetupMockNotificationCenter();
ChromeBrowserState* browser_state = profile_manager_.AddProfileWithBuilder(
TestChromeBrowserState::Builder());
BrowserList* list = BrowserListFactory::GetForBrowserState(browser_state);
mock_scene_state_ = OCMClassMock([SceneState class]);
OCMStub([mock_scene_state_ activationLevel])
.andReturn(SceneActivationLevelForegroundActive);
browser_ = std::make_unique<TestBrowser>(browser_state, mock_scene_state_);
list->AddBrowser(browser_.get());
client_ = std::make_unique<TipsNotificationClient>();
ScopedDictPrefUpdate update(GetApplicationContext()->GetLocalState(),
prefs::kAppLevelPushNotificationPermissions);
update->Set(kTipsNotificationKey, true);
}
// Sets up a mock notification center, so notification requests can be
// tested.
void SetupMockNotificationCenter() {
mock_notification_center_ = OCMClassMock([UNUserNotificationCenter class]);
// Swizzle in the mock notification center.
UNUserNotificationCenter* (^swizzle_block)() =
^UNUserNotificationCenter*() {
return mock_notification_center_;
};
notification_center_swizzler_ = std::make_unique<ScopedBlockSwizzler>(
[UNUserNotificationCenter class], @selector(currentNotificationCenter),
swizzle_block);
}
// Writes the first run sentinel file, to allow notifications to be
// registered.
void WriteFirstRunSentinel() {
base::ScopedAllowBlockingForTesting allow_blocking;
FirstRun::RemoveSentinel();
base::File::Error file_error = base::File::FILE_OK;
FirstRunSentinelCreationResult sentinel_created =
FirstRun::CreateSentinel(&file_error);
ASSERT_EQ(sentinel_created, FirstRunSentinelCreationResult::kSuccess)
<< "Error creating FirstRun sentinel: "
<< base::File::ErrorToString(file_error);
FirstRun::LoadSentinelInfo();
FirstRun::ClearStateForTesting();
}
// Returns an OCMArg that verifies a UNNotificationRequest was passed for the
// given notification `type`.
id NotificationRequestArg(TipsNotificationType type) {
return [OCMArg checkWithBlock:^BOOL(UNNotificationRequest* request) {
TipsNotificationType requested_type =
ParseTipsNotificationType(request).value();
EXPECT_TRUE(IsTipsNotification(request));
EXPECT_EQ(requested_type, type);
return YES;
}];
}
// Returns a mock UNNotificationResponse for the given notification `type`.
id MockRequestResponse(TipsNotificationType type) {
id mock_response = OCMClassMock([UNNotificationResponse class]);
OCMStub([mock_response notification]).andReturn(MockNotification(type));
return mock_response;
}
// Returns a mock UNNotification for the given notification `type`.
id MockNotification(TipsNotificationType type) {
UNNotificationRequest* request =
TipsNotificationRequest(type, TipsNotificationUserType::kUnknown);
id mock_notification = OCMClassMock([UNNotification class]);
OCMStub([mock_notification request]).andReturn(request);
return mock_notification;
}
// Stubs the notification center's completion callback for
// getPendingNotificationRequestsWithCompletionHandler.
void StubGetPendingRequests(NSArray<UNNotificationRequest*>* requests) {
auto completionCaller =
^BOOL(void (^completion)(NSArray<UNNotificationRequest*>* requests)) {
completion(requests);
return YES;
};
OCMStub([mock_notification_center_
getPendingNotificationRequestsWithCompletionHandler:
[OCMArg checkWithBlock:completionCaller]]);
}
// Stubs the notification center's completion callback for
// getPendingNotificationRequestsWithCompletionHandler.
void StubGetDeliveredNotifications(NSArray<UNNotification*>* notifications) {
auto completionCaller =
^BOOL(void (^completion)(NSArray<UNNotification*>* notifications)) {
completion(notifications);
return YES;
};
OCMStub([mock_notification_center_
getDeliveredNotificationsWithCompletionHandler:
[OCMArg checkWithBlock:completionCaller]]);
}
// Clears the pref used to store which notification types have already been
// sent.
void ClearSentNotifications() {
GetApplicationContext()->GetLocalState()->SetInteger(
kTipsNotificationsSentPref, 0);
}
// Sets the pref used to store which notification types have been sent.
void SetSentNotifications(std::vector<TipsNotificationType> types) {
int bits = 0;
for (TipsNotificationType type : types) {
bits |= 1 << int(type);
}
GetApplicationContext()->GetLocalState()->SetInteger(
kTipsNotificationsSentPref, bits);
}
// Stubs the `prepareToPresentModal:` method from `ApplicationCommands` so
// that it immediately calls the completion block.
void StubPrepareToPresentModal() {
prepare_to_present_modal_stub_ = [[PrepareToPresentModalStub alloc] init];
[browser_->GetCommandDispatcher()
startDispatchingToTarget:prepare_to_present_modal_stub_
forProtocol:@protocol(ApplicationCommands)];
}
// Sets up an OCMock expectation that a notification will be requested.
void ExpectNotificationRequest(TipsNotificationType type) {
ExpectNotificationRequest(NotificationRequestArg(type));
}
void ExpectNotificationRequest(id request) {
auto completionCaller = ^BOOL(void (^completion)(NSError* error)) {
completion(nil);
return YES;
};
OCMExpect([mock_notification_center_
addNotificationRequest:request
withCompletionHandler:[OCMArg checkWithBlock:completionCaller]]);
}
// Ensures that Chrome is considered as default browser.
void SetTrueChromeLikelyDefaultBrowser() { LogOpenHTTPURLFromExternalURL(); }
// Ensures that Chrome is not considered as default browser.
void SetFalseChromeLikelyDefaultBrowser() { ClearDefaultBrowserPromoData(); }
// Clears the pref that stores the last action the user took with a Default
// Browser promo.
void ClearDefaultBrowserPromoLastAction() {
GetApplicationContext()->GetLocalState()->ClearPref(
prefs::kIosDefaultBrowserPromoLastAction);
}
// Creates a mock command handler and starts dispatching to it.
id MockHandler(Protocol* protocol) {
id mock_handler = OCMProtocolMock(protocol);
[browser_->GetCommandDispatcher() startDispatchingToTarget:mock_handler
forProtocol:protocol];
return mock_handler;
}
// Simulates foregrounding the app by calling the client's
// OnSceneActiveForegroundBrowserReady method.
void SimulateForegroundingApp() {
base::RunLoop run_loop;
client_->OnSceneActiveForegroundBrowserReady(run_loop.QuitClosure());
run_loop.Run();
}
// Returns the user's type stored in local state prefs.
TipsNotificationUserType GetUserType() {
PrefService* local_state = GetApplicationContext()->GetLocalState();
return static_cast<TipsNotificationUserType>(
local_state->GetInteger(kTipsNotificationsUserType));
}
base::test::TaskEnvironment task_environment_;
const base::HistogramTester histogram_tester_;
IOSChromeScopedTestingLocalState scoped_testing_local_state_;
TestProfileManagerIOS profile_manager_;
id mock_scene_state_;
std::unique_ptr<TestBrowser> browser_;
std::unique_ptr<TipsNotificationClient> client_;
id mock_notification_center_;
std::unique_ptr<ScopedBlockSwizzler> notification_center_swizzler_;
PrepareToPresentModalStub* prepare_to_present_modal_stub_;
};
#pragma mark - Test cases
// Tests that HandleNotificationReception does nothing and returns "NoData".
TEST_F(TipsNotificationClientTest, HandleNotificationReception) {
EXPECT_EQ(client_->HandleNotificationReception(nil),
UIBackgroundFetchResultNoData);
}
// Tests that RegisterActionalableNotifications returns an empty array.
TEST_F(TipsNotificationClientTest, RegisterActionableNotifications) {
EXPECT_EQ(client_->RegisterActionableNotifications().count, 0u);
}
// Tests that the client can register a Default Browser notification.
TEST_F(TipsNotificationClientTest, DefaultBrowserRequest) {
WriteFirstRunSentinel();
SetFalseChromeLikelyDefaultBrowser();
ClearDefaultBrowserPromoLastAction();
StubGetPendingRequests(nil);
SetSentNotifications({TipsNotificationType::kWhatsNew,
TipsNotificationType::kOmniboxPosition,
TipsNotificationType::kSignin,
TipsNotificationType::kSetUpListContinuation,
TipsNotificationType::kDocking});
ExpectNotificationRequest(TipsNotificationType::kDefaultBrowser);
base::RunLoop run_loop;
client_->OnSceneActiveForegroundBrowserReady(run_loop.QuitClosure());
run_loop.Run();
EXPECT_OCMOCK_VERIFY(mock_notification_center_);
histogram_tester_.ExpectUniqueSample(
"IOS.Notifications.Tips.Sent", TipsNotificationType::kDefaultBrowser, 1);
// Run again, but this time simulating a delivered notification.
NSMutableArray<UNNotification*>* delivered_notifications = [NSMutableArray
arrayWithObject:MockNotification(TipsNotificationType::kDefaultBrowser)];
StubGetDeliveredNotifications(delivered_notifications);
base::RunLoop run_loop_2;
client_->OnSceneActiveForegroundBrowserReady(run_loop_2.QuitClosure());
run_loop_2.Run();
EXPECT_OCMOCK_VERIFY(mock_notification_center_);
histogram_tester_.ExpectUniqueSample("IOS.Notifications.Tips.Triggered",
TipsNotificationType::kDefaultBrowser,
1);
// Run again, but this time the delivered notification is gone.
[delivered_notifications removeAllObjects];
base::RunLoop run_loop_3;
client_->OnSceneActiveForegroundBrowserReady(run_loop_3.QuitClosure());
run_loop_3.Run();
EXPECT_OCMOCK_VERIFY(mock_notification_center_);
histogram_tester_.ExpectUniqueSample("IOS.Notifications.Tips.Dismissed",
TipsNotificationType::kDefaultBrowser,
1);
}
// Tests that the client handles a Default Browser notification response.
TEST_F(TipsNotificationClientTest, DefaultBrowserHandle) {
StubPrepareToPresentModal();
id mock_handler = MockHandler(@protocol(SettingsCommands));
OCMExpect([mock_handler
showDefaultBrowserSettingsFromViewController:nil
sourceForUMA:
DefaultBrowserSettingsPageSource::
kTipsNotification]);
id mock_response = MockRequestResponse(TipsNotificationType::kDefaultBrowser);
client_->HandleNotificationInteraction(mock_response);
EXPECT_OCMOCK_VERIFY(mock_handler);
histogram_tester_.ExpectUniqueSample("IOS.Notifications.Tips.Interaction",
TipsNotificationType::kDefaultBrowser,
1);
}
// Tests that the client can register a Whats New notification.
TEST_F(TipsNotificationClientTest, WhatsNewRequest) {
WriteFirstRunSentinel();
SetTrueChromeLikelyDefaultBrowser();
SetSentNotifications({TipsNotificationType::kSetUpListContinuation});
StubGetPendingRequests(nil);
ExpectNotificationRequest(TipsNotificationType::kWhatsNew);
base::RunLoop run_loop;
client_->OnSceneActiveForegroundBrowserReady(run_loop.QuitClosure());
run_loop.Run();
EXPECT_OCMOCK_VERIFY(mock_notification_center_);
histogram_tester_.ExpectUniqueSample("IOS.Notifications.Tips.Sent",
TipsNotificationType::kWhatsNew, 1);
}
// Tests that the client handles a Whats New notification response.
TEST_F(TipsNotificationClientTest, WhatsNewHandle) {
StubPrepareToPresentModal();
id mock_handler = MockHandler(@protocol(WhatsNewCommands));
OCMExpect([mock_handler showWhatsNew]);
id mock_response = MockRequestResponse(TipsNotificationType::kWhatsNew);
client_->HandleNotificationInteraction(mock_response);
EXPECT_OCMOCK_VERIFY(mock_handler);
histogram_tester_.ExpectUniqueSample("IOS.Notifications.Tips.Interaction",
TipsNotificationType::kWhatsNew, 1);
}
// Tests that the client can register a SetUpList Continuation notification.
TEST_F(TipsNotificationClientTest, SetUpListContinuationRequest) {
WriteFirstRunSentinel();
StubGetPendingRequests(nil);
ExpectNotificationRequest(TipsNotificationType::kSetUpListContinuation);
base::RunLoop run_loop;
client_->OnSceneActiveForegroundBrowserReady(run_loop.QuitClosure());
run_loop.Run();
EXPECT_OCMOCK_VERIFY(mock_notification_center_);
histogram_tester_.ExpectUniqueSample(
"IOS.Notifications.Tips.Sent",
TipsNotificationType::kSetUpListContinuation, 1);
}
// Tests that the client handles a SetUpList Continuation notification response.
TEST_F(TipsNotificationClientTest, SetUpListContinuationHandle) {
StubPrepareToPresentModal();
id mock_handler = MockHandler(@protocol(ContentSuggestionsCommands));
OCMExpect([mock_handler showSetUpListSeeMoreMenu]);
id mock_response =
MockRequestResponse(TipsNotificationType::kSetUpListContinuation);
client_->HandleNotificationInteraction(mock_response);
EXPECT_OCMOCK_VERIFY(mock_handler);
histogram_tester_.ExpectUniqueSample(
"IOS.Notifications.Tips.Interaction",
TipsNotificationType::kSetUpListContinuation, 1);
}
// Tests that the client can register a Docking promo notification.
TEST_F(TipsNotificationClientTest, DockingRequest) {
WriteFirstRunSentinel();
SetSentNotifications({TipsNotificationType::kSetUpListContinuation,
TipsNotificationType::kWhatsNew,
TipsNotificationType::kOmniboxPosition,
TipsNotificationType::kDefaultBrowser});
StubGetPendingRequests(nil);
ExpectNotificationRequest(TipsNotificationType::kDocking);
base::RunLoop run_loop;
client_->OnSceneActiveForegroundBrowserReady(run_loop.QuitClosure());
run_loop.Run();
EXPECT_OCMOCK_VERIFY(mock_notification_center_);
histogram_tester_.ExpectUniqueSample("IOS.Notifications.Tips.Sent",
TipsNotificationType::kDocking, 1);
}
// Tests that the client handles a Docking promo notification response.
TEST_F(TipsNotificationClientTest, DockingHandle) {
StubPrepareToPresentModal();
id mock_handler = MockHandler(@protocol(DockingPromoCommands));
OCMExpect([mock_handler showDockingPromo:YES]);
id mock_response = MockRequestResponse(TipsNotificationType::kDocking);
client_->HandleNotificationInteraction(mock_response);
EXPECT_OCMOCK_VERIFY(mock_handler);
histogram_tester_.ExpectUniqueSample("IOS.Notifications.Tips.Interaction",
TipsNotificationType::kDocking, 1);
}
// Tests that the client can register an Omnibox Position promo notification.
TEST_F(TipsNotificationClientTest, OmniboxPositionRequest) {
// OmniboxPositionChoice is only available on phones.
if (ui::GetDeviceFormFactor() != ui::DEVICE_FORM_FACTOR_PHONE) {
return;
}
WriteFirstRunSentinel();
SetSentNotifications({TipsNotificationType::kSetUpListContinuation,
TipsNotificationType::kWhatsNew});
StubGetPendingRequests(nil);
ExpectNotificationRequest(TipsNotificationType::kOmniboxPosition);
base::RunLoop run_loop;
client_->OnSceneActiveForegroundBrowserReady(run_loop.QuitClosure());
run_loop.Run();
EXPECT_OCMOCK_VERIFY(mock_notification_center_);
histogram_tester_.ExpectUniqueSample(
"IOS.Notifications.Tips.Sent", TipsNotificationType::kOmniboxPosition, 1);
}
// Tests that the client handles an Omnibox Position promo notification
// response.
TEST_F(TipsNotificationClientTest, OmniboxPositionHandle) {
// OmniboxPositionChoice is only available on phones.
if (ui::GetDeviceFormFactor() != ui::DEVICE_FORM_FACTOR_PHONE) {
return;
}
StubPrepareToPresentModal();
id mock_handler = MockHandler(@protocol(BrowserCoordinatorCommands));
OCMExpect([mock_handler showOmniboxPositionChoice]);
id mock_response =
MockRequestResponse(TipsNotificationType::kOmniboxPosition);
client_->HandleNotificationInteraction(mock_response);
EXPECT_OCMOCK_VERIFY(mock_handler);
histogram_tester_.ExpectUniqueSample("IOS.Notifications.Tips.Interaction",
TipsNotificationType::kOmniboxPosition,
1);
}
// Tests the the user can be classified as an "Active Seeker" of Tips.
TEST_F(TipsNotificationClientTest, ClassifyUserActiveSeeker) {
base::ScopedMockClockOverride clock;
WriteFirstRunSentinel();
SetSentNotifications({
TipsNotificationType::kWhatsNew,
TipsNotificationType::kOmniboxPosition,
TipsNotificationType::kDefaultBrowser,
TipsNotificationType::kDocking,
TipsNotificationType::kSignin,
});
StubPrepareToPresentModal();
EXPECT_EQ(GetUserType(), TipsNotificationUserType::kUnknown);
StubGetPendingRequests(nil);
ExpectNotificationRequest(TipsNotificationType::kSetUpListContinuation);
base::RunLoop run_loop;
client_->OnSceneActiveForegroundBrowserReady(run_loop.QuitClosure());
run_loop.Run();
EXPECT_OCMOCK_VERIFY(mock_notification_center_);
clock.Advance(base::Hours(1));
SimulateForegroundingApp();
EXPECT_EQ(GetUserType(), TipsNotificationUserType::kUnknown);
clock.Advance(base::Hours(24));
SimulateForegroundingApp();
EXPECT_EQ(GetUserType(), TipsNotificationUserType::kActiveSeeker);
}
// Tests the the user can be classified as "Less Engaged".
TEST_F(TipsNotificationClientTest, ClassifyUserLessEngaged) {
base::ScopedMockClockOverride clock;
WriteFirstRunSentinel();
SetSentNotifications({
TipsNotificationType::kWhatsNew,
TipsNotificationType::kOmniboxPosition,
TipsNotificationType::kDefaultBrowser,
TipsNotificationType::kDocking,
TipsNotificationType::kSignin,
});
StubPrepareToPresentModal();
EXPECT_EQ(GetUserType(), TipsNotificationUserType::kUnknown);
StubGetPendingRequests(nil);
ExpectNotificationRequest(TipsNotificationType::kSetUpListContinuation);
base::RunLoop run_loop;
client_->OnSceneActiveForegroundBrowserReady(run_loop.QuitClosure());
run_loop.Run();
EXPECT_OCMOCK_VERIFY(mock_notification_center_);
clock.Advance(base::Hours(73));
SimulateForegroundingApp();
EXPECT_EQ(GetUserType(), TipsNotificationUserType::kLessEngaged);
}
// Tests that the correct trigger time is used, depending on the user's
// classification.
TEST_F(TipsNotificationClientTest, TestTriggerTimeDeltas) {
EXPECT_EQ(TipsNotificationTriggerDelta(TipsNotificationUserType::kUnknown),
base::Days(3));
EXPECT_EQ(
TipsNotificationTriggerDelta(TipsNotificationUserType::kLessEngaged),
base::Days(21));
EXPECT_EQ(
TipsNotificationTriggerDelta(TipsNotificationUserType::kActiveSeeker),
base::Days(7));
// Verify that the feature params can set the trigger delta.
base::test::ScopedFeatureList feature_list;
feature_list.InitAndEnableFeatureWithParameters(
kIOSTipsNotifications,
{
{kIOSTipsNotificationsUnknownTriggerTimeParam, "1d"},
{kIOSTipsNotificationsLessEngagedTriggerTimeParam, "2d"},
{kIOSTipsNotificationsActiveSeekerTriggerTimeParam, "3d"},
});
EXPECT_EQ(TipsNotificationTriggerDelta(TipsNotificationUserType::kUnknown),
base::Days(1));
EXPECT_EQ(
TipsNotificationTriggerDelta(TipsNotificationUserType::kLessEngaged),
base::Days(2));
EXPECT_EQ(
TipsNotificationTriggerDelta(TipsNotificationUserType::kActiveSeeker),
base::Days(3));
}