diff --git a/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt index 2cbdbf5b5f..37649d7c39 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt @@ -113,6 +113,7 @@ class DefaultVectorAnalytics @Inject constructor( private fun observeUserConsent() { getUserConsent() .onEach { consent -> + println("!!!, got consent: $consent") Timber.tag(analyticsTag.value).d("User consent updated to $consent") userConsent = consent optOutPostHog() @@ -147,6 +148,9 @@ class DefaultVectorAnalytics @Inject constructor( override fun screen(screen: VectorAnalyticsScreen) { Timber.tag(analyticsTag.value).d("screen($screen)") + + println("userconsnet: $userConsent") + posthog ?.takeIf { userConsent == true } ?.screen(screen.getName(), screen.getProperties()?.toPostHogProperties()) diff --git a/vector/src/test/java/im/vector/app/features/analytics/impl/DefaultVectorAnalyticsTest.kt b/vector/src/test/java/im/vector/app/features/analytics/impl/DefaultVectorAnalyticsTest.kt new file mode 100644 index 0000000000..2680979d7e --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/analytics/impl/DefaultVectorAnalyticsTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 im.vector.app.features.analytics.impl + +import com.posthog.android.Properties +import im.vector.app.features.analytics.itf.VectorAnalyticsEvent +import im.vector.app.features.analytics.itf.VectorAnalyticsScreen +import im.vector.app.test.fakes.FakeAnalyticsStore +import im.vector.app.test.fakes.FakePostHog +import im.vector.app.test.fakes.FakePostHogFactory +import im.vector.app.test.fixtures.AnalyticsConfigFixture.anAnalyticsConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Before +import org.junit.Test + +private const val AN_ANALYTICS_ID = "analytics-id" +private val A_SCREEN_EVENT = object : VectorAnalyticsScreen { + override fun getName() = "a-screen-event-name" + override fun getProperties() = mapOf("property-name" to "property-value") +} +private val AN_EVENT = object : VectorAnalyticsEvent { + override fun getName() = "an-event-name" + override fun getProperties() = mapOf("property-name" to "property-value") +} + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultVectorAnalyticsTest { + + private val fakePostHog = FakePostHog() + private val fakeAnalyticsStore = FakeAnalyticsStore() + + private val defaultVectorAnalytics = DefaultVectorAnalytics( + postHogFactory = FakePostHogFactory(fakePostHog.instance).instance, + analyticsStore = fakeAnalyticsStore.instance, + globalScope = CoroutineScope(Dispatchers.Unconfined), + analyticsConfig = anAnalyticsConfig(isEnabled = true) + ) + + @Before + fun setUp() { + defaultVectorAnalytics.init() + } + + @Test + fun `when setting user consent then updates analytics store`() = runBlockingTest { + defaultVectorAnalytics.setUserConsent(true) + + fakeAnalyticsStore.verifyConsentUpdated(updatedValue = true) + } + + @Test + fun `when consenting to analytics then updates posthog opt out to false`() = runBlockingTest { + fakeAnalyticsStore.givenUserContent(consent = true) + + fakePostHog.verifyOptOutStatus(optedOut = false) + } + + @Test + fun `when revoking consent to analytics then updates posthog opt out to true`() = runBlockingTest { + fakeAnalyticsStore.givenUserContent(consent = false) + + fakePostHog.verifyOptOutStatus(optedOut = true) + } + + @Test + fun `when setting the analytics id then updates analytics store`() = runBlockingTest { + defaultVectorAnalytics.setAnalyticsId(AN_ANALYTICS_ID) + + fakeAnalyticsStore.verifyAnalyticsIdUpdated(updatedValue = AN_ANALYTICS_ID) + } + + @Test + fun `when valid analytics id updates then identify`() = runBlockingTest { + fakeAnalyticsStore.givenAnalyticsId(AN_ANALYTICS_ID) + + fakePostHog.verifyIdentifies(AN_ANALYTICS_ID) + } + + @Test + fun `when signing out analytics id updates then resets`() = runBlockingTest { + fakeAnalyticsStore.allowSettingAnalyticsIdToCallBackingFlow() + + defaultVectorAnalytics.onSignOut() + + fakePostHog.verifyReset() + } + + @Test + fun `given user consent when tracking screen events then submits to posthog`() = runBlockingTest { + fakeAnalyticsStore.givenUserContent(consent = true) + + defaultVectorAnalytics.screen(A_SCREEN_EVENT) + + fakePostHog.verifyScreenTracked(A_SCREEN_EVENT.getName(), Properties().also { + it.putAll(A_SCREEN_EVENT.getProperties()) + }) + } + + @Test + fun `given user has not consented when tracking screen events then does not track`() = runBlockingTest { + fakeAnalyticsStore.givenUserContent(consent = false) + + defaultVectorAnalytics.screen(A_SCREEN_EVENT) + + fakePostHog.verifyNoScreenTracking() + } + + @Test + fun `given user consent when tracking events then submits to posthog`() = runBlockingTest { + fakeAnalyticsStore.givenUserContent(consent = true) + + defaultVectorAnalytics.capture(AN_EVENT) + + fakePostHog.verifyEventTracked(AN_EVENT.getName(), Properties().also { + it.putAll(AN_EVENT.getProperties()) + }) + } + + @Test + fun `given user has not consented when tracking events then does not track`() = runBlockingTest { + fakeAnalyticsStore.givenUserContent(consent = false) + + defaultVectorAnalytics.capture(AN_EVENT) + + fakePostHog.verifyNoEventTracking() + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeAnalyticsStore.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeAnalyticsStore.kt new file mode 100644 index 0000000000..6304da8d37 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeAnalyticsStore.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 im.vector.app.test.fakes + +import im.vector.app.features.analytics.store.AnalyticsStore +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking + +class FakeAnalyticsStore { + + private val _consentFlow = MutableSharedFlow() + private val _idFlow = MutableSharedFlow() + + val instance = mockk(relaxed = true) { + every { userConsentFlow } returns _consentFlow + every { analyticsIdFlow } returns _idFlow + } + + fun allowSettingAnalyticsIdToCallBackingFlow() { + coEvery { instance.setAnalyticsId(any()) } answers { + runBlocking { _idFlow.emit(firstArg()) } + } + } + + fun verifyConsentUpdated(updatedValue: Boolean) { + coVerify { instance.setUserConsent(updatedValue) } + } + + suspend fun givenUserContent(consent: Boolean) { + _consentFlow.emit(consent) + } + + fun verifyAnalyticsIdUpdated(updatedValue: String) { + coVerify { instance.setAnalyticsId(updatedValue) } + } + + suspend fun givenAnalyticsId(id: String) { + _idFlow.emit(id) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakePostHog.kt b/vector/src/test/java/im/vector/app/test/fakes/FakePostHog.kt new file mode 100644 index 0000000000..631e09aada --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakePostHog.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 im.vector.app.test.fakes + +import android.os.Looper +import com.posthog.android.PostHog +import com.posthog.android.Properties +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify + +class FakePostHog { + + init { + // workaround to avoid PostHog.HANDLER failing + mockkStatic(Looper::class) + val looper = mockk { + every { thread } returns Thread.currentThread() + } + every { Looper.getMainLooper() } returns looper + } + + val instance = mockk(relaxed = true) + + fun verifyOptOutStatus(optedOut: Boolean) { + verify { instance.optOut(optedOut) } + } + + fun verifyIdentifies(analyticsId: String) { + verify { instance.identify(analyticsId) } + } + + fun verifyReset() { + verify { instance.reset() } + } + + fun verifyScreenTracked(name: String, properties: Properties) { + verify { instance.screen(name, properties) } + } + + fun verifyNoScreenTracking() { + verify(exactly = 0) { + instance.screen(any()) + instance.screen(any(), any()) + instance.screen(any(), any(), any()) + } + } + + fun verifyEventTracked(name: String, properties: Properties) { + verify { instance.capture(name, properties) } + } + + fun verifyNoEventTracking() { + verify(exactly = 0) { + instance.capture(any()) + instance.capture(any(), any()) + instance.capture(any(), any(), any()) + } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakePostHogFactory.kt b/vector/src/test/java/im/vector/app/test/fakes/FakePostHogFactory.kt new file mode 100644 index 0000000000..1d18c97d32 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakePostHogFactory.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 im.vector.app.test.fakes + +import com.posthog.android.PostHog +import im.vector.app.features.analytics.impl.PostHogFactory +import io.mockk.every +import io.mockk.mockk + +class FakePostHogFactory(postHog: PostHog) { + val instance = mockk().also { + every { it.createPosthog() } returns postHog + } +} diff --git a/vector/src/test/java/im/vector/app/test/fixtures/AnalyticsConfigFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/AnalyticsConfigFixture.kt new file mode 100644 index 0000000000..5fbcdd98d1 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fixtures/AnalyticsConfigFixture.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 im.vector.app.test.fixtures + +import im.vector.app.features.analytics.AnalyticsConfig + +object AnalyticsConfigFixture { + fun anAnalyticsConfig( + isEnabled: Boolean = false, + postHogHost: String = "http://posthog.url", + postHogApiKey: String = "api-key", + policyLink: String = "http://policy.link" + ) = object : AnalyticsConfig { + override val isEnabled: Boolean = isEnabled + override val postHogHost = postHogHost + override val postHogApiKey = postHogApiKey + override val policyLink = policyLink + } +}