Skip to content

Commit

Permalink
Update Settings: Update PIR Settings Item (#5397)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1207908166761516/1208966262488272/f

### Description
Adds support for different PIR (Private Information Removal)
subscription states in the settings menu, including active, expired,
activating, and hidden states. Introduces a new subscription manager to
handle PIR status and updates the UI to reflect these states with
appropriate icons and interactions.

### Steps to test this PR

Prerequisite: Enable `newSettings` feature toggle

Prerequisite: Apply the patch in the [Asana
task](https://app.asana.com/0/1207908166761516/1208966262488271/f)

Note: Only the VPN item, PIR and possibly the Settings item will be
seen, depending on the different states. ITR is not included as part of
this PR and so is not visible.

_PIR Subscribed_
- [x] In `RealSubscriptions` change ln 77 to `return flowOf(listOf(NetP,
PIR))`
- [x] Open New Settings
- [x] Verify PIR item is visible with colored icon and status is on
- [x] Click PIR item
- [x] PIR screen should open

_Unsubscribed_
- [x] In `SubscriptionManager` change ln 442 to `return
SubscriptionStatus.UNKNOWN`
- [x] Open New Settings
- [x] Verify VPN item is not visible

_PIR Expired_
- [x] In `SubscriptionManager` change ln 442 to `return
SubscriptionStatus.EXPIRED`
- [x] Open New Settings
- [x] Verify PIR item is visible with grey icon and is not clickable,
and status is off
- [x] Click PIR item
- [x] Nothing should happen

_PPro Activating_
- [x] In `SubscriptionManager` change ln 442 to `return
SubscriptionStatus.WAITING`
- [x] Open New Settings
- [x] Verify PIR item is visible with grey icon and status is off
- [x] Click PIR item
- [x] Nothing should happen

_No Entitlement_
- [x] In `SubscriptionManager` change ln 442 to `return
SubscriptionStatus.AUTO_RENEWABLE`
- [x] In `RealSubscriptions` change ln 77 to `return flowOf(listOf())` 
- [x] Open New Settings
- [x] Verify PIR item is not visible

_Legacy Support_
- [x] Turn off `newSettings`
- [ ] Verify old PIR settings works by repeating steps above

### UI changes
| Before | After |
| --- | --- |
|
![old_pir_subscribed_active](https://github.com/user-attachments/assets/c7c47bb7-a9c7-43b8-a70e-24492dd359ec)
|
![new_pir_subscribed](https://github.com/user-attachments/assets/1330d724-f488-4516-9999-636c09a9a1d7)
|
|
![old_pir_expired](https://github.com/user-attachments/assets/db81cc81-82a4-4e86-a635-a3a5b89fb9c6)
|
![pir_new_expired](https://github.com/user-attachments/assets/cb13fbab-50a0-43e0-a1cb-3d3cce7da811)
|
|
![old_pir_activating](https://github.com/user-attachments/assets/fb193871-fd1e-45e6-be2c-806ab81f4063)
|
![pir_new_activating](https://github.com/user-attachments/assets/4e847f69-4a90-4440-ac9c-44db516ea45c)
|
|
![old_pir_no_entitlement](https://github.com/user-attachments/assets/5718eb22-db46-43bc-a810-4572ea7dc1db)
|
![pir_new_no_entitlement](https://github.com/user-attachments/assets/69a68ed7-2035-4996-89cc-59bbf1872b25)
|
|
![old_pir_unsubscribed](https://github.com/user-attachments/assets/91d2fb61-bc49-4735-b83e-c8c6c1744488)
|
![pir_unsubscribed](https://github.com/user-attachments/assets/899b1a33-b3c5-4068-ad25-8f5e4e964434)
|
  • Loading branch information
mikescamell authored Dec 19, 2024
1 parent b7af5ce commit 4b15b55
Show file tree
Hide file tree
Showing 10 changed files with 513 additions and 32 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.subscriptions.impl.pir

import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.subscriptions.api.Product.PIR
import com.duckduckgo.subscriptions.api.SubscriptionStatus
import com.duckduckgo.subscriptions.api.Subscriptions
import com.duckduckgo.subscriptions.impl.pir.PirSubscriptionManager.PirStatus
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

interface PirSubscriptionManager {
fun pirStatus(): Flow<PirStatus>

enum class PirStatus {
ACTIVE,
EXPIRED,
SIGNED_OUT,
INACTIVE,
WAITING,
INELIGIBLE,
}
}

@ContributesBinding(AppScope::class)
class RealPirSubscriptionManager @Inject constructor(
private val subscriptions: Subscriptions,
) : PirSubscriptionManager {

override fun pirStatus(): Flow<PirStatus> = hasPirEntitlement().map { getPirStatusInternal(it) }

private fun hasPirEntitlement(): Flow<Boolean> = subscriptions.getEntitlementStatus().map { it.contains(PIR) }

private suspend fun getPirStatusInternal(hasValidEntitlement: Boolean): PirStatus = when {
!hasValidEntitlement -> PirStatus.INELIGIBLE
else -> when (subscriptions.getSubscriptionStatus()) {
SubscriptionStatus.INACTIVE, SubscriptionStatus.EXPIRED -> PirStatus.EXPIRED
SubscriptionStatus.UNKNOWN -> PirStatus.SIGNED_OUT
SubscriptionStatus.AUTO_RENEWABLE, SubscriptionStatus.NOT_AUTO_RENEWABLE, SubscriptionStatus.GRACE_PERIOD -> PirStatus.ACTIVE
SubscriptionStatus.WAITING -> PirStatus.WAITING
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.duckduckgo.settings.api.NewSettingsFeature
import com.duckduckgo.settings.api.ProSettingsPlugin
import com.duckduckgo.subscriptions.impl.R
import com.duckduckgo.subscriptions.impl.settings.views.ItrSettingView
import com.duckduckgo.subscriptions.impl.settings.views.LegacyPirSettingView
import com.duckduckgo.subscriptions.impl.settings.views.LegacyProSettingView
import com.duckduckgo.subscriptions.impl.settings.views.PirSettingView
import com.duckduckgo.subscriptions.impl.settings.views.ProSettingView
Expand Down Expand Up @@ -55,9 +56,13 @@ class ProSettings @Inject constructor(private val newSettingsFeature: NewSetting

@ContributesMultibinding(scope = ActivityScope::class)
@PriorityKey(300)
class PIRSettings @Inject constructor() : ProSettingsPlugin {
class PIRSettings @Inject constructor(private val newSettingsFeature: NewSettingsFeature) : ProSettingsPlugin {
override fun getView(context: Context): View {
return PirSettingView(context)
return if (newSettingsFeature.self().isEnabled()) {
PirSettingView(context)
} else {
LegacyPirSettingView(context)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.subscriptions.impl.settings.views

import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.common.ui.view.gone
import com.duckduckgo.common.ui.view.show
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.common.utils.ConflatedJob
import com.duckduckgo.common.utils.ViewViewModelFactory
import com.duckduckgo.di.scopes.ViewScope
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.subscriptions.impl.databinding.LegacyViewPirSettingsBinding
import com.duckduckgo.subscriptions.impl.pir.PirActivity.Companion.PirScreenWithEmptyParams
import com.duckduckgo.subscriptions.impl.settings.views.LegacyPirSettingViewModel.Command
import com.duckduckgo.subscriptions.impl.settings.views.LegacyPirSettingViewModel.Command.OpenPir
import com.duckduckgo.subscriptions.impl.settings.views.LegacyPirSettingViewModel.ViewState
import dagger.android.support.AndroidSupportInjection
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach

@InjectWith(ViewScope::class)
class LegacyPirSettingView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : FrameLayout(context, attrs, defStyle) {

@Inject
lateinit var viewModelFactory: ViewViewModelFactory

@Inject
lateinit var globalActivityStarter: GlobalActivityStarter

private var coroutineScope: CoroutineScope? = null

private val binding: LegacyViewPirSettingsBinding by viewBinding()

private val viewModel: LegacyPirSettingViewModel by lazy {
ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[LegacyPirSettingViewModel::class.java]
}

private var job: ConflatedJob = ConflatedJob()

override fun onAttachedToWindow() {
AndroidSupportInjection.inject(this)
super.onAttachedToWindow()

findViewTreeLifecycleOwner()?.lifecycle?.addObserver(viewModel)

binding.pirSettings.setClickListener {
viewModel.onPir()
}

@SuppressLint("NoHardcodedCoroutineDispatcher")
coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

job += viewModel.commands()
.onEach { processCommands(it) }
.launchIn(coroutineScope!!)

viewModel.viewState
.onEach { renderView(it) }
.launchIn(coroutineScope!!)
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
findViewTreeLifecycleOwner()?.lifecycle?.removeObserver(viewModel)
coroutineScope?.cancel()
job.cancel()
coroutineScope = null
}

private fun renderView(viewState: ViewState) {
if (viewState.hasSubscription) {
binding.pirSettings.show()
} else {
binding.pirSettings.gone()
}
}

private fun processCommands(command: Command) {
when (command) {
is OpenPir -> {
globalActivityStarter.start(context, PirScreenWithEmptyParams)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.subscriptions.impl.settings.views

import android.annotation.SuppressLint
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.di.scopes.ViewScope
import com.duckduckgo.subscriptions.api.Product.PIR
import com.duckduckgo.subscriptions.api.Subscriptions
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
import com.duckduckgo.subscriptions.impl.settings.views.LegacyPirSettingViewModel.Command.OpenPir
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch

@SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle
@ContributesViewModel(ViewScope::class)
class LegacyPirSettingViewModel @Inject constructor(
private val subscriptions: Subscriptions,
private val pixelSender: SubscriptionPixelSender,
) : ViewModel(), DefaultLifecycleObserver {

sealed class Command {
data object OpenPir : Command()
}

private val command = Channel<Command>(1, BufferOverflow.DROP_OLDEST)
internal fun commands(): Flow<Command> = command.receiveAsFlow()
data class ViewState(val hasSubscription: Boolean = false)

private val _viewState = MutableStateFlow(ViewState())
val viewState = _viewState.asStateFlow()

fun onPir() {
pixelSender.reportAppSettingsPirClick()
sendCommand(OpenPir)
}

override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
subscriptions.getEntitlementStatus().onEach {
_viewState.emit(viewState.value.copy(hasSubscription = it.contains(PIR)))
}.launchIn(viewModelScope)
}

private fun sendCommand(newCommand: Command) {
viewModelScope.launch {
command.send(newCommand)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,27 @@ import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.common.ui.view.gone
import com.duckduckgo.common.ui.view.show
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.common.utils.ConflatedJob
import com.duckduckgo.common.utils.ViewViewModelFactory
import com.duckduckgo.di.scopes.ViewScope
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.subscriptions.impl.R
import com.duckduckgo.subscriptions.impl.databinding.ViewPirSettingsBinding
import com.duckduckgo.subscriptions.impl.pir.PirActivity.Companion.PirScreenWithEmptyParams
import com.duckduckgo.subscriptions.impl.settings.views.PirSettingViewModel.Command
import com.duckduckgo.subscriptions.impl.settings.views.PirSettingViewModel.Command.OpenPir
import com.duckduckgo.subscriptions.impl.settings.views.PirSettingViewModel.ViewState
import com.duckduckgo.subscriptions.impl.settings.views.PirSettingViewModel.ViewState.PirState.Activating
import com.duckduckgo.subscriptions.impl.settings.views.PirSettingViewModel.ViewState.PirState.Expired
import com.duckduckgo.subscriptions.impl.settings.views.PirSettingViewModel.ViewState.PirState.Hidden
import com.duckduckgo.subscriptions.impl.settings.views.PirSettingViewModel.ViewState.PirState.Subscribed
import dagger.android.support.AndroidSupportInjection
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -74,10 +79,6 @@ class PirSettingView @JvmOverloads constructor(

findViewTreeLifecycleOwner()?.lifecycle?.addObserver(viewModel)

binding.pirSettings.setClickListener {
viewModel.onPir()
}

@SuppressLint("NoHardcodedCoroutineDispatcher")
coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

Expand All @@ -99,10 +100,23 @@ class PirSettingView @JvmOverloads constructor(
}

private fun renderView(viewState: ViewState) {
if (viewState.hasSubscription) {
binding.pirSettings.show()
} else {
binding.pirSettings.gone()
with(binding.pirSettings) {
when (viewState.pirState) {
is Subscribed -> {
isVisible = true
setStatus(isOn = true)
setLeadingIconResource(R.drawable.ic_identity_blocked_pir_color_24)
isClickable = true
binding.pirSettings.setClickListener { viewModel.onPir() }
}
Expired, Activating -> {
isVisible = true
isClickable = false
setStatus(isOn = false)
setLeadingIconResource(R.drawable.ic_identity_blocked_pir_grayscale_color_24)
}
Hidden -> isGone = true
}
}
}

Expand Down
Loading

0 comments on commit 4b15b55

Please sign in to comment.