1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-2450 Membership | Analytics (#1284)

This commit is contained in:
Konstantin Ivanov 2024-06-11 12:30:49 +02:00 committed by konstantiniiv
parent cbf7edf8fa
commit f97ee286cb
12 changed files with 293 additions and 13 deletions

View file

@ -3,8 +3,19 @@ package com.anytypeio.anytype.analytics.props
sealed class UserProperty {
data class AccountId(val id: String?) : UserProperty()
data class InterfaceLanguage(val lang: String) : UserProperty()
data class Tier(val tierId: Int) : UserProperty() {
val tier: String
get() = when (tierId) {
0 -> "None"
1 -> "Explorer"
4 -> "Builder"
5 -> "Co-Creator"
else -> "Custom"
}
}
companion object {
const val INTERFACE_LANG_KEY = "interfaceLang"
const val TIER_KEY = "tier"
}
}

View file

@ -63,6 +63,11 @@ class AmplitudeTracker(
JSONObject(mapOf(UserProperty.INTERFACE_LANG_KEY to prop.lang))
)
}
is UserProperty.Tier -> {
tracker.setUserProperties(
JSONObject(mapOf(UserProperty.TIER_KEY to prop.tier))
)
}
}
}
}

View file

@ -26,6 +26,7 @@ import com.anytypeio.anytype.domain.wallpaper.RestoreWallpaper
import com.anytypeio.anytype.domain.wallpaper.WallpaperStore
import com.anytypeio.anytype.domain.workspace.SpaceManager
import com.anytypeio.anytype.presentation.main.MainViewModelFactory
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider
import com.anytypeio.anytype.presentation.navigation.DeepLinkToObjectDelegate
import com.anytypeio.anytype.presentation.notifications.NotificationActionDelegate
import com.anytypeio.anytype.presentation.notifications.NotificationsProvider
@ -74,7 +75,8 @@ object MainEntryModule {
notificator: SystemNotificationService,
notificationActionDelegate: NotificationActionDelegate,
deepLinkToObjectDelegate: DeepLinkToObjectDelegate,
awaitAccountStartManager: AwaitAccountStartManager
awaitAccountStartManager: AwaitAccountStartManager,
membershipProvider: MembershipProvider
): MainViewModelFactory = MainViewModelFactory(
resumeAccount = resumeAccount,
analytics = analytics,
@ -93,7 +95,8 @@ object MainEntryModule {
notificator = notificator,
notificationActionDelegate = notificationActionDelegate,
deepLinkToObjectDelegate = deepLinkToObjectDelegate,
awaitAccountStartManager = awaitAccountStartManager
awaitAccountStartManager = awaitAccountStartManager,
membershipProvider = membershipProvider
)
@JvmStatic

View file

@ -6,6 +6,7 @@ import com.anytypeio.anytype.data.auth.event.MembershipRemoteChannel
import com.anytypeio.anytype.domain.account.AwaitAccountStartManager
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.misc.DateProvider
import com.anytypeio.anytype.domain.misc.LocaleProvider
import com.anytypeio.anytype.domain.workspace.MembershipChannel
import com.anytypeio.anytype.middleware.EventProxy
@ -61,12 +62,14 @@ object MembershipModule {
awaitAccountStartManager: AwaitAccountStartManager,
membershipChannel: MembershipChannel,
localeProvider: LocaleProvider,
repo: BlockRepository
repo: BlockRepository,
dateProvider: DateProvider
): MembershipProvider = MembershipProvider.Default(
dispatchers = dispatchers,
membershipChannel = membershipChannel,
awaitAccountStartManager = awaitAccountStartManager,
localeProvider = localeProvider,
repo = repo
repo = repo,
dateProvider = dateProvider
)
}

View file

@ -2,6 +2,9 @@ package com.anytypeio.anytype.domain.misc
import com.anytypeio.anytype.core_models.TimeInMillis
import com.anytypeio.anytype.core_models.TimeInSeconds
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
@ -18,6 +21,7 @@ interface DateProvider {
fun getTimestampForWeekAgoAtStartOfDay(): TimeInSeconds
fun adjustToStartOfDayInUserTimeZone(timestamp: TimeInSeconds): TimeInMillis
fun adjustFromStartOfDayInUserTimeZoneToUTC(timestamp: TimeInMillis): TimeInSeconds
fun formatToDateString(timestamp: Long, pattern: String, locale: Locale): String
}
interface DateTypeNameProvider {

View file

@ -0,0 +1,29 @@
package com.anytypeio.anytype.payments
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultCoroutineTestRule() : TestWatcher() {
val dispatcher = StandardTestDispatcher(name = "Default test dispatcher")
override fun starting(description: Description) {
super.starting(description)
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
super.finished(description)
Dispatchers.resetMain()
}
fun advanceTime(millis: Long = 100L) {
dispatcher.scheduler.advanceTimeBy(millis)
}
}

View file

@ -1,10 +1,34 @@
package com.anytypeio.anytype.payments
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.core_models.membership.NameServiceNameType
import kotlin.random.Random
import net.bytebuddy.utility.RandomString
fun StubMembership(
tier: Int = Random.nextInt(),
status: Membership.Status = Membership.Status.STATUS_ACTIVE,
dateEnds: Long = 432331231L,
paymentMethod: MembershipPaymentMethod = MembershipPaymentMethod.METHOD_CRYPTO,
anyName: String = "anyName-${RandomString.make()}",
dateStarted: Long = Random.nextLong()
): Membership {
return Membership(
tier = tier,
membershipStatusModel = status,
dateEnds = dateEnds,
paymentMethod = paymentMethod,
userEmail = "",
nameServiceName = anyName,
nameServiceType = NameServiceNameType.ANY_NAME,
subscribeToNewsletter = false,
isAutoRenew = false,
dateStarted = dateStarted
)
}
fun StubMembershipTierData(
id: Int = Random.nextInt(),

View file

@ -0,0 +1,142 @@
package com.anytypeio.anytype.payments.provider
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import app.cash.turbine.turbineScope
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.domain.account.AwaitAccountStartManager
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.misc.DateProvider
import com.anytypeio.anytype.domain.misc.LocaleProvider
import com.anytypeio.anytype.domain.workspace.MembershipChannel
import com.anytypeio.anytype.payments.DefaultCoroutineTestRule
import com.anytypeio.anytype.payments.StubMembership
import com.anytypeio.anytype.payments.StubMembershipTierData
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider.Default.Companion.DATE_FORMAT
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.stub
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
class MembershipProviderTest {
@get:Rule
val rule = InstantTaskExecutorRule()
@get:Rule
val coroutineTestRule = DefaultCoroutineTestRule()
private val dispatchers = AppCoroutineDispatchers(
io = coroutineTestRule.dispatcher,
main = coroutineTestRule.dispatcher,
computation = coroutineTestRule.dispatcher
)
@Mock
lateinit var localeProvider: LocaleProvider
@Mock
lateinit var repo: BlockRepository
@Mock
lateinit var dateProvider: DateProvider
@Mock
lateinit var membershipChannel: MembershipChannel
private val awaitAccountStartManager: AwaitAccountStartManager =
AwaitAccountStartManager.Default
private lateinit var provider: MembershipProvider
@Before
fun before() {
MockitoAnnotations.openMocks(this)
provider = MembershipProvider.Default(
dispatchers = dispatchers,
membershipChannel = membershipChannel,
awaitAccountStartManager = awaitAccountStartManager,
localeProvider = localeProvider,
repo = repo,
dateProvider = dateProvider
)
}
@Test
fun `test membership status and tiers fetched successfully plus updated after event`() =
runTest {
turbineScope {
val dateEnds = 432331231L
val membership = StubMembership(dateEnds = dateEnds)
val tierData = StubMembershipTierData()
val tierData2 = StubMembershipTierData(isTest = true)
val event1 = Membership.Event(StubMembership(dateEnds = dateEnds))
val event2 = Membership.Event(StubMembership(dateEnds = dateEnds))
val eventList = flow {
emit(listOf(event1))
emit(listOf(event2))
}
membershipChannel.stub {
on { observe() } doReturn eventList
}
whenever(repo.membershipStatus(any())).thenReturn(membership)
whenever(
dateProvider.formatToDateString(
dateEnds,
DATE_FORMAT,
localeProvider.locale()
)
).thenReturn("01-01-1970")
val command = Command.Membership.GetTiers(
noCache = true,
locale = "en"
)
repo.stub {
onBlocking { membershipGetTiers(command) } doReturn listOf(tierData2, tierData)
}
whenever(localeProvider.language()).thenReturn("en")
awaitAccountStartManager.setIsStarted(true)
val membershipProviderFlow = provider.status().testIn(backgroundScope)
val membershipProviderFlow1 = provider.status().testIn(backgroundScope)
val membershipProviderFlow2 = provider.activeTier().testIn(backgroundScope)
val initialStatus = membershipProviderFlow.awaitItem()
assertEquals(membership.tier, initialStatus.activeTier.value)
assertEquals(tierData.id, initialStatus.tiers.first().id)
val status1 = membershipProviderFlow.awaitItem()
assertEquals(event1.membership.tier, status1.activeTier.value)
assertEquals(tierData.id, status1.tiers.first().id)
val status2 = membershipProviderFlow.awaitItem()
assertEquals(event2.membership.tier, status2.activeTier.value)
assertEquals(tierData.id, status2.tiers.first().id)
membershipProviderFlow1.awaitItem()
membershipProviderFlow1.awaitItem()
membershipProviderFlow1.awaitItem()
membershipProviderFlow2.awaitItem()
membershipProviderFlow2.awaitItem()
membershipProviderFlow2.awaitItem()
verify(repo, times(6)).membershipGetTiers(command)
}
}
}

View file

@ -32,6 +32,7 @@ import com.anytypeio.anytype.domain.wallpaper.ObserveWallpaper
import com.anytypeio.anytype.domain.wallpaper.RestoreWallpaper
import com.anytypeio.anytype.presentation.home.OpenObjectNavigation
import com.anytypeio.anytype.presentation.home.navigation
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider
import com.anytypeio.anytype.presentation.navigation.DeepLinkToObjectDelegate
import com.anytypeio.anytype.presentation.notifications.NotificationAction
import com.anytypeio.anytype.presentation.notifications.NotificationActionDelegate
@ -66,7 +67,8 @@ class MainViewModel(
private val notificator: SystemNotificationService,
private val notificationActionDelegate: NotificationActionDelegate,
private val deepLinkToObjectDelegate: DeepLinkToObjectDelegate,
private val awaitAccountStartManager: AwaitAccountStartManager
private val awaitAccountStartManager: AwaitAccountStartManager,
private val membershipProvider: MembershipProvider
) : ViewModel(),
NotificationActionDelegate by notificationActionDelegate,
DeepLinkToObjectDelegate by deepLinkToObjectDelegate {
@ -114,6 +116,14 @@ class MainViewModel(
}
}
}
viewModelScope.launch {
membershipProvider.activeTier().collect { result ->
updateUserProperties(
analytics = analytics,
userProperty = UserProperty.Tier(result.value)
)
}
}
}
private suspend fun handleNotification(event: Notification.Event) {

View file

@ -17,6 +17,7 @@ import com.anytypeio.anytype.domain.search.RelationsSubscriptionManager
import com.anytypeio.anytype.domain.spaces.SpaceDeletedStatusWatcher
import com.anytypeio.anytype.domain.wallpaper.ObserveWallpaper
import com.anytypeio.anytype.domain.wallpaper.RestoreWallpaper
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider
import com.anytypeio.anytype.presentation.navigation.DeepLinkToObjectDelegate
import com.anytypeio.anytype.presentation.notifications.NotificationActionDelegate
import com.anytypeio.anytype.presentation.notifications.NotificationsProvider
@ -40,7 +41,8 @@ class MainViewModelFactory @Inject constructor(
private val notificator: SystemNotificationService,
private val notificationActionDelegate: NotificationActionDelegate,
private val deepLinkToObjectDelegate: DeepLinkToObjectDelegate,
private val awaitAccountStartManager: AwaitAccountStartManager
private val awaitAccountStartManager: AwaitAccountStartManager,
private val membershipProvider: MembershipProvider
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
@ -63,6 +65,7 @@ class MainViewModelFactory @Inject constructor(
notificator = notificator,
notificationActionDelegate = notificationActionDelegate,
deepLinkToObjectDelegate = deepLinkToObjectDelegate,
awaitAccountStartManager = awaitAccountStartManager
awaitAccountStartManager = awaitAccountStartManager,
membershipProvider = membershipProvider
) as T
}

View file

@ -3,10 +3,10 @@ package com.anytypeio.anytype.presentation.membership.provider
import com.anytypeio.anytype.core_models.Command
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.core_utils.ext.formatToDateString
import com.anytypeio.anytype.domain.account.AwaitAccountStartManager
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.misc.DateProvider
import com.anytypeio.anytype.domain.misc.LocaleProvider
import com.anytypeio.anytype.domain.workspace.MembershipChannel
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
@ -25,13 +25,15 @@ import timber.log.Timber
interface MembershipProvider {
fun status(): Flow<MembershipStatus>
fun activeTier(): Flow<TierId>
class Default(
private val dispatchers: AppCoroutineDispatchers,
private val membershipChannel: MembershipChannel,
private val awaitAccountStartManager: AwaitAccountStartManager,
private val localeProvider: LocaleProvider,
private val repo: BlockRepository
private val repo: BlockRepository,
private val dateProvider: DateProvider
) : MembershipProvider {
@OptIn(ExperimentalCoroutinesApi::class)
@ -48,6 +50,33 @@ interface MembershipProvider {
.flowOn(dispatchers.io)
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun activeTier(): Flow<TierId> {
return awaitAccountStartManager.isStarted().flatMapLatest { isStarted ->
if (isStarted) {
buildActiveTierFlow(
initial = proceedWithGettingMembership()
)
} else {
emptyFlow()
}
}.catch { e -> Timber.e(e) }
.flowOn(dispatchers.io)
}
private fun buildActiveTierFlow(
initial: Membership?
): Flow<TierId> {
return membershipChannel
.observe()
.scan(initial) { _, events ->
events.lastOrNull()?.membership
}.filterNotNull()
.map { membership ->
TierId(membership.tier)
}
}
private fun buildStatusFlow(
initial: Membership?
): Flow<MembershipStatus> {
@ -86,6 +115,11 @@ interface MembershipProvider {
membership: Membership,
tiers: List<MembershipTierData>
): MembershipStatus {
val formattedDateEnds = dateProvider.formatToDateString(
timestamp = membership.dateEnds,
pattern = DATE_FORMAT,
locale = localeProvider.locale()
)
return MembershipStatus(
activeTier = TierId(membership.tier),
status = membership.membershipStatusModel,
@ -93,10 +127,7 @@ interface MembershipProvider {
paymentMethod = membership.paymentMethod,
anyName = membership.nameServiceName,
tiers = tiers,
formattedDateEnds = membership.dateEnds.formatToDateString(
pattern = DATE_FORMAT,
locale = localeProvider.locale()
),
formattedDateEnds = formattedDateEnds,
userEmail = membership.userEmail
)
}

View file

@ -3,15 +3,20 @@ package com.anytypeio.anytype.presentation.widgets.collection
import android.text.format.DateUtils
import com.anytypeio.anytype.core_models.TimeInMillis
import com.anytypeio.anytype.core_models.TimeInSeconds
import com.anytypeio.anytype.core_utils.ext.formatToDateString
import com.anytypeio.anytype.domain.misc.DateProvider
import com.anytypeio.anytype.domain.misc.DateType
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import javax.inject.Inject
import timber.log.Timber
class DateProviderImpl @Inject constructor() : DateProvider {
@ -112,6 +117,16 @@ class DateProviderImpl @Inject constructor() : DateProvider {
// Convert the local start of the day back to seconds
return calendarLocal.timeInMillis / 1000
}
override fun formatToDateString(timestamp: Long, pattern: String, locale: Locale): String {
try {
val formatter = SimpleDateFormat(pattern, locale)
return formatter.format(Date(timestamp))
} catch (e: Exception) {
Timber.e(e,"Error formatting timestamp to date string")
return ""
}
}
}