Merge branch 'main' into PM-33982/build-device-screen

# Conflicts:
#	app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt
This commit is contained in:
Andre Rosado
2026-04-29 17:41:31 +01:00
121 changed files with 5196 additions and 1540 deletions

View File

@@ -24,6 +24,7 @@ import com.bitwarden.network.service.OrganizationService
import com.bitwarden.network.service.PushService
import com.bitwarden.network.service.SendsService
import com.bitwarden.network.service.SyncService
import kotlinx.serialization.json.Json
/**
* Provides access to Bitwarden services.
@@ -176,4 +177,8 @@ interface BitwardenServiceClient {
*/
fun bitwardenServiceClient(
config: BitwardenServiceClientConfig,
): BitwardenServiceClient = BitwardenServiceClientImpl(config)
json: Json,
): BitwardenServiceClient = BitwardenServiceClientImpl(
bitwardenServiceClientConfig = config,
clientJson = json,
)

View File

@@ -1,7 +1,6 @@
package com.bitwarden.network
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.serializer.InstantSerializer
import com.bitwarden.network.interceptor.AuthTokenManager
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import com.bitwarden.network.interceptor.CookieInterceptor
@@ -44,8 +43,6 @@ import com.bitwarden.network.service.PushServiceImpl
import com.bitwarden.network.service.SendsServiceImpl
import com.bitwarden.network.service.SyncServiceImpl
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import retrofit2.create
/**
@@ -54,6 +51,7 @@ import retrofit2.create
@OmitFromCoverage
internal class BitwardenServiceClientImpl(
private val bitwardenServiceClientConfig: BitwardenServiceClientConfig,
private val clientJson: Json,
) : BitwardenServiceClient {
private val authTokenManager: AuthTokenManager = AuthTokenManager(
@@ -63,22 +61,7 @@ internal class BitwardenServiceClientImpl(
override val tokenProvider: TokenProvider = authTokenManager
override val cookieProvider: CookieProvider = bitwardenServiceClientConfig.cookieProvider
private val clientJson = Json {
// If there are keys returned by the server not modeled by a serializable class,
// ignore them.
// This makes additive server changes non-breaking.
ignoreUnknownKeys = true
// We allow for nullable values to have keys missing in the JSON response.
explicitNulls = false
serializersModule = SerializersModule {
contextual(InstantSerializer())
}
// Respect model default property values.
coerceInputValues = true
}
private val retrofits: Retrofits by lazy {
RetrofitsImpl(
authTokenManager = authTokenManager,

View File

@@ -1,5 +1,6 @@
package com.bitwarden.network.api
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CheckoutSessionRequestJson
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.NetworkResult
@@ -33,4 +34,10 @@ internal interface AuthenticatedBillingApi {
*/
@GET("/plans/premium")
suspend fun getPremiumPlan(): NetworkResult<PremiumPlanResponseJson>
/**
* Retrieves the user's premium subscription details.
*/
@GET("/account/billing/vnext/subscription")
suspend fun getSubscription(): NetworkResult<BitwardenSubscriptionResponseJson>
}

View File

@@ -0,0 +1,222 @@
package com.bitwarden.network.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.math.BigDecimal
import java.time.Instant
/**
* Response object returned when retrieving the user's premium subscription details.
*
* @property status The current status of the subscription.
* @property cart The cart details of the subscription.
* @property storage The storage usage details, if available.
* @property cancelAt The date the subscription is scheduled to cancel, if applicable.
* @property canceled The date the subscription was canceled, if applicable.
* @property nextCharge The date of the next charge, if applicable.
* @property suspension The date the subscription was suspended, if applicable.
* @property gracePeriod The grace period in days, if applicable.
*/
@Serializable
data class BitwardenSubscriptionResponseJson(
@SerialName("status")
val status: SubscriptionStatusJson,
@SerialName("cart")
val cart: CartJson,
@SerialName("storage")
val storage: StorageJson?,
@Contextual
@SerialName("cancelAt")
val cancelAt: Instant?,
@Contextual
@SerialName("canceled")
val canceled: Instant?,
@Contextual
@SerialName("nextCharge")
val nextCharge: Instant?,
@Contextual
@SerialName("suspension")
val suspension: Instant?,
@SerialName("gracePeriod")
val gracePeriod: Int?,
)
/**
* Represents the status of a subscription.
*/
@Serializable
enum class SubscriptionStatusJson {
@SerialName("active")
ACTIVE,
@SerialName("canceled")
CANCELED,
@SerialName("past_due")
PAST_DUE,
@SerialName("incomplete")
INCOMPLETE,
@SerialName("incomplete_expired")
INCOMPLETE_EXPIRED,
@SerialName("unpaid")
UNPAID,
@SerialName("trialing")
TRIALING,
@SerialName("paused")
PAUSED,
}
/**
* Represents the cart details of a subscription.
*
* @property passwordManager The password manager cart items.
* @property secretsManager The secrets manager cart items, if applicable.
* @property cadence The billing cadence of the subscription.
* @property discount The discount applied to the cart, if applicable.
* @property estimatedTax The estimated tax amount.
*/
@Serializable
data class CartJson(
@SerialName("passwordManager")
val passwordManager: PasswordManagerCartItemsJson,
@SerialName("secretsManager")
val secretsManager: SecretsManagerCartItemsJson?,
@SerialName("cadence")
val cadence: CadenceTypeJson,
@SerialName("discount")
val discount: BitwardenDiscountJson?,
@Contextual
@SerialName("estimatedTax")
val estimatedTax: BigDecimal,
)
/**
* Represents the password manager cart items within a subscription.
*
* @property seats The seat pricing details.
* @property additionalStorage The additional storage pricing details, if applicable.
*/
@Serializable
data class PasswordManagerCartItemsJson(
@SerialName("seats")
val seats: CartItemJson,
@SerialName("additionalStorage")
val additionalStorage: CartItemJson?,
)
/**
* Represents the secrets manager cart items within a subscription.
*
* @property seats The seat pricing details.
* @property additionalServiceAccounts The additional service accounts pricing details,
* if applicable.
*/
@Serializable
data class SecretsManagerCartItemsJson(
@SerialName("seats")
val seats: CartItemJson,
@SerialName("additionalServiceAccounts")
val additionalServiceAccounts: CartItemJson?,
)
/**
* Represents a single cart item within a subscription.
*
* @property translationKey The translation key for display purposes.
* @property quantity The quantity of this item.
* @property cost The cost of this item.
* @property discount The discount applied to this item, if applicable.
*/
@Serializable
data class CartItemJson(
@SerialName("translationKey")
val translationKey: String,
@SerialName("quantity")
val quantity: Long,
@Contextual
@SerialName("cost")
val cost: BigDecimal,
@SerialName("discount")
val discount: BitwardenDiscountJson?,
)
/**
* Represents a discount applied to a subscription or cart item.
*
* @property type The type of discount.
* @property value The discount value.
*/
@Serializable
data class BitwardenDiscountJson(
@SerialName("type")
val type: DiscountTypeJson,
@Contextual
@SerialName("value")
val value: BigDecimal,
)
/**
* Represents the type of discount applied to a subscription.
*/
@Serializable
enum class DiscountTypeJson {
@SerialName("amount-off")
AMOUNT_OFF,
@SerialName("percent-off")
PERCENT_OFF,
}
/**
* Represents the billing cadence of a subscription.
*/
@Serializable
enum class CadenceTypeJson {
@SerialName("annually")
ANNUALLY,
@SerialName("monthly")
MONTHLY,
}
/**
* Represents storage usage details for a subscription.
*
* @property available The available storage in bytes.
* @property used The used storage amount.
* @property readableUsed A human-readable representation of the used storage.
*/
@Serializable
data class StorageJson(
@SerialName("available")
val available: Int,
@SerialName("used")
val used: Double,
@SerialName("readableUsed")
val readableUsed: String,
)

View File

@@ -36,6 +36,7 @@ data class SyncResponseJson(
@JsonNames("Profile")
val profile: Profile,
@Contextual
@SerialName("ciphers")
val ciphers: List<Cipher>?,

View File

@@ -1,5 +1,6 @@
package com.bitwarden.network.service
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.PortalUrlResponseJson
import com.bitwarden.network.model.PremiumPlanResponseJson
@@ -23,4 +24,9 @@ interface BillingService {
* Retrieves the premium plan pricing information.
*/
suspend fun getPremiumPlan(): Result<PremiumPlanResponseJson>
/**
* Retrieves the user's premium subscription details.
*/
suspend fun getSubscription(): Result<BitwardenSubscriptionResponseJson>
}

View File

@@ -1,6 +1,7 @@
package com.bitwarden.network.service
import com.bitwarden.network.api.AuthenticatedBillingApi
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CheckoutSessionRequestJson
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.PortalUrlResponseJson
@@ -32,4 +33,9 @@ internal class BillingServiceImpl(
authenticatedBillingApi
.getPremiumPlan()
.toResult()
override suspend fun getSubscription(): Result<BitwardenSubscriptionResponseJson> =
authenticatedBillingApi
.getSubscription()
.toResult()
}

View File

@@ -3,15 +3,27 @@ package com.bitwarden.network.service
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.api.AuthenticatedBillingApi
import com.bitwarden.network.base.BaseServiceTest
import com.bitwarden.network.model.BitwardenDiscountJson
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CadenceTypeJson
import com.bitwarden.network.model.CartItemJson
import com.bitwarden.network.model.CartJson
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.DiscountTypeJson
import com.bitwarden.network.model.PasswordManagerCartItemsJson
import com.bitwarden.network.model.PortalUrlResponseJson
import com.bitwarden.network.model.PremiumPlanResponseJson
import com.bitwarden.network.model.StorageJson
import com.bitwarden.network.model.SubscriptionStatusJson
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerializationException
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
import java.math.BigDecimal
import java.time.Instant
class BillingServiceTest : BaseServiceTest() {
@@ -77,6 +89,105 @@ class BillingServiceTest : BaseServiceTest() {
val actual = service.getPremiumPlan()
assertEquals(PREMIUM_PLAN_RESPONSE.asSuccess(), actual)
}
@Test
fun `getSubscription when response is Failure should return Failure`() =
runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
val actual = service.getSubscription()
assertTrue(actual.isFailure)
}
@Test
fun `getSubscription when response is Success should return Success`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertEquals(SUBSCRIPTION_RESPONSE.asSuccess(), actual)
}
@Test
fun `getSubscription with monthly cadence should parse correctly`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_MONTHLY_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertEquals(
CadenceTypeJson.MONTHLY,
actual.getOrNull()?.cart?.cadence,
)
}
@Test
fun `getSubscription should parse every SubscriptionStatusJson value`() =
runTest {
SubscriptionStatusJson.entries.forEach { status ->
val body = subscriptionResponseJsonForStatus(status)
val response = MockResponse()
.setBody(body)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertEquals(status, actual.getOrNull()?.status)
}
}
@Test
fun `getSubscription with null storage and discount should parse`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_MINIMAL_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertTrue(actual.isSuccess)
}
@Test
fun `getSubscription with AMOUNT_OFF discount should parse`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_AMOUNT_OFF_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertEquals(
DiscountTypeJson.AMOUNT_OFF,
actual.getOrNull()?.cart?.discount?.type,
)
}
@Test
fun `getSubscription with PERCENT_OFF discount should parse`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_PERCENT_OFF_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertEquals(
DiscountTypeJson.PERCENT_OFF,
actual.getOrNull()?.cart?.discount?.type,
)
}
@Test
fun `getSubscription with unknown cadence should fail deserialization`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_UNKNOWN_CADENCE_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertTrue(actual.isFailure)
assertTrue(actual.exceptionOrNull() is SerializationException)
}
}
private const val CHECKOUT_SESSION_RESPONSE_JSON = """
@@ -132,3 +243,261 @@ private val PREMIUM_PLAN_RESPONSE = PremiumPlanResponseJson(
provided = 5,
),
)
private const val SUBSCRIPTION_RESPONSE_JSON = """
{
"status": "active",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
},
"additionalStorage": {
"translationKey": "additionalStorage",
"quantity": 24,
"cost": 24.00,
"discount": null
}
},
"secretsManager": null,
"cadence": "annually",
"discount": {
"type": "amount-off",
"value": 2.10
},
"estimatedTax": 3.85
},
"storage": {
"available": 5,
"used": 0,
"readableUsed": "0 Bytes"
},
"cancelAt": null,
"canceled": null,
"nextCharge": "2026-04-02T00:00:00Z",
"suspension": null,
"gracePeriod": null
}
"""
private val SUBSCRIPTION_RESPONSE = BitwardenSubscriptionResponseJson(
status = SubscriptionStatusJson.ACTIVE,
cart = CartJson(
passwordManager = PasswordManagerCartItemsJson(
seats = CartItemJson(
translationKey = "premiumMembership",
quantity = 1,
cost = BigDecimal("19.80"),
discount = null,
),
additionalStorage = CartItemJson(
translationKey = "additionalStorage",
quantity = 24,
cost = BigDecimal("24.00"),
discount = null,
),
),
secretsManager = null,
cadence = CadenceTypeJson.ANNUALLY,
discount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("2.10"),
),
estimatedTax = BigDecimal("3.85"),
),
storage = StorageJson(
available = 5,
used = 0.0,
readableUsed = "0 Bytes",
),
cancelAt = null,
canceled = null,
nextCharge = Instant.parse("2026-04-02T00:00:00Z"),
suspension = null,
gracePeriod = null,
)
private const val SUBSCRIPTION_RESPONSE_MONTHLY_JSON = """
{
"status": "active",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 1.67,
"discount": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "monthly",
"discount": null,
"estimatedTax": 0
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
"""
private const val SUBSCRIPTION_RESPONSE_MINIMAL_JSON = """
{
"status": "active",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "annually",
"discount": null,
"estimatedTax": 0
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
"""
private const val SUBSCRIPTION_RESPONSE_AMOUNT_OFF_JSON = """
{
"status": "active",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "annually",
"discount": {
"type": "amount-off",
"value": 5.00
},
"estimatedTax": 0
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
"""
private const val SUBSCRIPTION_RESPONSE_PERCENT_OFF_JSON = """
{
"status": "active",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "annually",
"discount": {
"type": "percent-off",
"value": 15.00
},
"estimatedTax": 0
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
"""
private const val SUBSCRIPTION_RESPONSE_UNKNOWN_CADENCE_JSON = """
{
"status": "active",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "weekly",
"discount": null,
"estimatedTax": 0
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
"""
private fun subscriptionResponseJsonForStatus(
status: SubscriptionStatusJson,
): String {
val wireValue = when (status) {
SubscriptionStatusJson.ACTIVE -> "active"
SubscriptionStatusJson.CANCELED -> "canceled"
SubscriptionStatusJson.PAST_DUE -> "past_due"
SubscriptionStatusJson.INCOMPLETE -> "incomplete"
SubscriptionStatusJson.INCOMPLETE_EXPIRED -> "incomplete_expired"
SubscriptionStatusJson.UNPAID -> "unpaid"
SubscriptionStatusJson.TRIALING -> "trialing"
SubscriptionStatusJson.PAUSED -> "paused"
}
return """
{
"status": "$wireValue",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "annually",
"discount": null,
"estimatedTax": 0
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
""".trimIndent()
}

View File

@@ -310,7 +310,7 @@ private const val SYNC_SUCCESS_JSON = """
"mockCollectionId-1"
],
"name": "mockName-1",
"id": "mockId-1"
"id": "mockId-1",
"fields": [
{
"linkedId": 100,