Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,22 @@ type AgreementDisplayItem {
displaySubtitle: String
displayValue: String!
}
input AnswersInput {
relationship: String
isPregnant: Boolean
hasKids: Boolean
hasCat: Boolean
hasDog: Boolean
yourAge: String
partnerAge: String
isStudent: Boolean
housingType: String
houseAge: String
isRental: Boolean
hasCar: Boolean
carAge: String
wantsAllRisk: Boolean
}
enum AppPlatform {
IOS
ANDROID
Expand Down Expand Up @@ -678,6 +694,10 @@ type ChangeTierDeductibleFromAgreement {
The tier level
"""
tierLevel: Int
"""
Optional tier description, if set use that rather than productVariant->tierDescription
"""
tierDescription: String
"""
List of display items for the quote
"""
Expand Down Expand Up @@ -714,6 +734,10 @@ type ChangeTierDeductibleQuote {
The tier level, a smaller number means lower coverage. Use this to sort the tier list.
"""
tierLevel: Int
"""
Optional tier description, if set use that rather than productVariant->tierDescription
"""
tierDescription: String
"""
The deductible if any
"""
Expand Down Expand Up @@ -872,6 +896,7 @@ type Claim {
isUploadingFilesEnabled: Boolean!
infoText: String
displayItems: [ClaimDisplayItem!]!
contractId: String
"""
Terms & conditions for the claim found using claims contractId and dateOfOccurrence, otherwise null.
"""
Expand Down Expand Up @@ -1003,7 +1028,7 @@ type ClaimIntentStep {
"""
A union of all the different kinds of "step content".
"""
union ClaimIntentStepContent = ClaimIntentStepContentForm|ClaimIntentStepContentSelect|ClaimIntentStepContentTask|ClaimIntentStepContentAudioRecording|ClaimIntentStepContentFileUpload|ClaimIntentStepContentSummary|ClaimIntentStepContentDeflection|ClaimIntentStepContentDeflectionMessage
union ClaimIntentStepContent = ClaimIntentStepContentForm|ClaimIntentStepContentSelect|ClaimIntentStepContentTask|ClaimIntentStepContentAudioRecording|ClaimIntentStepContentFileUpload|ClaimIntentStepContentSummary|ClaimIntentStepContentDeflection|ClaimIntentStepContentDeflectionMessage|ClaimIntentStepContentInformation
"""
An audio recording step is one where the user is meant to record some audio.
Submitted using `Mutation.claimIntentSubmitAudio`.
Expand Down Expand Up @@ -1181,6 +1206,22 @@ This typically will be backed by a String - but other formats could appear.
"""
scalar ClaimIntentStepContentFormFieldValue
"""
A read and acknowledge notice if the member needs to be shown some urgent information (if water is still leaking -> turn off the pipe). Not terminal
Submitted using `Mutation.claimIntentSubmitInformation`.
"""
type ClaimIntentStepContentInformation {
notice: String!
"""
So we can show a white/red border if info/critical respectively.
"""
severity: ClaimIntentStepContentInformationSeverity!
buttonTitle: String!
}
enum ClaimIntentStepContentInformationSeverity {
INFO
CRITICAL
}
"""
A select step is one that contains a choice to select one of several alternatives.
It can be seen as a special-case form with nicer rendering.
Submitted using `Mutation.claimIntentSubmitSelect`.
Expand Down Expand Up @@ -1268,6 +1309,9 @@ input ClaimIntentSubmitFormInput {
stepId: ID!
fields: [ClaimIntentFormSubmitInputField!]!
}
input ClaimIntentSubmitInformationInput {
stepId: ID!
}
input ClaimIntentSubmitSelectInput {
stepId: ID!
selectedId: ID!
Expand Down Expand Up @@ -1662,6 +1706,10 @@ input CrossSellInput {
A/B test experiments for attribution tracking
"""
experiments: [CrossSellExperimentInput!]!
"""
The contract that was changed in the flow. When set, enables addon recommendations for that contract.
"""
contractId: ID
}
enum CrossSellSource {
HOME
Expand All @@ -1677,6 +1725,10 @@ type CrossSellV2 {
"""
recommendedCrossSell: RecommendedCrossSell
"""
Addon recommendation for the contract in input.contractId (null when not applicable)
"""
recommendedAddon: RecommendedAddonCrossSell
"""
Other available cross-sells (behavior varies by userFlow)
"""
otherCrossSells: [CrossSell!]!
Expand Down Expand Up @@ -2433,6 +2485,28 @@ type InsuranceEvidenceOutput {
insuranceEvidenceInformation: InsuranceEvidenceInformation
userError: UserError
}
input InsuranceGuideInput {
"""
Email of the user.
"""
email: String
"""
Id of the insurance guide shopSession.
"""
shopSessionId: UUID!
"""
Insurance recommendation form answers.
"""
answers: AnswersInput!
"""
Insurance recommendations.
"""
recommendations: [RecommendationInput!]!
}
type InsuranceGuideOutput {
success: Boolean!
userError: UserError
}
input InsurelyInitiateIframeDataCollectionInput {
collectionId: String!
partner: String
Expand Down Expand Up @@ -3449,6 +3523,10 @@ type MoveIntentMutationOutput {
Fail case
"""
userError: UserError
"""
Id of the new contract if it was created after finalizing the move
"""
newContractId: ID
}
type MoveIntentQuoteCost {
quoteId: ID!
Expand Down Expand Up @@ -3655,6 +3733,10 @@ type Mutation {
"""
claimIntentSubmitTask(input: ClaimIntentSubmitTaskInput!): ClaimIntentMutationOutput!
"""
Acknowledge a step containing a `ClaimIntentStepContentInformation`, continuing the flow.
"""
claimIntentSubmitInformation(input: ClaimIntentSubmitInformationInput!): ClaimIntentMutationOutput!
"""
Submit a step containing a `ClaimIntentStepContentSummary`.
"""
claimIntentSubmitSummary(input: ClaimIntentSubmitSummaryInput!): ClaimIntentMutationOutput!
Expand Down Expand Up @@ -3729,6 +3811,10 @@ type Mutation {
"""
productOffersCancellationRequestedUpdate(productOfferIds: [UUID!]!, requested: Boolean!): ProductOffersMutationOutput!
"""
Called to save insurance recommendation form and save recommendation email.
"""
insuranceGuide(input: InsuranceGuideInput!): InsuranceGuideOutput!
"""
Create a `PriceIntent`.
The `input.productName` has to be the name of a product among the ones listed at `Query.availableProducts`. The
choice of product also affects which data will be needed for the intent to be confirmed and to produce offers.
Expand Down Expand Up @@ -4305,6 +4391,10 @@ type ProductOffer {
"""
priceIntentId: UUID
"""
Link to the price calculator for this offer (resumes the shop session).
"""
priceCalculatorLink: String
"""
UI-safe masked form data used to generate the offer. PII fields (street, zipCode, city) are masked when address came from registration address lookup.
"""
priceIntentData: PricingFormData!
Expand Down Expand Up @@ -4706,6 +4796,28 @@ type RecommendationExternalInsurance {
"""
dataCollectionId: String!
}
input RecommendationInput {
productId: String!
name: String!
tier: String
productPageUrl: String!
priceCalculatorUrl: String!
}
"""
A recommendation to add an addon to an existing contract. The button opens the addon flow via an app deep link.
"""
type RecommendedAddonCrossSell {
id: ID!
title: String!
description: String!
buttonTitle: String!
"""
App deep link to the addon flow for this contract
"""
deepLink: String!
pillowImageSmall: StoryblokImageAsset!
pillowImageLarge: StoryblokImageAsset!
}
type RecommendedCrossSell {
crossSell: CrossSell!
bannerText: String!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ internal class BottomSheetSceneStrategyTest {

private fun entry(key: String, metadata: Map<String, Any> = emptyMap()) = NavEntry(key = key, metadata = metadata) { }

private fun capturingEntry(key: String, capture: String, metadata: Map<String, Any> = emptyMap()) =
NavEntry(key = key, metadata = metadata) { capture.length }

@Test
fun `bottomSheet metadata is recognized as a bottom-sheet marker`() {
assertThat(BottomSheetSceneStrategy.bottomSheet().keys).isNotEmpty()
Expand All @@ -36,4 +39,22 @@ internal class BottomSheetSceneStrategyTest {
assertThat(overlay.overlaidEntries.map { it.contentKey } == listOf<Any>("a")).isTrue()
assertThat(overlay.entries.map { it.contentKey } == listOf<Any>("b")).isTrue()
}

@Test
fun `bottom-sheet scenes for the same content keys are equal despite distinct content lambdas`() {
fun buildScene(capture: String) = with(strategy()) {
with(SceneStrategyScope<String>()) {
calculateScene(
listOf(
capturingEntry("a", capture),
capturingEntry("b", capture, BottomSheetSceneStrategy.bottomSheet()),
),
)
}
}
val first = buildScene("first")!!
val second = buildScene("second")!!
assertThat(first == second).isTrue()
assertThat(first.hashCode() == second.hashCode()).isTrue()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.hedvig.android.app.navigation

import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.scene.SceneDecoratorStrategyScope
import androidx.navigation3.scene.SceneStrategyScope
import androidx.navigation3.scene.SinglePaneSceneStrategy
import assertk.assertThat
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import com.hedvig.android.navigation.compose.NavSuiteSceneDecoratorStrategy
import com.hedvig.android.navigation.compose.navUpBarSceneDecoratorStrategy
import org.junit.Test

/**
* Regression test for b/516312097: NavUpBarScene.equals must compare by key, not by the wrapped
* Scene's full equality (which in turn compares NavEntry.content lambda identity).
*
* Root cause: the old `data class NavUpBarScene` delegated equals to its constructor parameters.
* The wrapped `SinglePaneScene` compares `entry == other.entry`, and NavEntry equality is identity
* on the `content: @Composable () -> Unit` lambda. Between recompositions Nav3 creates fresh
* NavEntry instances for the same logical destination: same `contentKey`, different `content`
* lambda. That made two logically-identical NavUpBarScene wrappers unequal, causing the
* NavDisplay's AnimatedContent to treat them as different scenes mid-transition and triggering
* the SaveableStateProvider "Key used multiple times" crash.
*
* NavUpBarScene is `internal` to :navigation-compose; this test reaches it via the public
* [navUpBarSceneDecoratorStrategy] factory, which exercises the same code path that
* [NavSuiteSceneDecoratorStrategy] uses for the ShowUpBar mode.
*/
internal class NavUpBarSceneEqualityTest {
private fun singlePaneStrategy() = SinglePaneSceneStrategy<String>()

// Non-capturing `{ }` lambdas are folded by the compiler into a singleton, so entries built
// that way would share one content reference and would NOT reproduce the bug. Capturing entries
// guarantee distinct content lambda instances, which is the actual production scenario Nav3
// creates between recompositions.
private fun capturingEntry(key: String, capture: String) =
NavEntry(key = key, metadata = NavSuiteSceneDecoratorStrategy.showNavBar()) { capture.length }

@Test
fun `NavUpBarScene wrappers for same-key entries with distinct content lambdas are equal`() {
fun buildWrapped(capture: String) = with(navUpBarSceneDecoratorStrategy<String>()) {
with(SceneDecoratorStrategyScope<String>()) {
val singlePane = with(singlePaneStrategy()) {
with(SceneStrategyScope<String>()) {
calculateScene(listOf(capturingEntry(key = "dest", capture = capture)))
}
}!!
decorateScene(singlePane)
}
}

// Pre-condition: two SinglePaneScenes with the same contentKey but different content lambdas
// are NOT equal to each other. This documents the root cause: any scene wrapper that delegated
// equals to the wrapped scene would inherit this inequality and produce a spurious "new scene"
// mid-transition.
val rawFirst = with(singlePaneStrategy()) {
with(SceneStrategyScope<String>()) {
calculateScene(listOf(capturingEntry(key = "dest", capture = "alpha")))
}
}!!
val rawSecond = with(singlePaneStrategy()) {
with(SceneStrategyScope<String>()) {
calculateScene(listOf(capturingEntry(key = "dest", capture = "beta")))
}
}!!
assertThat(rawFirst == rawSecond).isFalse()

// After the fix: two NavUpBarScene wrappers for the same contentKey must be equal, regardless
// of the content lambda identity of the underlying entries.
val first = buildWrapped("alpha")
val second = buildWrapped("beta")
assertThat(first == second).isTrue()
assertThat(first.hashCode() == second.hashCode()).isTrue()
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.hedvig.android.auth

import com.hedvig.android.auth.token.isTokenExpired
import com.hedvig.android.core.common.di.AppScope
import com.hedvig.android.logger.LogPriority
import com.hedvig.android.logger.logcat
Expand All @@ -8,8 +9,6 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import java.util.UUID
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

Expand All @@ -26,20 +25,20 @@ internal class AndroidAccessTokenProvider(
val requestId = "#${UUID.randomUUID().toString().takeLast(6)}"
val accessToken = (authTokenService.authStatus.value as? AuthStatus.LoggedIn)?.accessToken
logcat(LogPriority.VERBOSE) { "$requestId Got accessToken: $accessToken" }
if (accessToken?.expiryDate?.isExpired()?.not() == true) {
if (accessToken?.expiryDate?.isTokenExpired(clock)?.not() == true) {
logcat(LogPriority.VERBOSE) { "$requestId Current AccessToken not expired, fast track to adding the header" }
return accessToken.token
}
return mutex.withLock {
val authTokens = authTokenService.getTokens() ?: return@withLock null.also {
logcat(LogPriority.VERBOSE) { "$requestId Tokens were not stored, proceeding unauthenticated" }
}
if (authTokens.accessToken.expiryDate.isExpired().not()) {
if (authTokens.accessToken.expiryDate.isTokenExpired(clock).not()) {
logcat(LogPriority.VERBOSE) { "$requestId After lock, token was refreshed, proceeding with refreshed token" }
return@withLock authTokens.accessToken.token
}
logcat(LogPriority.VERBOSE) { "$requestId Still an expired token at this point, try to refresh it" }
if (authTokens.refreshToken.expiryDate.isExpired()) {
if (authTokens.refreshToken.expiryDate.isTokenExpired(clock)) {
logcat { "$requestId Refresh token expired, invalidating tokens and proceeding unauthenticated" }
// If refresh is also expired, consider ourselves logged out
authTokenService.logoutAndInvalidateTokens()
Expand All @@ -55,17 +54,4 @@ internal class AndroidAccessTokenProvider(
refreshedAccessToken.token
}
}

private fun Instant.isExpired(): Boolean {
val bufferAdjustedExpirationInstant = this - expirationTimeBuffer
return bufferAdjustedExpirationInstant <= clock.now()
}

companion object {
/**
* Assume the token expires a bit earlier than it really does, to give some room for the network requests and such
* to run and not risk assuming it's active but until the request goes through it's already become invalidated.
*/
private val expirationTimeBuffer = 60.seconds
}
}
Loading
Loading