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

Optimize the logic of "no blaze campaign" local notification #12585

Merged
merged 7 commits into from
Sep 12, 2024
11 changes: 11 additions & 0 deletions WooCommerce/src/main/kotlin/com/woocommerce/android/AppPrefs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ object AppPrefs {
STORE_CREATION_PROFILER_ANSWERS,
AI_CONTENT_GENERATION_TONE,
AI_PRODUCT_CREATION_IS_FIRST_ATTEMPT,
BLAZE_FIRST_TIME_WITHOUT_CAMPAIGN,
BLAZE_CAMPAIGN_CREATED,
BLAZE_CELEBRATION_SCREEN_SHOWN,
BLAZE_NO_CAMPAIGN_REMINDER_SHOWN,
Expand Down Expand Up @@ -1047,6 +1048,16 @@ object AppPrefs {
value = value
)

var blazeFirstTimeWithoutCampaign: Long
get() = getLong(DeletablePrefKey.BLAZE_FIRST_TIME_WITHOUT_CAMPAIGN, 0L)
set(value) = setLong(DeletablePrefKey.BLAZE_FIRST_TIME_WITHOUT_CAMPAIGN, value)

fun removeBlazeFirstTimeWithoutCampaign() {
remove(DeletablePrefKey.BLAZE_FIRST_TIME_WITHOUT_CAMPAIGN)
}

fun existsBlazeFirstTimeWithoutCampaign() = exists(DeletablePrefKey.BLAZE_FIRST_TIME_WITHOUT_CAMPAIGN)

fun setBlazeCampaignCreated(siteId: Long) {
setBoolean(
key = PrefKeyString("$BLAZE_CAMPAIGN_CREATED:$siteId"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class AppPrefsWrapper @Inject constructor() {

var isBlazeCelebrationScreenShown by AppPrefs::isBlazeCelebrationScreenShown

var blazeFirstTimeWithoutCampaign by AppPrefs::blazeFirstTimeWithoutCampaign

var isBlazeNoCampaignReminderShown by AppPrefs::isBlazeNoCampaignReminderShown

var isBlazeAbandonedCampaignReminderShown by AppPrefs::isBlazeAbandonedCampaignReminderShown
Expand Down Expand Up @@ -376,6 +378,12 @@ class AppPrefsWrapper @Inject constructor() {
fun getNotificationChannelTypeSuffix(channel: NotificationChannelType): Int? =
AppPrefs.getNotificationChannelTypeSuffix(channel)

fun removeBlazeFirstTimeWithoutCampaign() {
AppPrefs.removeBlazeFirstTimeWithoutCampaign()
}

fun existsBlazeFirstTimeWithoutCampaign() = AppPrefs.existsBlazeFirstTimeWithoutCampaign()

fun setBlazeCampaignCreated(siteId: Long) {
AppPrefs.setBlazeCampaignCreated(siteId)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.work.WorkManager
import androidx.work.workDataOf
import com.woocommerce.android.analytics.AnalyticsEvent
import com.woocommerce.android.analytics.AnalyticsTracker
import com.woocommerce.android.util.WooLog
import com.woocommerce.android.viewmodel.ResourceProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
Expand Down Expand Up @@ -47,6 +48,11 @@ class LocalNotificationScheduler @Inject constructor(
AnalyticsTracker.KEY_BLOG_ID to notification.siteId,
)
)
WooLog.d(
tag = WooLog.T.NOTIFICATIONS,
message = "Local notification scheduled: " +
"type=${notification.type}, delay=${notification.delay}${notification.delayUnit}"
)
}

private fun buildPreconditionCheckWorkRequest(notification: LocalNotification): OneTimeWorkRequest {
Expand Down Expand Up @@ -79,5 +85,9 @@ class LocalNotificationScheduler @Inject constructor(

fun cancelScheduledNotification(type: LocalNotificationType) {
workManager.cancelAllWorkByTag(type.value)
WooLog.d(
tag = WooLog.T.NOTIFICATIONS,
message = "Local notification canceled: $type"
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class LocalNotificationWorker @AssistedInject constructor(
when (LocalNotificationType.fromString(type)) {
LocalNotificationType.BLAZE_NO_CAMPAIGN_REMINDER -> {
appsPrefsWrapper.isBlazeNoCampaignReminderShown = true
appsPrefsWrapper.removeBlazeFirstTimeWithoutCampaign()
}

LocalNotificationType.BLAZE_ABANDONED_CAMPAIGN_REMINDER -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import com.woocommerce.android.AppPrefsWrapper
import com.woocommerce.android.extensions.daysLater
import com.woocommerce.android.notifications.local.LocalNotification.BlazeNoCampaignReminderNotification
import com.woocommerce.android.notifications.local.LocalNotificationScheduler
import com.woocommerce.android.notifications.local.LocalNotificationType
import com.woocommerce.android.tools.SelectedSite
import com.woocommerce.android.ui.blaze.CampaignStatusUi
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
Expand All @@ -13,6 +15,7 @@ import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel
import org.wordpress.android.fluxc.store.blaze.BlazeCampaignsStore
import java.util.Calendar
import java.util.concurrent.TimeUnit
import javax.inject.Inject

/**
Expand All @@ -36,29 +39,57 @@ class BlazeCampaignsObserver @Inject constructor(
private suspend fun observeBlazeCampaigns(site: SiteModel) {
blazeCampaignsStore.observeBlazeCampaigns(site)
.filter { it.isNotEmpty() }
.collectLatest { scheduleNotification(it) }
.collectLatest { processCampaigns(it) }
}

private fun scheduleNotification(campaigns: List<BlazeCampaignModel>) {
private fun processCampaigns(campaigns: List<BlazeCampaignModel>) {
if (campaigns.isEmpty()) {
// There are no campaigns. Skip scheduling the notification.
return
} else if (hasActiveEndlessCampaigns(campaigns)) {
appPrefsWrapper.removeBlazeFirstTimeWithoutCampaign()
localNotificationScheduler.cancelScheduledNotification(LocalNotificationType.BLAZE_NO_CAMPAIGN_REMINDER)
} else if (campaigns.any { CampaignStatusUi.isActive(it.uiStatus) }) {
// There are active limited campaigns.
val latestEndTime = getLatestEndTimeOfActiveLimitedCampaigns(campaigns)
scheduleNotification(latestEndTime)
} else if (!appPrefsWrapper.existsBlazeFirstTimeWithoutCampaign() ||
appPrefsWrapper.blazeFirstTimeWithoutCampaign > Calendar.getInstance().time.time
) {
scheduleNotification(Calendar.getInstance().time.time)
}
}

val delayForNotification = calculateDelayForNotification(campaigns)
private fun hasActiveEndlessCampaigns(campaigns: List<BlazeCampaignModel>) = campaigns.any {
it.isEndlessCampaign && CampaignStatusUi.isActive(it.uiStatus)
}

localNotificationScheduler.scheduleNotification(
BlazeNoCampaignReminderNotification(selectedSite.get().siteId, delayForNotification)
)
private fun getLatestEndTimeOfActiveLimitedCampaigns(campaigns: List<BlazeCampaignModel>): Long {
val activeLimitedCampaigns = campaigns.filter {
!it.isEndlessCampaign && CampaignStatusUi.isActive(it.uiStatus)
}
return activeLimitedCampaigns.maxOf { it.startTime.daysLater(it.durationInDays) }.time
}

private fun calculateDelayForNotification(campaigns: List<BlazeCampaignModel>): Long {
val latestEndTime = campaigns.maxOf { it.startTime.daysLater(it.durationInDays) }
val notificationTime = latestEndTime.daysLater(DAYS_DURATION_NO_CAMPAIGN_REMINDER_NOTIFICATION)
return notificationTime.time - Calendar.getInstance().time.time
private fun scheduleNotification(firstTimeWithoutCampaign: Long) {
if (appPrefsWrapper.blazeFirstTimeWithoutCampaign == firstTimeWithoutCampaign) {
// There is already a scheduled notification for firstTimeWithoutCampaign.
return
}
appPrefsWrapper.blazeFirstTimeWithoutCampaign = firstTimeWithoutCampaign

val notificationTime = firstTimeWithoutCampaign +
TimeUnit.DAYS.toMillis(DAYS_DURATION_NO_CAMPAIGN_REMINDER_NOTIFICATION)

val notification = BlazeNoCampaignReminderNotification(
siteId = selectedSite.get().siteId,
delay = notificationTime - Calendar.getInstance().time.time
Copy link
Contributor

Choose a reason for hiding this comment

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

Just a minor suggestion. No need to apply.

Wdyt of converting/rounding this milliseconds to DAYS and use DAYS as delayUnit for this. Makes debugging and checking the logs easier.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good advice! However, while I’m confident it would work, it’s still an extra calculation that may result in rounding by ±1 day. I won't add it just for debugging purposes and will stay on the safe side.

)

localNotificationScheduler.scheduleNotification(notification)
}

companion object {
const val DAYS_DURATION_NO_CAMPAIGN_REMINDER_NOTIFICATION = 30
private const val DAYS_DURATION_NO_CAMPAIGN_REMINDER_NOTIFICATION = 30L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.woocommerce.android.ui.blaze.notification
import com.woocommerce.android.AppPrefsWrapper
import com.woocommerce.android.extensions.daysLater
import com.woocommerce.android.notifications.local.LocalNotificationScheduler
import com.woocommerce.android.notifications.local.LocalNotificationType
import com.woocommerce.android.tools.SelectedSite
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
Expand All @@ -11,6 +12,7 @@ import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions
import org.mockito.kotlin.verifyNoMoreInteractions
import org.mockito.kotlin.whenever
import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel
Expand All @@ -33,6 +35,7 @@ class BlazeCampaignsObserverTest {
whenever(selectedSite.observe()).thenReturn(flowOf(site))
whenever(selectedSite.get()).thenReturn(site)
whenever(appPrefsWrapper.isBlazeNoCampaignReminderShown).thenReturn(notificationShownBefore)
whenever(appPrefsWrapper.existsBlazeFirstTimeWithoutCampaign()).thenReturn(false)
whenever(blazeCampaignsStore.observeBlazeCampaigns(site)).thenReturn(flowOf(campaigns))
blazeCampaignsObserver = BlazeCampaignsObserver(
selectedSite,
Expand Down Expand Up @@ -61,7 +64,20 @@ class BlazeCampaignsObserverTest {
}

@Test
fun `when there are campaigns, schedule the notification`() = runTest {
fun `when there is active endless campaign, don't schedule the notification`() = runTest {
val campaign1 = BLAZE_CAMPAIGN_MODEL.copy(isEndlessCampaign = true)
val campaign2 = BLAZE_CAMPAIGN_MODEL.copy(isEndlessCampaign = false, uiStatus = "completed")
val campaignList = listOf(campaign1, campaign2)
initBlazeCampaignsObserver(campaigns = campaignList)

blazeCampaignsObserver.observeAndScheduleNotifications()

verify(localNotificationScheduler).cancelScheduledNotification(LocalNotificationType.BLAZE_NO_CAMPAIGN_REMINDER)
verifyNoMoreInteractions(localNotificationScheduler)
}

@Test
fun `when there are active limited campaigns, schedule the notification`() = runTest {
val campaign1 = BLAZE_CAMPAIGN_MODEL
val campaign2 = BLAZE_CAMPAIGN_MODEL.copy(durationInDays = 8)
val campaign3 = BLAZE_CAMPAIGN_MODEL.copy(uiStatus = "completed")
Expand All @@ -73,6 +89,17 @@ class BlazeCampaignsObserverTest {
verify(localNotificationScheduler).scheduleNotification(any())
}

@Test
fun `when there is no active campaign and no existing notification, schedule the notification`() = runTest {
val campaign = BLAZE_CAMPAIGN_MODEL.copy(uiStatus = "completed")
val campaignList = listOf(campaign)
initBlazeCampaignsObserver(campaigns = campaignList)

blazeCampaignsObserver.observeAndScheduleNotifications()

verify(localNotificationScheduler).scheduleNotification(any())
}

companion object {
private val BLAZE_CAMPAIGN_MODEL = BlazeCampaignModel(
campaignId = "2",
Expand Down
Loading