Skip to content

Commit d19380a

Browse files
committed
Refresh duck.ai when subscription state changes
1 parent 941067a commit d19380a

File tree

11 files changed

+449
-0
lines changed

11 files changed

+449
-0
lines changed

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivity.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromEx
7070
import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.NoMediaCaptured
7171
import com.duckduckgo.js.messaging.api.JsMessageCallback
7272
import com.duckduckgo.js.messaging.api.JsMessaging
73+
import com.duckduckgo.js.messaging.api.SubscriptionEventData
7374
import com.duckduckgo.navigation.api.GlobalActivityStarter
7475
import com.duckduckgo.navigation.api.getActivityParams
7576
import com.duckduckgo.subscriptions.api.SUBSCRIPTIONS_FEATURE_NAME
@@ -80,6 +81,8 @@ import javax.inject.Inject
8081
import javax.inject.Named
8182
import kotlinx.coroutines.CoroutineScope
8283
import kotlinx.coroutines.flow.cancellable
84+
import kotlinx.coroutines.flow.launchIn
85+
import kotlinx.coroutines.flow.onEach
8386
import kotlinx.coroutines.launch
8487
import kotlinx.coroutines.withContext
8588
import org.json.JSONObject
@@ -92,6 +95,8 @@ internal data class DuckChatWebViewActivityWithParams(
9295
@ContributeToActivityStarter(DuckChatWebViewActivityWithParams::class)
9396
open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationDialogListener {
9497

98+
private val viewModel: DuckChatWebViewActivityViewModel by bindViewModel()
99+
95100
@Inject
96101
lateinit var webViewClient: DuckChatWebViewClient
97102

@@ -284,6 +289,21 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
284289
}
285290
pendingUploadTask = null
286291
}
292+
293+
// Observe ViewModel commands
294+
viewModel.commands
295+
.onEach { command ->
296+
when (command) {
297+
is DuckChatWebViewActivityViewModel.Command.SendSubscriptionAuthUpdateEvent -> {
298+
val authUpdateEvent = SubscriptionEventData(
299+
featureName = SUBSCRIPTIONS_FEATURE_NAME,
300+
subscriptionName = "authUpdate",
301+
params = org.json.JSONObject(),
302+
)
303+
contentScopeScripts.sendSubscriptionEvent(authUpdateEvent)
304+
}
305+
}
306+
}.launchIn(lifecycleScope)
287307
}
288308

289309
data class FileChooserRequestedParams(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.ui
18+
19+
import androidx.lifecycle.ViewModel
20+
import androidx.lifecycle.viewModelScope
21+
import com.duckduckgo.anvil.annotations.ContributesViewModel
22+
import com.duckduckgo.di.scopes.ActivityScope
23+
import com.duckduckgo.subscriptions.api.Subscriptions
24+
import javax.inject.Inject
25+
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
26+
import kotlinx.coroutines.channels.Channel
27+
import kotlinx.coroutines.flow.distinctUntilChanged
28+
import kotlinx.coroutines.flow.launchIn
29+
import kotlinx.coroutines.flow.onEach
30+
import kotlinx.coroutines.flow.receiveAsFlow
31+
import logcat.logcat
32+
33+
@ContributesViewModel(ActivityScope::class)
34+
class DuckChatWebViewActivityViewModel @Inject constructor(
35+
private val subscriptions: Subscriptions,
36+
) : ViewModel() {
37+
38+
private val commandChannel = Channel<Command>(capacity = 1, onBufferOverflow = DROP_OLDEST)
39+
val commands = commandChannel.receiveAsFlow()
40+
41+
sealed class Command {
42+
data object SendSubscriptionAuthUpdateEvent : Command()
43+
}
44+
45+
init {
46+
observeSubscriptionChanges()
47+
}
48+
49+
private fun observeSubscriptionChanges() {
50+
subscriptions.getSubscriptionStatusFlow()
51+
.distinctUntilChanged()
52+
.onEach { _ ->
53+
logcat { "CRIS: SUBSCRIPTION STATUS CHANGED" }
54+
commandChannel.trySend(Command.SendSubscriptionAuthUpdateEvent)
55+
}.launchIn(viewModelScope)
56+
}
57+
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import android.webkit.WebView
3737
import androidx.annotation.AnyThread
3838
import androidx.appcompat.widget.Toolbar
3939
import androidx.core.content.ContextCompat
40+
import androidx.lifecycle.ViewModelProvider
4041
import androidx.lifecycle.lifecycleScope
4142
import com.duckduckgo.anvil.annotations.InjectWith
4243
import com.duckduckgo.app.di.AppCoroutineScope
@@ -48,6 +49,7 @@ import com.duckduckgo.common.ui.view.makeSnackbarWithNoBottomInset
4849
import com.duckduckgo.common.ui.viewbinding.viewBinding
4950
import com.duckduckgo.common.utils.ConflatedJob
5051
import com.duckduckgo.common.utils.DispatcherProvider
52+
import com.duckduckgo.common.utils.FragmentViewModelFactory
5153
import com.duckduckgo.di.scopes.FragmentScope
5254
import com.duckduckgo.downloads.api.DOWNLOAD_SNACKBAR_DELAY
5355
import com.duckduckgo.downloads.api.DOWNLOAD_SNACKBAR_LENGTH
@@ -72,6 +74,7 @@ import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromEx
7274
import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.NoMediaCaptured
7375
import com.duckduckgo.js.messaging.api.JsMessageCallback
7476
import com.duckduckgo.js.messaging.api.JsMessaging
77+
import com.duckduckgo.js.messaging.api.SubscriptionEventData
7578
import com.duckduckgo.subscriptions.api.SUBSCRIPTIONS_FEATURE_NAME
7679
import com.google.android.material.snackbar.BaseTransientBottomBar
7780
import com.google.android.material.snackbar.Snackbar
@@ -80,13 +83,22 @@ import javax.inject.Inject
8083
import javax.inject.Named
8184
import kotlinx.coroutines.CoroutineScope
8285
import kotlinx.coroutines.flow.cancellable
86+
import kotlinx.coroutines.flow.launchIn
87+
import kotlinx.coroutines.flow.onEach
8388
import kotlinx.coroutines.launch
8489
import kotlinx.coroutines.withContext
8590
import org.json.JSONObject
8691

8792
@InjectWith(FragmentScope::class)
8893
open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_chat_webview), DownloadConfirmationDialogListener {
8994

95+
@Inject
96+
lateinit var viewModelFactory: FragmentViewModelFactory
97+
98+
private val viewModel: DuckChatWebViewViewModel by lazy {
99+
ViewModelProvider(this, viewModelFactory)[DuckChatWebViewViewModel::class.java]
100+
}
101+
90102
@Inject
91103
lateinit var webViewClient: DuckChatWebViewClient
92104

@@ -279,6 +291,21 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c
279291
}
280292
pendingUploadTask = null
281293
}
294+
295+
// Observe ViewModel commands
296+
viewModel.commands
297+
.onEach { command ->
298+
when (command) {
299+
is DuckChatWebViewViewModel.Command.SendSubscriptionAuthUpdateEvent -> {
300+
val authUpdateEvent = SubscriptionEventData(
301+
featureName = SUBSCRIPTIONS_FEATURE_NAME,
302+
subscriptionName = "authUpdate",
303+
params = JSONObject(),
304+
)
305+
contentScopeScripts.sendSubscriptionEvent(authUpdateEvent)
306+
}
307+
}
308+
}.launchIn(lifecycleScope)
282309
}
283310

284311
data class FileChooserRequestedParams(
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.ui
18+
19+
import androidx.lifecycle.ViewModel
20+
import androidx.lifecycle.viewModelScope
21+
import com.duckduckgo.anvil.annotations.ContributesViewModel
22+
import com.duckduckgo.di.scopes.FragmentScope
23+
import com.duckduckgo.subscriptions.api.Subscriptions
24+
import javax.inject.Inject
25+
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
26+
import kotlinx.coroutines.channels.Channel
27+
import kotlinx.coroutines.flow.distinctUntilChanged
28+
import kotlinx.coroutines.flow.launchIn
29+
import kotlinx.coroutines.flow.onEach
30+
import kotlinx.coroutines.flow.receiveAsFlow
31+
import logcat.logcat
32+
33+
@ContributesViewModel(FragmentScope::class)
34+
class DuckChatWebViewViewModel @Inject constructor(
35+
private val subscriptions: Subscriptions,
36+
) : ViewModel() {
37+
38+
private val commandChannel = Channel<Command>(capacity = 1, onBufferOverflow = DROP_OLDEST)
39+
val commands = commandChannel.receiveAsFlow()
40+
41+
sealed class Command {
42+
data object SendSubscriptionAuthUpdateEvent : Command()
43+
}
44+
45+
init {
46+
observeSubscriptionChanges()
47+
}
48+
49+
private fun observeSubscriptionChanges() {
50+
subscriptions.getSubscriptionStatusFlow()
51+
.distinctUntilChanged()
52+
.onEach { _ ->
53+
logcat { "CRIS: SUBSCRIPTION STATUS CHANGED" }
54+
commandChannel.trySend(Command.SendSubscriptionAuthUpdateEvent)
55+
}.launchIn(viewModelScope)
56+
}
57+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.ui
18+
19+
import androidx.test.ext.junit.runners.AndroidJUnit4
20+
import app.cash.turbine.test
21+
import com.duckduckgo.common.test.CoroutineTestRule
22+
import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewActivityViewModel.Command
23+
import com.duckduckgo.subscriptions.api.SubscriptionStatus
24+
import com.duckduckgo.subscriptions.api.SubscriptionStatus.AUTO_RENEWABLE
25+
import com.duckduckgo.subscriptions.api.SubscriptionStatus.EXPIRED
26+
import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE
27+
import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN
28+
import com.duckduckgo.subscriptions.api.Subscriptions
29+
import kotlinx.coroutines.flow.MutableSharedFlow
30+
import kotlinx.coroutines.test.runTest
31+
import org.junit.Assert.assertTrue
32+
import org.junit.Before
33+
import org.junit.Rule
34+
import org.junit.Test
35+
import org.junit.runner.RunWith
36+
import org.mockito.kotlin.mock
37+
import org.mockito.kotlin.whenever
38+
39+
@RunWith(AndroidJUnit4::class)
40+
class DuckChatWebViewActivityViewModelTest {
41+
42+
@get:Rule
43+
val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
44+
45+
private val subscriptions: Subscriptions = mock()
46+
private val subscriptionStatusFlow = MutableSharedFlow<SubscriptionStatus>()
47+
48+
private lateinit var viewModel: DuckChatWebViewActivityViewModel
49+
50+
@Before
51+
fun setup() {
52+
whenever(subscriptions.getSubscriptionStatusFlow()).thenReturn(subscriptionStatusFlow)
53+
viewModel = DuckChatWebViewActivityViewModel(subscriptions)
54+
}
55+
56+
@Test
57+
fun whenSubscriptionStatusChangesToActiveThenSendSubscriptionAuthUpdateEventCommand() = runTest {
58+
viewModel.commands.test {
59+
subscriptionStatusFlow.emit(AUTO_RENEWABLE)
60+
61+
val command = awaitItem()
62+
assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
63+
}
64+
}
65+
66+
@Test
67+
fun whenSubscriptionStatusChangesToInactiveThenSendSubscriptionAuthUpdateEventCommand() = runTest {
68+
viewModel.commands.test {
69+
subscriptionStatusFlow.emit(INACTIVE)
70+
71+
val command = awaitItem()
72+
assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
73+
}
74+
}
75+
76+
@Test
77+
fun whenSubscriptionStatusChangesToExpiredThenSendSubscriptionAuthUpdateEventCommand() = runTest {
78+
viewModel.commands.test {
79+
subscriptionStatusFlow.emit(EXPIRED)
80+
81+
val command = awaitItem()
82+
assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
83+
}
84+
}
85+
86+
@Test
87+
fun whenSubscriptionStatusChangesToUnknownThenSendSubscriptionAuthUpdateEventCommand() = runTest {
88+
viewModel.commands.test {
89+
subscriptionStatusFlow.emit(UNKNOWN)
90+
91+
val command = awaitItem()
92+
assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
93+
}
94+
}
95+
96+
@Test
97+
fun whenSubscriptionStatusChangesTwiceToSameValueThenOnlyOneCommandSent() = runTest {
98+
viewModel.commands.test {
99+
// Emit the same status twice
100+
subscriptionStatusFlow.emit(AUTO_RENEWABLE)
101+
subscriptionStatusFlow.emit(AUTO_RENEWABLE)
102+
103+
// Should only receive one command due to distinctUntilChanged
104+
val command = awaitItem()
105+
assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
106+
expectNoEvents()
107+
}
108+
}
109+
110+
@Test
111+
fun whenSubscriptionStatusChangesTwiceToDifferentValuesThenTwoCommandsSent() = runTest {
112+
viewModel.commands.test {
113+
subscriptionStatusFlow.emit(AUTO_RENEWABLE)
114+
subscriptionStatusFlow.emit(EXPIRED)
115+
116+
val firstCommand = awaitItem()
117+
assertTrue(firstCommand is Command.SendSubscriptionAuthUpdateEvent)
118+
119+
val secondCommand = awaitItem()
120+
assertTrue(secondCommand is Command.SendSubscriptionAuthUpdateEvent)
121+
}
122+
}
123+
124+
@Test
125+
fun whenMultipleSubscriptionStatusChangesOccurThenCorrespondingCommandsSent() = runTest {
126+
viewModel.commands.test {
127+
subscriptionStatusFlow.emit(UNKNOWN)
128+
subscriptionStatusFlow.emit(INACTIVE)
129+
subscriptionStatusFlow.emit(AUTO_RENEWABLE)
130+
subscriptionStatusFlow.emit(EXPIRED)
131+
132+
repeat(4) {
133+
val command = awaitItem()
134+
assertTrue(command is Command.SendSubscriptionAuthUpdateEvent)
135+
}
136+
}
137+
}
138+
}

0 commit comments

Comments
 (0)