Skip to content

Commit 7c5839b

Browse files
Promo banners can be dismissed [VPNAND-1471].
1 parent 6b98890 commit 7c5839b

File tree

9 files changed

+192
-44
lines changed

9 files changed

+192
-44
lines changed

app/src/main/java/com/protonvpn/android/appconfig/ApiNotification.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ data class ApiNotificationOfferPanel(
6060
@SerialName("PageFooter") val pageFooter: String? = null,
6161
@SerialName("FullScreenImage") val fullScreenImage: ApiNotificationOfferFullScreenImage? = null,
6262
@SerialName("ShowCountdown") val showCountdown: Boolean = false, // Only valid for banners.
63+
@SerialName("IsDismissible") val isDismissible: Boolean = true, // Only valid for banners.
6364
)
6465

6566
@Serializable

app/src/main/java/com/protonvpn/android/ui/home/countries/CountryListFragment.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,14 @@ class CountryListFragment : Fragment(R.layout.fragment_country_list), NetworkLoa
131131
}
132132
}
133133
return with(model) {
134-
PromoOfferBannerItem(imageUrl, alternativeText, endTimestamp, clickAction, viewLifecycleOwner)
134+
PromoOfferBannerItem(
135+
imageUrl,
136+
alternativeText,
137+
endTimestamp,
138+
clickAction,
139+
{ viewModel.onUpsellBannerDismissed(model.notificationId) }.takeIf { isDismissible },
140+
viewLifecycleOwner
141+
)
135142
}
136143
}
137144

app/src/main/java/com/protonvpn/android/ui/home/countries/CountryListViewModel.kt

Lines changed: 74 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import com.protonvpn.android.partnerships.PartnershipsRepository
4747
import com.protonvpn.android.settings.data.EffectiveCurrentUserSettings
4848
import com.protonvpn.android.ui.home.InformationActivity
4949
import com.protonvpn.android.ui.home.ServerListUpdater
50+
import com.protonvpn.android.ui.promooffers.PromoOffersPrefs
5051
import com.protonvpn.android.utils.AndroidUtils.whenNotNullNorEmpty
5152
import com.protonvpn.android.utils.CountryTools
5253
import com.protonvpn.android.utils.ServerManager
@@ -55,9 +56,13 @@ import com.protonvpn.android.vpn.ConnectTrigger
5556
import com.protonvpn.android.vpn.DisconnectTrigger
5657
import com.protonvpn.android.vpn.VpnStatusProviderUI
5758
import dagger.hilt.android.lifecycle.HiltViewModel
59+
import kotlinx.coroutines.ExperimentalCoroutinesApi
5860
import kotlinx.coroutines.flow.Flow
61+
import kotlinx.coroutines.flow.MutableStateFlow
5962
import kotlinx.coroutines.flow.combine
6063
import kotlinx.coroutines.flow.distinctUntilChanged
64+
import kotlinx.coroutines.flow.flatMapLatest
65+
import kotlinx.coroutines.flow.flowOf
6166
import kotlinx.coroutines.flow.map
6267
import kotlinx.coroutines.flow.mapNotNull
6368
import java.util.concurrent.TimeUnit
@@ -91,7 +96,9 @@ data class PromoOfferBannerModel(
9196
val imageUrl: String,
9297
val alternativeText: String,
9398
val action: ApiNotificationOfferButton,
99+
val isDismissible: Boolean,
94100
val endTimestamp: Long?,
101+
val notificationId: String,
95102
val reference: String?,
96103
) : ServerListItemModel()
97104

@@ -154,14 +161,44 @@ class CountryListViewModel @Inject constructor(
154161
userSettings: EffectiveCurrentUserSettings,
155162
currentUser: CurrentUser,
156163
restrictConfig: RestrictionsConfig,
157-
apiNotificationManager: ApiNotificationManager,
164+
private val apiNotificationManager: ApiNotificationManager,
165+
private val promoOffersPrefs: PromoOffersPrefs,
158166
) : ViewModel() {
159167

168+
private sealed interface UpsellBanner {
169+
object FreeDefault : UpsellBanner
170+
class Promo(val notification: ApiNotification) : UpsellBanner
171+
}
172+
160173
val vpnStatus = vpnStatusProviderUI.status.asLiveData()
161174

162-
private val bannerNotification: Flow<ApiNotification?> = apiNotificationManager.activeListFlow
163-
.map { notifications -> notifications.firstOrNull { it.type == ApiNotificationTypes.TYPE_COUNTRY_LIST_BANNER } }
164-
.distinctUntilChanged()
175+
// After a banner is dismissed any other banners (e.g. the default one) are suppressed until the activity is
176+
// started again.
177+
private val suppressBanners = MutableStateFlow(false)
178+
179+
private val promoBannerFlow = combine(
180+
apiNotificationManager.activeListFlow,
181+
promoOffersPrefs.visitedOffersFlow
182+
) { notifications, dismissedOffers ->
183+
notifications.firstOrNull {
184+
it.type == ApiNotificationTypes.TYPE_COUNTRY_LIST_BANNER && !dismissedOffers.contains(it.id)
185+
}
186+
}
187+
188+
@OptIn(ExperimentalCoroutinesApi::class)
189+
private val bannerFlow: Flow<UpsellBanner?> = suppressBanners.flatMapLatest { suppressed ->
190+
if (suppressed) {
191+
flowOf(null)
192+
} else {
193+
combine(currentUser.vpnUserFlow, promoBannerFlow) { user, promoBanner ->
194+
when {
195+
promoBanner != null -> UpsellBanner.Promo(promoBanner)
196+
user?.isFreeUser == true -> UpsellBanner.FreeDefault
197+
else -> null
198+
}
199+
}
200+
}
201+
}.distinctUntilChanged()
165202
private val userTierFlow = currentUser.vpnUserFlow.map { it?.userTier }.distinctUntilChanged()
166203

167204
// Scroll to top on downgrade
@@ -175,21 +212,21 @@ class CountryListViewModel @Inject constructor(
175212
serverManager.serverListVersion,
176213
restrictConfig.restrictionFlow,
177214
userTierFlow,
178-
bannerNotification,
179-
) { secureCore, _, restrictions, userTier, notification ->
180-
ServerListState(sections = getItemsFor(secureCore, restrictions, userTier, notification))
215+
bannerFlow,
216+
) { secureCore, _, restrictions, userTier, banner ->
217+
ServerListState(sections = getItemsFor(secureCore, restrictions, userTier, banner))
181218
}
182219

183220
private fun getItemsFor(
184221
secureCore: Boolean,
185222
restrictions: Restrictions,
186223
userTier: Int?,
187-
notification: ApiNotification?,
224+
banner: UpsellBanner?,
188225
): List<ServerListSectionModel> =
189226
if (userTier == VpnUser.FREE_TIER)
190-
getItemsForFreeUser(secureCore, restrictions, notification)
227+
getItemsForFreeUser(secureCore, restrictions, banner)
191228
else
192-
getItemsForPremiumUser(userTier, secureCore, restrictions, notification)
229+
getItemsForPremiumUser(userTier, secureCore, restrictions, banner)
193230

194231
private fun List<ServerGroup>.asListItems(
195232
userTier: Int?,
@@ -210,40 +247,39 @@ class CountryListViewModel @Inject constructor(
210247
private fun getItemsForFreeUser(
211248
secureCore: Boolean,
212249
restrictions: Restrictions,
213-
notification: ApiNotification?
250+
banner: UpsellBanner?
214251
): List<ServerListSectionModel> = buildList {
252+
val upsellBanner = createBannerList(banner)
215253
if (restrictions.serverList) {
216254
add(ServerListSectionModel(
217255
R.string.listFreeCountries,
218256
getRestrictedRecommendedConnections(),
219257
infoType = ServerListSectionModel.InfoType.FreeConnections)
220258
)
221259
val allCountries = getCountriesForList(secureCore)
222-
val upsellBanner = createPromoOfferBanner(notification) ?: FreeUpsellBannerModel
223-
val plusItems =
224-
listOf(upsellBanner) + allCountries.asListItems(VpnUser.FREE_TIER, secureCore, restrictions)
225-
add(ServerListSectionModel(R.string.listPremiumCountries_new_plans, plusItems, itemCount = plusItems.size - 1))
260+
val plusCountries = allCountries.asListItems(VpnUser.FREE_TIER, secureCore, restrictions)
261+
val items = upsellBanner + plusCountries
262+
add(ServerListSectionModel(R.string.listPremiumCountries_new_plans, items, itemCount = plusCountries.size))
226263
} else {
227-
val upsellBanner = createPromoOfferBanner(notification) ?: FreeUpsellBannerModel
228264
val (free, premiumCountries) = getFreeAndPremiumCountries(userTier = VpnUser.FREE_TIER, secureCore)
229265
val premiumItems =
230-
listOf(upsellBanner) + premiumCountries.asListItems(VpnUser.FREE_TIER, secureCore, restrictions)
266+
upsellBanner + premiumCountries.asListItems(VpnUser.FREE_TIER, secureCore, restrictions)
231267
add(ServerListSectionModel(R.string.listFreeCountries, free.asListItems(VpnUser.FREE_TIER, secureCore, restrictions)))
232-
add(ServerListSectionModel(R.string.listPremiumCountries_new_plans, premiumItems))
268+
add(ServerListSectionModel(R.string.listPremiumCountries_new_plans, premiumItems, itemCount = premiumCountries.size))
233269
}
234270
}
235271

236272
private fun getItemsForPremiumUser(
237273
userTier: Int?,
238274
secureCore: Boolean,
239275
restrictions: Restrictions,
240-
notification: ApiNotification?
276+
banner: UpsellBanner?
241277
) = buildList {
242278
val gateways = getGatewayGroupsForList(secureCore)
243279
.asListItems(userTier, secureCore, restrictions, sectionId = SECTION_GATEWAYS)
244280
if (gateways.isNotEmpty())
245281
add(ServerListSectionModel(R.string.listGateways, gateways))
246-
val promoOfferBanner = createPromoOfferBanner(notification)?.let { listOf(it) } ?: emptyList()
282+
val promoOfferBanner = createBannerList(banner)
247283
add(
248284
ServerListSectionModel(R.string.listAllCountries.takeIf { gateways.isNotEmpty() },
249285
promoOfferBanner + getCountriesForList(secureCore).asListItems(userTier, secureCore, restrictions))
@@ -261,6 +297,11 @@ class CountryListViewModel @Inject constructor(
261297
fun getServerPartnerships(server: Server): List<Partner> =
262298
partnershipsRepository.getServerPartnerships(server)
263299

300+
fun onUpsellBannerDismissed(notificationId: String) {
301+
promoOffersPrefs.addVisitedOffer(notificationId)
302+
suppressBanners.value = true
303+
}
304+
264305
data class ServerGroupTitle(val titleRes: Int, val infoType: InformationActivity.InfoType?)
265306

266307
private fun getRestrictedRecommendedConnections(): List<RecommendedConnectionModel> =
@@ -355,8 +396,17 @@ class CountryListViewModel @Inject constructor(
355396
EventBus.post(event)
356397
}
357398

358-
private fun createPromoOfferBanner(notification: ApiNotification?): ServerListItemModel? =
359-
if (notification?.offer?.panel?.button?.url?.isNotEmpty() == true &&
399+
private fun createBannerList(banner: UpsellBanner?): List<ServerListItemModel> {
400+
val bannerModel = when(banner) {
401+
is UpsellBanner.FreeDefault -> FreeUpsellBannerModel
402+
is UpsellBanner.Promo -> createPromoOfferBanner(banner.notification)
403+
null -> null
404+
}
405+
return bannerModel?.let { listOf(it) } ?: emptyList()
406+
}
407+
408+
private fun createPromoOfferBanner(notification: ApiNotification): ServerListItemModel? =
409+
if (notification.offer?.panel?.button?.url?.isNotEmpty() == true &&
360410
notification.offer.panel.fullScreenImage?.source?.isNotEmpty() == true
361411
) {
362412
val fullScreenImage = notification.offer.panel.fullScreenImage
@@ -365,7 +415,9 @@ class CountryListViewModel @Inject constructor(
365415
imageSource.url,
366416
fullScreenImage.alternativeText,
367417
notification.offer.panel.button,
418+
notification.offer.panel.isDismissible,
368419
TimeUnit.SECONDS.toMillis(notification.endTime).takeIf { notification.offer.panel.showCountdown },
420+
notification.id,
369421
notification.reference,
370422
)
371423
} else {

app/src/main/java/com/protonvpn/android/ui/home/countries/PromoOfferBannerItem.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import com.bumptech.glide.Glide
3030
import com.protonvpn.android.R
3131
import com.protonvpn.android.databinding.ItemPromoOfferBannerBinding
3232
import com.protonvpn.android.utils.BindableItemEx
33+
import com.protonvpn.android.utils.setMinSizeTouchDelegate
3334
import com.protonvpn.android.utils.tickFlow
3435
import kotlinx.coroutines.Job
3536
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -49,6 +50,7 @@ class PromoOfferBannerItem(
4950
private val alternativeText: String,
5051
private val endTimestamp: Long?,
5152
private val action: suspend () -> Unit,
53+
private val dismissAction: (() -> Unit)?,
5254
private val parentLifecycleOwner: LifecycleOwner
5355
) : BindableItemEx<ItemPromoOfferBannerBinding>() {
5456

@@ -74,6 +76,10 @@ class PromoOfferBannerItem(
7476
.launchIn(parentLifecycleOwner.lifecycleScope)
7577
}
7678

79+
imageClose.isVisible = dismissAction != null
80+
imageClose.setOnClickListener { dismissAction?.invoke() }
81+
imageClose.setMinSizeTouchDelegate()
82+
7783
root.setOnClickListener(suspendingClickAction(action))
7884
root.alpha = 1f
7985
root.isEnabled = true

app/src/main/java/com/protonvpn/android/ui/main/MainActivityHelper.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import androidx.lifecycle.flowWithLifecycle
2525
import androidx.lifecycle.lifecycleScope
2626
import com.protonvpn.android.utils.Constants
2727
import com.protonvpn.android.utils.launchAndCollectIn
28-
import kotlinx.coroutines.flow.distinctUntilChanged
2928
import kotlinx.coroutines.flow.launchIn
3029
import kotlinx.coroutines.flow.onEach
3130
import kotlinx.coroutines.launch
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
2+
Copyright (c) 2023. Proton AG
3+
4+
This file is part of ProtonVPN.
5+
6+
ProtonVPN is free software: you can redistribute it and/or modify
7+
it under the terms of the GNU General Public License as published by
8+
the Free Software Foundation, either version 3 of the License, or
9+
(at your option) any later version.
10+
11+
ProtonVPN is distributed in the hope that it will be useful,
12+
but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
GNU General Public License for more details.
15+
16+
You should have received a copy of the GNU General Public License
17+
along with ProtonVPN. If not, see <https://www.gnu.org/licenses/>.
18+
-->
19+
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
20+
<item>
21+
<shape android:shape="oval">
22+
<solid android:color="?attr/proton_background_norm" />
23+
</shape>
24+
</item>
25+
<item android:top="2dp" android:bottom="2dp" android:left="2dp" android:right="2dp">
26+
<shape android:shape="oval">
27+
<solid android:color="?attr/proton_background_secondary" />
28+
<stroke android:color="?attr/proton_separator_norm" android:width="1dp" />
29+
</shape>
30+
</item>
31+
</layer-list>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
2+
Copyright (c) 2023. Proton AG
3+
4+
This file is part of ProtonVPN.
5+
6+
ProtonVPN is free software: you can redistribute it and/or modify
7+
it under the terms of the GNU General Public License as published by
8+
the Free Software Foundation, either version 3 of the License, or
9+
(at your option) any later version.
10+
11+
ProtonVPN is distributed in the hope that it will be useful,
12+
but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
GNU General Public License for more details.
15+
16+
You should have received a copy of the GNU General Public License
17+
along with ProtonVPN. If not, see <https://www.gnu.org/licenses/>.
18+
-->
19+
20+
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
21+
android:color="?attr/colorControlHighlight">
22+
<item android:drawable="@drawable/bg_banner_close_button_oval"/>
23+
</ripple>

app/src/main/res/layout/item_promo_offer_banner.xml

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,45 @@
1717
along with ProtonVPN. If not, see <https://www.gnu.org/licenses/>.
1818
-->
1919

20-
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
20+
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
21+
xmlns:app="http://schemas.android.com/apk/res-auto"
2122
xmlns:tools="http://schemas.android.com/tools"
2223
android:layout_width="match_parent"
23-
android:layout_height="wrap_content"
24-
android:layout_marginHorizontal="16dp"
25-
android:layout_marginVertical="8dp"
26-
android:background="@drawable/promo_offer_banner_bg_ripple"
27-
android:orientation="vertical"
28-
android:paddingHorizontal="16dp"
29-
android:paddingVertical="12dp">
24+
android:layout_height="wrap_content">
3025

31-
<ImageView
32-
android:id="@+id/imageBanner"
33-
android:layout_width="match_parent"
34-
android:layout_height="wrap_content" />
35-
36-
<TextView
37-
android:id="@+id/textTimeLeft"
38-
style="@style/Proton.Text.Caption.Weak"
26+
<LinearLayout
3927
android:layout_width="match_parent"
4028
android:layout_height="wrap_content"
41-
tools:text="6 days 15 minutes left" />
29+
android:layout_marginHorizontal="16dp"
30+
android:layout_marginTop="20dp"
31+
android:layout_marginBottom="8dp"
32+
android:background="@drawable/promo_offer_banner_bg_ripple"
33+
android:orientation="vertical"
34+
android:paddingHorizontal="16dp"
35+
android:paddingVertical="12dp">
36+
37+
<ImageView
38+
android:id="@+id/imageBanner"
39+
android:layout_width="match_parent"
40+
android:layout_height="wrap_content" />
41+
42+
<TextView
43+
android:id="@+id/textTimeLeft"
44+
style="@style/Proton.Text.Caption.Weak"
45+
android:layout_width="match_parent"
46+
android:layout_height="wrap_content"
47+
tools:text="6 days 15 minutes left" />
48+
</LinearLayout>
4249

43-
</LinearLayout>
50+
<ImageView
51+
android:id="@+id/imageClose"
52+
android:layout_width="28dp"
53+
android:layout_height="28dp"
54+
android:layout_gravity="top|end"
55+
android:layout_margin="6dp"
56+
android:background="@drawable/bg_banner_close_button_ripple"
57+
android:contentDescription="@string/close"
58+
android:padding="6dp"
59+
app:srcCompat="@drawable/ic_proton_cross_small" />
60+
61+
</FrameLayout>

0 commit comments

Comments
 (0)