Skip to content

Commit

Permalink
Update Settings: Address Ppro Issues (#5460)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1207908166761516/1209138029097313/f

### Description

Address Ship Review comments regarding not being able to see Ppro
settings items when subscriptions expired.

It was necessary to consider available Products ([you may see these
referred to as "features" internally in the subscription
module](https://app.asana.com/0/414730916066338/1209117572597478/f))
when a user's subscription has expired or they are in a waiting state,
as their entitlements would end up being empty.

Now we know what Products are available to the user, we can display them
correctly in their disabled state to match the new Settings design.

### Steps to test this PR

Prerequisite: `newSettings` feature flag is enabled

Follow these
[instructions](https://app.asana.com/0/1142021229838617/1208861246073828/f)
to install Play Billing Lab so you can test US and ROW subscription
plans

**US**

Prerequisite: Country/Region is set to US in Play Billing Lab 

_New Settings Top Level_
- [x] Open Settings screen 
- [x] Verify items displayed match design Not Subscribed state

_Subscribed State_
- [ ] Tap "Get Privacy Pro"
- [ ] Purchase Ppro monthly
- [ ] One Purchase is completed return to top level settings screen
- [ ] Verify VPN, PIR, ITR and Settings items are displayed, matching
design Subscribed state
- [ ] Turn on VPN
- [ ] Return to top level settings screen
- [ ] Ensure VPN status indicator is on
- [ ] Turn VPN off
- [ ] Return to top level settings screen
- [ ] Ensure VPN status is off

_Expired State_
- [ ] Tap "Subscription Settings"
- [ ] Click "Update Plan or Cancel" item
- [ ] Cancel the Subscription
- [ ] Wait 5 mins
- [ ] Return to top level settings screen
- [ ] Verify VPN, PIR, ITR are in a disabled state like designs
- [ ] Verify VPN, PIR, ITR are not clickable
- [ ] Verify Settings item is in expired state 

_Remove subscription_
- [ ] Re-purchase Ppro
- [ ] From the Subscription Settings screen click "Remove From this
Device"
- [ ] Return to top level settings screen
- [ ] Verify you're in the Not Subscribed state

_Activating State_

Prerequisite: Apply patch in
[task](https://app.asana.com/0/1207908166761516/1209138029097313/f) as
this is hard to reproduce via device

- [ ] Apply patch
- [ ] Open settings
- [ ] Verify Ppro is in Activating state like designs
- [ ] Verify VPN, PIR, ITR are not clickable
- [ ] Re-purchase Ppro
- [ ] From the Subscription Settings screen click "Remove From this
Device"
- [ ] Return to top level settings screen
- [ ] Verify you're in the Not Subscribed state

**ROW**

Prerequisite: Country/Region is set to UK in Play Billing Lab 

_New Settings Top Level_
- [ ] Open Settings screen 
- [ ] Verify items displayed match design Not Subscribed state

_Subscribed State_
- [ ] Tap "Get Privacy Pro"
- [ ] Purchase Ppro monthly
- [ ] One Purchase is completed return to top level settings screen
- [ ] Verify only VPN, ITR and Settings items are displayed and match
design Subscribed state
- [ ] Turn on VPN
- [ ] Return to top level settings screen
- [ ] Ensure VPN status indicator is on
- [ ] Turn VPN off
- [ ] Return to top level settings screen
- [ ] Ensure VPN status is off

_Expired State_
- [ ] Tap "Subscription Settings"
- [ ] Click "Update Plan or Cancel" item
- [ ] Cancel the Subscription
- [ ] Wait 5 mins
- [ ] Return to top level settings screen
- [ ] Verify only VPN, ITR are in a disabled state like designs
- [ ] Verify VPN, ITR are not clickable
- [ ] Verify Settings item is in expired state 

_Remove subscription_
- [ ] Re-purchase Ppro
- [ ] From the Subscription Settings screen click "Remove From this
Device"
- [ ] Return to top level settings screen
- [ ] Verify you're in the Not Subscribed state

### UI changes

No UI changes but it's helpful to see the
[designs](https://www.figma.com/design/CjH849hL53lhsPlf6Ufeo4/%E2%9A%99%EF%B8%8F-Browser-Settings-Documentation-(All-Platforms)?node-id=7605-431390&t=RdmuNULWUyR1cMH7-4)
to remember our new states
  • Loading branch information
mikescamell authored Jan 14, 2025
1 parent d88e5ae commit 019e7fb
Show file tree
Hide file tree
Showing 20 changed files with 1,186 additions and 357 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ class SettingsListItem @JvmOverloads constructor(
}

/** Sets the item click listener */
fun setClickListener(onClick: () -> Unit) {
binding.root.setOnClickListener { onClick() }
fun setClickListener(onClick: (() -> Unit)?) {
binding.root.setOnClickListener { onClick?.invoke() }
}

/** Sets whether the status indicator is on or off */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package com.duckduckgo.networkprotection.impl.subscription
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.networkprotection.impl.subscription.NetpSubscriptionManager.VpnStatus
import com.duckduckgo.settings.api.NewSettingsFeature
import com.duckduckgo.subscriptions.api.Product.NetP
import com.duckduckgo.subscriptions.api.SubscriptionStatus
import com.duckduckgo.subscriptions.api.Subscriptions
Expand All @@ -38,8 +37,6 @@ interface NetpSubscriptionManager {
EXPIRED,
SIGNED_OUT,
INACTIVE,
WAITING,
INELIGIBLE,
}
}

Expand All @@ -55,7 +52,6 @@ fun VpnStatus.isExpired(): Boolean {
class RealNetpSubscriptionManager @Inject constructor(
private val subscriptions: Subscriptions,
private val dispatcherProvider: DispatcherProvider,
private val newSettingsFeature: NewSettingsFeature,
) : NetpSubscriptionManager {

override suspend fun getVpnStatus(): VpnStatus {
Expand All @@ -75,29 +71,15 @@ class RealNetpSubscriptionManager @Inject constructor(
private fun hasValidEntitlementFlow(): Flow<Boolean> = subscriptions.getEntitlementStatus().map { it.contains(NetP) }

private suspend fun getVpnStatusInternal(hasValidEntitlement: Boolean): VpnStatus {
return if (newSettingsFeature.self().isEnabled()) {
when {
!hasValidEntitlement -> VpnStatus.INELIGIBLE
else -> {
when (subscriptions.getSubscriptionStatus()) {
SubscriptionStatus.INACTIVE, SubscriptionStatus.EXPIRED -> VpnStatus.EXPIRED
SubscriptionStatus.UNKNOWN -> VpnStatus.SIGNED_OUT
SubscriptionStatus.AUTO_RENEWABLE, SubscriptionStatus.NOT_AUTO_RENEWABLE, SubscriptionStatus.GRACE_PERIOD -> VpnStatus.ACTIVE
SubscriptionStatus.WAITING -> VpnStatus.WAITING
}
}
}
} else {
val subscriptionState = subscriptions.getSubscriptionStatus()
when (subscriptionState) {
SubscriptionStatus.INACTIVE, SubscriptionStatus.EXPIRED -> VpnStatus.EXPIRED
SubscriptionStatus.UNKNOWN -> VpnStatus.SIGNED_OUT
else -> {
if (hasValidEntitlement) {
VpnStatus.ACTIVE
} else {
VpnStatus.INACTIVE
}
val subscriptionState = subscriptions.getSubscriptionStatus()
return when (subscriptionState) {
SubscriptionStatus.INACTIVE, SubscriptionStatus.EXPIRED -> VpnStatus.EXPIRED
SubscriptionStatus.UNKNOWN -> VpnStatus.SIGNED_OUT
else -> {
if (hasValidEntitlement) {
VpnStatus.ACTIVE
} else {
VpnStatus.INACTIVE
}
}
}
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,9 @@ import com.duckduckgo.networkprotection.impl.subscription.settings.ProSettingNet
import com.duckduckgo.networkprotection.impl.subscription.settings.ProSettingNetPViewModel.Command.OpenNetPScreen
import com.duckduckgo.networkprotection.impl.subscription.settings.ProSettingNetPViewModel.Factory
import com.duckduckgo.networkprotection.impl.subscription.settings.ProSettingNetPViewModel.NetPEntryState
import com.duckduckgo.networkprotection.impl.subscription.settings.ProSettingNetPViewModel.NetPEntryState.Activating
import com.duckduckgo.networkprotection.impl.subscription.settings.ProSettingNetPViewModel.NetPEntryState.Expired
import com.duckduckgo.networkprotection.impl.subscription.settings.ProSettingNetPViewModel.NetPEntryState.Disabled
import com.duckduckgo.networkprotection.impl.subscription.settings.ProSettingNetPViewModel.NetPEntryState.Enabled
import com.duckduckgo.networkprotection.impl.subscription.settings.ProSettingNetPViewModel.NetPEntryState.Hidden
import com.duckduckgo.networkprotection.impl.subscription.settings.ProSettingNetPViewModel.NetPEntryState.Subscribed
import dagger.android.support.AndroidSupportInjection
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -79,7 +78,7 @@ class ProSettingNetPView @JvmOverloads constructor(
coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

viewModel.viewState
.onEach { updateNetPSettings(it.networkProtectionEntryState) }
.onEach { updateNetPSettings(it.netPEntryState) }
.launchIn(coroutineScope!!)

viewModel.commands()
Expand All @@ -91,15 +90,14 @@ class ProSettingNetPView @JvmOverloads constructor(
with(binding.netpPSetting) {
when (networkProtectionEntryState) {
Hidden -> isGone = true
Activating,
Expired,
-> {
is Disabled -> {
isVisible = true
isClickable = false
setClickListener(null)
setLeadingIconResource(R.drawable.ic_vpn_grayscale_color_24)
setStatus(isOn = false)
}
is Subscribed -> {
is Enabled -> {
isVisible = true
isClickable = true
setClickListener { viewModel.onNetPSettingClicked() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,10 @@ import com.duckduckgo.networkprotection.api.NetworkProtectionAccessState
import com.duckduckgo.networkprotection.api.NetworkProtectionState
import com.duckduckgo.networkprotection.api.NetworkProtectionState.ConnectionState
import com.duckduckgo.networkprotection.impl.pixels.NetworkProtectionPixelNames.NETP_SETTINGS_PRESSED
import com.duckduckgo.networkprotection.impl.subscription.settings.NetworkProtectionSettingsState.NetPSettingsState
import com.duckduckgo.networkprotection.impl.subscription.settings.NetworkProtectionSettingsState.NetPSettingsState.Hidden
import com.duckduckgo.networkprotection.impl.subscription.settings.NetworkProtectionSettingsState.NetPSettingsState.Visible.Activating
import com.duckduckgo.networkprotection.impl.subscription.settings.NetworkProtectionSettingsState.NetPSettingsState.Visible.Expired
import com.duckduckgo.networkprotection.impl.subscription.settings.NetworkProtectionSettingsState.NetPSettingsState.Visible.Subscribed
import com.duckduckgo.networkprotection.impl.subscription.settings.ProSettingNetPViewModel.Command.OpenNetPScreen
import com.duckduckgo.subscriptions.api.Product.NetP
import com.duckduckgo.subscriptions.api.SubscriptionStatus
import com.duckduckgo.subscriptions.api.Subscriptions
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
Expand All @@ -44,30 +42,32 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import logcat.logcat

@SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle
class ProSettingNetPViewModel(
private val networkProtectionSettingsState: NetworkProtectionSettingsState,
private val networkProtectionState: NetworkProtectionState,
private val networkProtectionAccessState: NetworkProtectionAccessState,
private val subscriptions: Subscriptions,
private val dispatcherProvider: DispatcherProvider,
private val pixel: Pixel,
) : ViewModel(), DefaultLifecycleObserver {

data class ViewState(val networkProtectionEntryState: NetPEntryState = NetPEntryState.Hidden)
data class ViewState(val netPEntryState: NetPEntryState = NetPEntryState.Hidden)

sealed class Command {
data class OpenNetPScreen(val params: ActivityParams) : Command()
}

sealed class NetPEntryState {

data object Hidden : NetPEntryState()
data class Subscribed(val isActive: Boolean) : NetPEntryState()
data object Expired : NetPEntryState()
data object Activating : NetPEntryState()
data class Enabled(val isActive: Boolean) : NetPEntryState()
data object Disabled : NetPEntryState()
}

private val command = Channel<Command>(1, BufferOverflow.DROP_OLDEST)
Expand All @@ -78,17 +78,56 @@ class ProSettingNetPViewModel(
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)

viewModelScope.launch {
combine(
networkProtectionSettingsState.getNetPSettingsStateFlow(),
networkProtectionState.getConnectionStateFlow(),
) { accessState, connectionState ->
_viewState.emit(
viewState.value.copy(
networkProtectionEntryState = getNetworkProtectionEntryState(accessState, connectionState),
),
)
}.flowOn(dispatcherProvider.main()).launchIn(viewModelScope)
combine(
subscriptions.getEntitlementStatus().map { entitledProducts -> entitledProducts.contains(NetP) },
networkProtectionState.getConnectionStateFlow(),
) { netpEntitlementStatus, connectionState ->

val subscriptionStatus = subscriptions.getSubscriptionStatus()

val netPEntryState = getNetpEntryState(netpEntitlementStatus, connectionState, subscriptionStatus)

_viewState.update { it.copy(netPEntryState = netPEntryState) }
}
.flowOn(dispatcherProvider.main())
.launchIn(viewModelScope)
}

private suspend fun getNetpEntryState(
netpEntitlementStatus: Boolean,
connectionState: ConnectionState,
subscriptionStatus: SubscriptionStatus,
): NetPEntryState {
return when (subscriptionStatus) {
SubscriptionStatus.UNKNOWN -> {
handleRevokedVPNState()
NetPEntryState.Hidden
}

SubscriptionStatus.INACTIVE,
SubscriptionStatus.EXPIRED,
SubscriptionStatus.WAITING,
-> {
if (hasNetpProduct()) {
NetPEntryState.Disabled
} else {
handleRevokedVPNState()
NetPEntryState.Hidden
}
}

SubscriptionStatus.AUTO_RENEWABLE,
SubscriptionStatus.NOT_AUTO_RENEWABLE,
SubscriptionStatus.GRACE_PERIOD,
-> {
if (netpEntitlementStatus) {
NetPEntryState.Enabled(isActive = connectionState.isConnected())
} else {
// ensure VPN is stopped in case entitlement is revoked
handleRevokedVPNState()
NetPEntryState.Hidden
}
}
}
}

Expand All @@ -102,32 +141,32 @@ class ProSettingNetPViewModel(
}
}

private fun getNetworkProtectionEntryState(
settingsState: NetPSettingsState,
networkProtectionConnectionState: ConnectionState,
): NetPEntryState =
when (settingsState) {
Hidden -> NetPEntryState.Hidden
Subscribed -> NetPEntryState.Subscribed(isActive = networkProtectionConnectionState.isConnected())
Activating -> NetPEntryState.Activating
Expired -> NetPEntryState.Expired
private suspend fun hasNetpProduct(): Boolean {
val products = subscriptions.getAvailableProducts()
return products.contains(NetP)
}

private suspend fun handleRevokedVPNState() {
if (networkProtectionState.isEnabled()) {
networkProtectionState.stop()
}
}

@Suppress("UNCHECKED_CAST")
class Factory @Inject constructor(
private val networkProtectionSettingsState: NetworkProtectionSettingsState,
private val networkProtectionState: NetworkProtectionState,
private val networkProtectionAccessState: NetworkProtectionAccessState,
private val subscriptions: Subscriptions,
private val dispatcherProvider: DispatcherProvider,
private val pixel: Pixel,
) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return with(modelClass) {
when {
isAssignableFrom(ProSettingNetPViewModel::class.java) -> ProSettingNetPViewModel(
networkProtectionSettingsState,
networkProtectionState,
networkProtectionAccessState,
subscriptions,
dispatcherProvider,
pixel,
)
Expand Down
Loading

0 comments on commit 019e7fb

Please sign in to comment.