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:
parent
cbf7edf8fa
commit
f97ee286cb
12 changed files with 293 additions and 13 deletions
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue