Skip to content

Commit

Permalink
Add subs info in settings screen (#3962)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/0/1206024785662125/f 

### Description
See task

### Steps to test this PR
See task
  • Loading branch information
marcosholgado authored Dec 13, 2023
1 parent 6b6dbcd commit ac941b1
Show file tree
Hide file tree
Showing 10 changed files with 323 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,21 @@ import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.autofill.api.email.EmailManager
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.subscriptions.impl.SubscriptionStatus.AutoRenewable
import com.duckduckgo.subscriptions.impl.SubscriptionStatus.Expired
import com.duckduckgo.subscriptions.impl.SubscriptionStatus.GracePeriod
import com.duckduckgo.subscriptions.impl.SubscriptionStatus.Inactive
import com.duckduckgo.subscriptions.impl.SubscriptionStatus.NotAutoRenewable
import com.duckduckgo.subscriptions.impl.SubscriptionStatus.Unknown
import com.duckduckgo.subscriptions.impl.SubscriptionsData.*
import com.duckduckgo.subscriptions.impl.auth.AuthService
import com.duckduckgo.subscriptions.impl.auth.CreateAccountResponse
import com.duckduckgo.subscriptions.impl.auth.Entitlement
import com.duckduckgo.subscriptions.impl.auth.ResponseError
import com.duckduckgo.subscriptions.impl.auth.StoreLoginBody
import com.duckduckgo.subscriptions.impl.billing.BillingClientWrapper
import com.duckduckgo.subscriptions.impl.billing.PurchaseState
import com.duckduckgo.subscriptions.impl.services.AuthService
import com.duckduckgo.subscriptions.impl.services.CreateAccountResponse
import com.duckduckgo.subscriptions.impl.services.Entitlement
import com.duckduckgo.subscriptions.impl.services.ResponseError
import com.duckduckgo.subscriptions.impl.services.StoreLoginBody
import com.duckduckgo.subscriptions.impl.services.SubscriptionsService
import com.duckduckgo.subscriptions.store.AuthDataStore
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.moshi.Moshi
Expand Down Expand Up @@ -68,6 +75,11 @@ interface SubscriptionsManager {
*/
suspend fun getSubscriptionData(): SubscriptionsData

/**
* Gets the subscription for an authenticated user
*/
suspend fun getSubscription(): Subscription

/**
* Authenticates the user based on the auth token
*/
Expand Down Expand Up @@ -113,6 +125,7 @@ interface SubscriptionsManager {
@ContributesBinding(AppScope::class)
class RealSubscriptionsManager @Inject constructor(
private val authService: AuthService,
private val subscriptionsService: SubscriptionsService,
private val authDataStore: AuthDataStore,
private val billingClientWrapper: BillingClientWrapper,
private val emailManager: EmailManager,
Expand Down Expand Up @@ -155,6 +168,35 @@ class RealSubscriptionsManager @Inject constructor(
}
}

override suspend fun getSubscription(): Subscription {
return try {
if (isUserAuthenticated()) {
val response = subscriptionsService.subscription("Bearer ${authDataStore.accessToken}")
val state = when (response.status) {
"Auto-Renewable" -> AutoRenewable
"Not Auto-Renewable" -> NotAutoRenewable
"Grace Period" -> GracePeriod
"Inactive" -> Inactive
"Expired" -> Expired
else -> Unknown
}
return Subscription.Success(
productId = response.productId,
startedAt = response.startedAt,
expiresOrRenewsAt = response.expiresOrRenewsAt,
status = state,
)
} else {
Subscription.Failure("Subscription not found")
}
} catch (e: HttpException) {
val error = parseError(e)?.error ?: "An error happened"
Subscription.Failure(error)
} catch (e: Exception) {
Subscription.Failure(e.message ?: "An error happened")
}
}

override suspend fun signOut() {
authDataStore.authToken = ""
authDataStore.accessToken = ""
Expand Down Expand Up @@ -390,11 +432,25 @@ sealed class SubscriptionsData {
data class Failure(val message: String) : SubscriptionsData()
}

sealed class Subscription {
data class Success(val productId: String, val startedAt: Long, val expiresOrRenewsAt: Long, val status: SubscriptionStatus) : Subscription()
data class Failure(val message: String) : Subscription()
}

sealed class SubscriptionStatus {
data object AutoRenewable : SubscriptionStatus()
data object NotAutoRenewable : SubscriptionStatus()
data object GracePeriod : SubscriptionStatus()
data object Inactive : SubscriptionStatus()
data object Expired : SubscriptionStatus()
data object Unknown : SubscriptionStatus()
}

sealed class CurrentPurchase {
object PreFlowInProgress : CurrentPurchase()
object PreFlowFinished : CurrentPurchase()
object InProgress : CurrentPurchase()
object Success : CurrentPurchase()
object Recovered : CurrentPurchase()
data object PreFlowInProgress : CurrentPurchase()
data object PreFlowFinished : CurrentPurchase()
data object InProgress : CurrentPurchase()
data object Success : CurrentPurchase()
data object Recovered : CurrentPurchase()
data class Failure(val message: String) : CurrentPurchase()
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

package com.duckduckgo.subscriptions.impl.auth
package com.duckduckgo.subscriptions.impl.services

import com.duckduckgo.anvil.annotations.ContributesNonCachingServiceApi
import com.duckduckgo.di.scopes.AppScope
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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.services

import com.duckduckgo.anvil.annotations.ContributesNonCachingServiceApi
import com.duckduckgo.di.scopes.AppScope
import retrofit2.http.GET
import retrofit2.http.Header

@ContributesNonCachingServiceApi(AppScope::class)
interface SubscriptionsService {
@GET("https://subscriptions-dev.duckduckgo.com/api/subscription")
suspend fun subscription(@Header("Authorization") authorization: String?): SubscriptionResponse
}

data class SubscriptionResponse(
val productId: String,
val startedAt: Long,
val expiresOrRenewsAt: Long,
val platform: String,
val status: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@ import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.subscriptions.impl.R.string
import com.duckduckgo.subscriptions.impl.SubscriptionStatus.AutoRenewable
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION
import com.duckduckgo.subscriptions.impl.databinding.ActivitySubscriptionSettingsBinding
import com.duckduckgo.subscriptions.impl.ui.AddDeviceActivity.Companion.AddDeviceScreenWithEmptyParams
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsActivity.Companion.SubscriptionsSettingsScreenWithEmptyParams
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.FinishSignOut
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SubscriptionDuration.Monthly
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.ViewState
import javax.inject.Inject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
Expand All @@ -59,11 +62,17 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() {
setContentView(binding.root)
setupToolbar(toolbar)

lifecycle.addObserver(viewModel)

viewModel.commands()
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.onEach { processCommand(it) }
.launchIn(lifecycleScope)

viewModel.viewState.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).onEach {
renderView(it)
}.launchIn(lifecycleScope)

binding.addDevice.setClickListener {
globalActivityStarter.start(this, AddDeviceScreenWithEmptyParams)
}
Expand Down Expand Up @@ -101,6 +110,26 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() {
}
}

override fun onDestroy() {
super.onDestroy()
lifecycle.removeObserver(viewModel)
}

private fun renderView(viewState: ViewState) {
val duration = if (viewState.duration is Monthly) {
getString(string.monthly)
} else {
getString(string.yearly)
}

val status = when (viewState.status) {
is AutoRenewable -> getString(string.renews)
else -> getString(string.expires)
}

binding.description.text = getString(string.subscriptionsData, duration, status, viewState.date)
}

private fun processCommand(command: Command) {
when (command) {
is FinishSignOut -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,75 @@

package com.duckduckgo.subscriptions.impl.ui

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.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.subscriptions.impl.Subscription
import com.duckduckgo.subscriptions.impl.SubscriptionStatus
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN
import com.duckduckgo.subscriptions.impl.SubscriptionsManager
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.FinishSignOut
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SubscriptionDuration.Monthly
import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SubscriptionDuration.Yearly
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch

@SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle
@ContributesViewModel(ActivityScope::class)
class SubscriptionSettingsViewModel @Inject constructor(
private val subscriptionsManager: SubscriptionsManager,
) : ViewModel() {
private val dispatcherProvider: DispatcherProvider,
) : ViewModel(), DefaultLifecycleObserver {

private val command = Channel<Command>(1, DROP_OLDEST)
internal fun commands(): Flow<Command> = command.receiveAsFlow()

private val _viewState = MutableStateFlow(ViewState())
val viewState = _viewState.asStateFlow()
data class ViewState(
val date: String? = null,
val duration: SubscriptionDuration? = null,
val status: SubscriptionStatus? = null,
)

override fun onResume(owner: LifecycleOwner) {
viewModelScope.launch(dispatcherProvider.io()) {
val subs = subscriptionsManager.getSubscription()
if (subs is Subscription.Success) {
val formatter = SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault())
val date = formatter.format(Date(subs.expiresOrRenewsAt))
val type = if (subs.productId == MONTHLY_PLAN) Monthly else Yearly
_viewState.emit(viewState.value.copy(date = date, duration = type, status = subs.status))
}
}
}

fun removeFromDevice() {
viewModelScope.launch {
subscriptionsManager.signOut()
command.send(FinishSignOut)
}
}

sealed class SubscriptionDuration {
data object Monthly : SubscriptionDuration()
data object Yearly : SubscriptionDuration()
}

sealed class Command {
object FinishSignOut : Command()
data object FinishSignOut : Command()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->

<!-- smartling.entity_escaping = false -->
<!-- smartling.instruction_attributes = instruction -->
<resources>
<string name="cancel">Cancel</string>
<string name="ok">OK</string>
Expand Down Expand Up @@ -55,6 +56,11 @@
<string name="subscriptionRemoved">Your subscription has been removed from this device</string>
<string name="removeFromDevice">Remove From This Device?</string>
<string name="removeFromDeviceDescription">You will no longer be able to access your subscription on this device. This will not cancel your subscription, and it will remain active on your other device.</string>
<string name="subscriptionsData" instruction="First placeholder is either monthly or yearly, second is renews or expires, third is a date in format MMMM dd, yyyy">Your %1$s Privacy Pro subscription %2$s on %3$s.</string>
<string name="monthly">monthly</string>
<string name="yearly">yearly</string>
<string name="renews">renews</string>
<string name="expires">expires</string>

<!--Settings Subscriptions-->
<string name="subscriptionSetting">Subscription Settings</string>
Expand Down
Loading

0 comments on commit ac941b1

Please sign in to comment.