Handle interactions with message controller
This commit is contained in:
parent
7b2c7c4cc6
commit
8c82e9f640
|
@ -1016,18 +1016,22 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
|
|||
}
|
||||
}
|
||||
|
||||
fun processIntent(intent: Intent): Boolean {
|
||||
return externalSourceIntentProcessors.any {
|
||||
it.process(
|
||||
intent,
|
||||
navHost.navController,
|
||||
this.intent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun getSettings(): Settings = settings()
|
||||
|
||||
private fun shouldNavigateToBrowserOnColdStart(savedInstanceState: Bundle?): Boolean {
|
||||
return isActivityColdStarted(intent, savedInstanceState) &&
|
||||
!externalSourceIntentProcessors.any {
|
||||
it.process(
|
||||
intent,
|
||||
navHost.navController,
|
||||
this.intent
|
||||
)
|
||||
}
|
||||
!processIntent(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package org.mozilla.fenix.gleanplum
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.net.toUri
|
||||
import org.mozilla.fenix.BuildConfig
|
||||
import org.mozilla.fenix.HomeActivity
|
||||
|
||||
/**
|
||||
* Handles default interactions with an ui message.
|
||||
*/
|
||||
class DefaultMessageController(
|
||||
private val messageManager: MessagesManager,
|
||||
private val homeActivity: HomeActivity
|
||||
) : MessageController {
|
||||
|
||||
override fun onMessagePressed(message: Message) {
|
||||
// TODO: report telemetry event
|
||||
messageManager.onMessagePressed(message)
|
||||
handleAction(message.data.action)
|
||||
}
|
||||
|
||||
override fun onMessageDismissed(message: Message) {
|
||||
// TODO: report telemetry event
|
||||
messageManager.onMessageDismissed(message)
|
||||
}
|
||||
|
||||
override fun onMessageDisplayed(message: Message) {
|
||||
// TODO: report telemetry event
|
||||
messageManager.onMessageDisplayed(message)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun handleAction(action: String): Intent {
|
||||
val url = if (action.startsWith("http", ignoreCase = true)) {
|
||||
"open?url=$action"
|
||||
} else {
|
||||
action
|
||||
}
|
||||
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEP_LINK_SCHEME}://$url".toUri())
|
||||
homeActivity.processIntent(intent)
|
||||
|
||||
return intent
|
||||
}
|
||||
|
||||
}
|
|
@ -4,12 +4,10 @@
|
|||
|
||||
package org.mozilla.fenix.gleanplum
|
||||
|
||||
/**
|
||||
* Controls all the interactions with a [Message].
|
||||
*/
|
||||
interface MessageController {
|
||||
/**
|
||||
* Finds the next message to be displayed.
|
||||
*/
|
||||
fun getNextMessage(): Message?
|
||||
|
||||
/**
|
||||
* Indicates the provided [message] was pressed press by the user.
|
||||
*/
|
||||
|
@ -26,6 +24,4 @@ interface MessageController {
|
|||
* to the users.
|
||||
*/
|
||||
fun onMessageDisplayed(message: Message)
|
||||
|
||||
fun initialize()
|
||||
}
|
|
@ -5,17 +5,22 @@
|
|||
package org.mozilla.fenix.gleanplum
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import org.mozilla.experiments.nimbus.internal.FeatureHolder
|
||||
import org.mozilla.fenix.BuildConfig
|
||||
import org.mozilla.fenix.nimbus.Messaging
|
||||
import org.mozilla.fenix.nimbus.StyleData
|
||||
import java.util.SortedSet
|
||||
import kotlin.Comparator
|
||||
|
||||
/**
|
||||
* Handles all interactions messages from nimbus.
|
||||
*/
|
||||
class MessagesManager(
|
||||
private val context: Context,
|
||||
private val storage: MessageStorage,
|
||||
private val messagingFeature: FeatureHolder<Messaging>
|
||||
) : MessageController {
|
||||
) {
|
||||
|
||||
private val availableMessages: SortedSet<Message> = sortedSetOf(
|
||||
Comparator { message1, message2 -> message2.style.priority.compareTo(message1.style.priority) }
|
||||
|
@ -25,12 +30,12 @@ class MessagesManager(
|
|||
return availableMessages.isNotEmpty()
|
||||
}
|
||||
|
||||
override fun getNextMessage(): Message? {
|
||||
fun getNextMessage(): Message? {
|
||||
return availableMessages.first()
|
||||
}
|
||||
|
||||
override fun onMessagePressed(message: Message) {
|
||||
// TODO: Report telemetry event
|
||||
fun onMessagePressed(message: Message) {
|
||||
// Update storage
|
||||
storage.updateMetadata(
|
||||
message.metadata.copy(
|
||||
pressed = true
|
||||
|
@ -39,8 +44,7 @@ class MessagesManager(
|
|||
availableMessages.remove(message)
|
||||
}
|
||||
|
||||
override fun onMessageDismissed(message: Message) {
|
||||
// TODO: Report telemetry event
|
||||
fun onMessageDismissed(message: Message) {
|
||||
storage.updateMetadata(
|
||||
message.metadata.copy(
|
||||
dismissed = true
|
||||
|
@ -49,8 +53,7 @@ class MessagesManager(
|
|||
availableMessages.remove(message)
|
||||
}
|
||||
|
||||
override fun onMessageDisplayed(message: Message) {
|
||||
// TODO: Report telemetry event
|
||||
fun onMessageDisplayed(message: Message) {
|
||||
val newMetadata = message.metadata.copy(
|
||||
displayCount = message.metadata.displayCount + 1
|
||||
)
|
||||
|
@ -66,10 +69,12 @@ class MessagesManager(
|
|||
}
|
||||
}
|
||||
|
||||
override fun initialize() {
|
||||
val nimbusTriggers = messagingFeature.value().triggers
|
||||
val nimbusStyles = messagingFeature.value().styles
|
||||
val nimbusMessages = messagingFeature.value().messages
|
||||
fun initialize() {
|
||||
val nimbusFeature = messagingFeature.value()
|
||||
val nimbusTriggers = nimbusFeature.triggers
|
||||
val nimbusStyles = nimbusFeature.styles
|
||||
|
||||
val nimbusMessages = nimbusFeature.messages
|
||||
val defaultStyle = StyleData(context)
|
||||
val storageMetadata = storage.getMetadata().associateBy {
|
||||
it.id
|
||||
|
|
|
@ -105,6 +105,7 @@ import org.mozilla.fenix.ext.requireComponents
|
|||
import org.mozilla.fenix.ext.runIfFragmentIsAttached
|
||||
import org.mozilla.fenix.ext.settings
|
||||
import org.mozilla.fenix.ext.sort
|
||||
import org.mozilla.fenix.gleanplum.DefaultMessageController
|
||||
import org.mozilla.fenix.home.blocklist.BlocklistHandler
|
||||
import org.mozilla.fenix.home.blocklist.BlocklistMiddleware
|
||||
import org.mozilla.fenix.home.mozonline.showPrivacyPopWindow
|
||||
|
@ -345,6 +346,10 @@ class HomeFragment : Fragment() {
|
|||
settings = components.settings,
|
||||
engine = components.core.engine,
|
||||
metrics = components.analytics.metrics,
|
||||
messageController = DefaultMessageController(
|
||||
messageManager = components.messagesManager,
|
||||
homeActivity = activity
|
||||
),
|
||||
store = store,
|
||||
tabCollectionStorage = components.core.tabCollectionStorage,
|
||||
addTabUseCase = components.useCases.tabsUseCases.addTab,
|
||||
|
|
|
@ -44,6 +44,9 @@ import org.mozilla.fenix.ext.metrics
|
|||
import org.mozilla.fenix.ext.nav
|
||||
import org.mozilla.fenix.ext.openSetDefaultBrowserOption
|
||||
import org.mozilla.fenix.ext.settings
|
||||
import org.mozilla.fenix.gleanplum.Message
|
||||
import org.mozilla.fenix.gleanplum.MessageController
|
||||
import org.mozilla.fenix.gleanplum.MessagesManager
|
||||
import org.mozilla.fenix.home.HomeFragment
|
||||
import org.mozilla.fenix.home.HomeFragmentAction
|
||||
import org.mozilla.fenix.home.HomeFragmentDirections
|
||||
|
@ -177,14 +180,19 @@ interface SessionControlController {
|
|||
fun handleMenuOpened()
|
||||
|
||||
/**
|
||||
* @see [ExperimentCardInteractor.onSetDefaultBrowserClicked]
|
||||
* @see [MessageCardInteractor.onMessageClicked]
|
||||
*/
|
||||
fun handleSetDefaultBrowser()
|
||||
fun handleMessageClicked(message: Message)
|
||||
|
||||
/**
|
||||
* @see [ExperimentCardInteractor.onCloseExperimentCardClicked]
|
||||
* @see [MessageCardInteractor.onMessageClosedClicked]
|
||||
*/
|
||||
fun handleCloseExperimentCard()
|
||||
fun handleMessageClosed(message: Message)
|
||||
|
||||
/**
|
||||
* @see [MessageCardInteractor.onMessageDisplayed]
|
||||
*/
|
||||
fun handleMessageDisplayed(message: Message)
|
||||
|
||||
/**
|
||||
* @see [TabSessionInteractor.onPrivateModeButtonClicked]
|
||||
|
@ -213,6 +221,7 @@ class DefaultSessionControlController(
|
|||
private val settings: Settings,
|
||||
private val engine: Engine,
|
||||
private val metrics: MetricController,
|
||||
private val messageController: MessageController,
|
||||
private val store: BrowserStore,
|
||||
private val tabCollectionStorage: TabCollectionStorage,
|
||||
private val addTabUseCase: TabsUseCases.AddNewTabUseCase,
|
||||
|
@ -597,14 +606,17 @@ class DefaultSessionControlController(
|
|||
navController.nav(R.id.homeFragment, directions)
|
||||
}
|
||||
|
||||
override fun handleSetDefaultBrowser() {
|
||||
settings.userDismissedExperimentCard = true
|
||||
activity.openSetDefaultBrowserOption()
|
||||
override fun handleMessageClicked(message: Message) {
|
||||
messageController.onMessagePressed(message)
|
||||
}
|
||||
|
||||
override fun handleCloseExperimentCard() {
|
||||
settings.userDismissedExperimentCard = true
|
||||
fragmentStore.dispatch(HomeFragmentAction.RemoveSetDefaultBrowserCard)
|
||||
override fun handleMessageClosed(message: Message) {
|
||||
messageController.onMessageDismissed(message)
|
||||
}
|
||||
|
||||
override fun handleMessageDisplayed(message: Message) {
|
||||
messageController.onMessageDismissed(message)
|
||||
// fragmentStore.dispatch(HomeFragmentAction.RemoveSetDefaultBrowserCard)
|
||||
}
|
||||
|
||||
override fun handlePrivateModeButtonClicked(
|
||||
|
|
|
@ -10,6 +10,7 @@ import mozilla.components.feature.top.sites.TopSite
|
|||
import mozilla.components.service.pocket.PocketRecommendedStory
|
||||
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
|
||||
import org.mozilla.fenix.components.tips.Tip
|
||||
import org.mozilla.fenix.gleanplum.Message
|
||||
import org.mozilla.fenix.home.HomeFragmentState
|
||||
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
|
||||
import org.mozilla.fenix.home.pocket.PocketStoriesController
|
||||
|
@ -232,16 +233,21 @@ interface TopSiteInteractor {
|
|||
fun onTopSiteMenuOpened()
|
||||
}
|
||||
|
||||
interface ExperimentCardInteractor {
|
||||
interface MessageCardInteractor {
|
||||
/**
|
||||
* Called when set default browser button is clicked
|
||||
* Called when a [Message]'s button is clicked
|
||||
*/
|
||||
fun onSetDefaultBrowserClicked()
|
||||
fun onMessageClicked(message: Message)
|
||||
|
||||
/**
|
||||
* Called when close button on experiment card
|
||||
* Called when close button on a [Message] card.
|
||||
*/
|
||||
fun onCloseExperimentCardClicked()
|
||||
fun onMessageClosedClicked(message: Message)
|
||||
|
||||
/**
|
||||
* Called when close button on a [Message] card.
|
||||
*/
|
||||
fun onMessageDisplayed(message: Message)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -263,7 +269,7 @@ class SessionControlInteractor(
|
|||
TipInteractor,
|
||||
TabSessionInteractor,
|
||||
ToolbarInteractor,
|
||||
ExperimentCardInteractor,
|
||||
MessageCardInteractor,
|
||||
RecentTabInteractor,
|
||||
RecentBookmarksInteractor,
|
||||
RecentVisitsInteractor,
|
||||
|
@ -374,14 +380,6 @@ class SessionControlInteractor(
|
|||
controller.handleMenuOpened()
|
||||
}
|
||||
|
||||
override fun onSetDefaultBrowserClicked() {
|
||||
controller.handleSetDefaultBrowser()
|
||||
}
|
||||
|
||||
override fun onCloseExperimentCardClicked() {
|
||||
controller.handleCloseExperimentCard()
|
||||
}
|
||||
|
||||
override fun onRecentTabClicked(tabId: String) {
|
||||
recentTabController.handleRecentTabClicked(tabId)
|
||||
}
|
||||
|
@ -459,4 +457,16 @@ class SessionControlInteractor(
|
|||
override fun reportSessionMetrics(state: HomeFragmentState) {
|
||||
controller.handleReportSessionMetrics(state)
|
||||
}
|
||||
|
||||
override fun onMessageClicked(message: Message) {
|
||||
controller.handleMessageClicked(message)
|
||||
}
|
||||
|
||||
override fun onMessageClosedClicked(message: Message) {
|
||||
controller.handleMessageClosed(message)
|
||||
}
|
||||
|
||||
override fun onMessageDisplayed(message: Message) {
|
||||
controller.handleMessageDisplayed(message)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,41 +18,36 @@ class ExperimentDefaultBrowserCardViewHolder(
|
|||
view: View,
|
||||
private val interactor: SessionControlInteractor
|
||||
) : RecyclerView.ViewHolder(view) {
|
||||
|
||||
// TODO: Address !!
|
||||
private val messagesManager: MessagesManager = view.context.components.messagesManager
|
||||
private val message: Message = messagesManager.getNextMessage()!!
|
||||
private val message: Message? = messagesManager.getNextMessage()
|
||||
|
||||
init {
|
||||
initialize(view)
|
||||
}
|
||||
|
||||
private fun initialize(view: View) {
|
||||
val binding = ExperimentDefaultBrowserBinding.bind(view)
|
||||
binding.setDefaultBrowser.setOnClickListener {
|
||||
// TODO: use interactor
|
||||
messagesManager.onMessagePressed(message)
|
||||
val safeMessage = message ?: return
|
||||
|
||||
binding.setDefaultBrowser.setOnClickListener {
|
||||
interactor.onMessageClicked(safeMessage)
|
||||
|
||||
interactor.onSetDefaultBrowserClicked()
|
||||
}
|
||||
|
||||
|
||||
binding.descriptionText.text = message.data.text
|
||||
|
||||
binding.descriptionText.text = safeMessage.data.text
|
||||
//TODO: Bind button text
|
||||
|
||||
binding.close.apply {
|
||||
increaseTapArea(CLOSE_BUTTON_EXTRA_DPS)
|
||||
setOnClickListener {
|
||||
// TODO: use interactor
|
||||
messagesManager.onMessageDismissed(message)
|
||||
|
||||
interactor.onCloseExperimentCardClicked()
|
||||
interactor.onMessageClosedClicked(safeMessage)
|
||||
}
|
||||
}
|
||||
|
||||
binding.close.apply {
|
||||
increaseTapArea(CLOSE_BUTTON_EXTRA_DPS)
|
||||
setOnClickListener {
|
||||
// TODO: use interactor
|
||||
messagesManager.onMessagePressed(message)
|
||||
interactor.onCloseExperimentCardClicked()
|
||||
interactor.onMessageClosedClicked(safeMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -323,24 +323,6 @@ class Settings(private val appContext: Context) : PreferencesHolder {
|
|||
default = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Shows if the user has chosen to close the set default browser experiment card
|
||||
* on home screen or has clicked the set as default browser button.
|
||||
*/
|
||||
var userDismissedExperimentCard by booleanPreference(
|
||||
appContext.getPreferenceKey(R.string.pref_key_experiment_card_home),
|
||||
default = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Shows if the set default browser experiment card should be shown on home screen.
|
||||
*/
|
||||
fun shouldShowSetAsDefaultBrowserCard(): Boolean {
|
||||
return isDefaultBrowserMessageLocation(MessageSurfaceId.HOMESCREEN_BANNER) &&
|
||||
!userDismissedExperimentCard &&
|
||||
numberOfAppLaunches > APP_LAUNCHES_TO_SHOW_DEFAULT_BROWSER_CARD
|
||||
}
|
||||
|
||||
private val defaultBrowserFeature: DefaultBrowserMessage by lazy {
|
||||
FxNimbus.features.defaultBrowserMessage.value()
|
||||
}
|
||||
|
|
|
@ -6,6 +6,9 @@
|
|||
<string name="app_name_private_5">Private %s</string>
|
||||
<!-- App name for private browsing mode. The first parameter is the name of the app defined in app_name (for example: Fenix)-->
|
||||
<string name="app_name_private_4">%s (Private)</string>
|
||||
<string name="gdpr_has_you_covered_firefox_has_gdpr_covered">%s (Private)</string>
|
||||
<string name="sharing_your_phone_autoclosing_private_tabs_is_for_you">%s (Private)</string>
|
||||
<string name="love_firefox_fill_in_our_survey">%s (Private)</string>
|
||||
|
||||
<!-- Home Fragment -->
|
||||
<!-- Content description (not visible, for screen readers etc.): "Three dot" menu button. -->
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package org.mozilla.fenix.gleanplum
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.spyk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import mozilla.components.support.test.robolectric.testContext
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mozilla.fenix.BuildConfig
|
||||
import org.mozilla.fenix.HomeActivity
|
||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||
import org.mozilla.fenix.nimbus.MessageData
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
class DefaultMessageControllerTest {
|
||||
|
||||
private val activity: HomeActivity = mockk(relaxed = true)
|
||||
private val manager: MessagesManager = mockk(relaxed = true)
|
||||
|
||||
private lateinit var controller: DefaultMessageController
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
controller = DefaultMessageController(
|
||||
messageManager = manager,
|
||||
homeActivity = activity
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling onMessagePressed THEN report to the messageManager and handle the action`() {
|
||||
val customController = spyk(controller)
|
||||
every { customController.handleAction(any()) } returns mockk()
|
||||
|
||||
val message = mockMessage()
|
||||
|
||||
customController.onMessagePressed(message)
|
||||
|
||||
verify { customController.handleAction(any()) }
|
||||
verify { customController.onMessagePressed(message) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN an URL WHEN calling handleAction THEN process the intent with an open uri`() {
|
||||
val intent = controller.handleAction("http://mozilla.org")
|
||||
|
||||
verify { activity.processIntent(any()) }
|
||||
|
||||
assertEquals(
|
||||
"${BuildConfig.DEEP_LINK_SCHEME}://open?url=http://mozilla.org",
|
||||
intent.data.toString()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN an deeplink WHEN calling handleAction THEN process the intent with an deeplink uri`() {
|
||||
val intent = controller.handleAction("settings_privacy")
|
||||
|
||||
verify { activity.processIntent(any()) }
|
||||
|
||||
assertEquals("${BuildConfig.DEEP_LINK_SCHEME}://settings_privacy", intent.data.toString())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun `WHEN calling onMessageDismissed THEN report to the messageManager`() {
|
||||
val message = mockMessage()
|
||||
|
||||
controller.onMessageDismissed(message)
|
||||
|
||||
|
||||
verify { controller.onMessageDismissed(message) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling onMessageDisplayed THEN report to the messageManager`() {
|
||||
val message = mockMessage()
|
||||
|
||||
controller.onMessageDisplayed(message)
|
||||
|
||||
|
||||
verify { controller.onMessageDisplayed(message) }
|
||||
}
|
||||
|
||||
private fun mockMessage() = Message(
|
||||
id = "id",
|
||||
data = MessageData(_context = testContext),
|
||||
style = mockk(),
|
||||
triggers = emptyList(),
|
||||
metadata = MessageMetadata(
|
||||
id = "id",
|
||||
displayCount = 0,
|
||||
pressed = false,
|
||||
dismissed = false
|
||||
)
|
||||
)
|
||||
}
|
|
@ -55,6 +55,7 @@ import org.mozilla.fenix.components.metrics.MetricController
|
|||
import org.mozilla.fenix.components.tips.Tip
|
||||
import org.mozilla.fenix.ext.components
|
||||
import org.mozilla.fenix.ext.settings
|
||||
import org.mozilla.fenix.gleanplum.MessageController
|
||||
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
|
||||
import org.mozilla.fenix.home.recenttabs.RecentTab
|
||||
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
|
||||
|
@ -72,6 +73,7 @@ class DefaultSessionControlControllerTest {
|
|||
private val fragmentStore: HomeFragmentStore = mockk(relaxed = true)
|
||||
private val navController: NavController = mockk(relaxed = true)
|
||||
private val metrics: MetricController = mockk(relaxed = true)
|
||||
private val messageController: MessageController = mockk(relaxed = true)
|
||||
private val engine: Engine = mockk(relaxed = true)
|
||||
private val tabCollectionStorage: TabCollectionStorage = mockk(relaxed = true)
|
||||
private val tabsUseCases: TabsUseCases = mockk(relaxed = true)
|
||||
|
@ -1058,6 +1060,7 @@ class DefaultSessionControlControllerTest {
|
|||
engine = engine,
|
||||
metrics = metrics,
|
||||
store = store,
|
||||
messageController = messageController,
|
||||
tabCollectionStorage = tabCollectionStorage,
|
||||
addTabUseCase = tabsUseCases.addTab,
|
||||
restoreUseCase = mockk(relaxed = true),
|
||||
|
|
Loading…
Reference in New Issue