From 75bf8642d730676299fc3f756ed9b1ca8264107c Mon Sep 17 00:00:00 2001 From: Joseph Sanjaya Date: Wed, 27 Aug 2025 20:22:52 +0700 Subject: [PATCH 1/2] feat(#225): Send sync duration in Firebase Analytics as a custom event and in Settings row This commit introduces analytics tracking for the sync process. It includes: - `TimeProvider`: A utility class for getting the current time, allowing for easier testing. - `AnalyticsSource`: An interface for logging analytics events. - `FirebaseAnalyticsSource`: An implementation of `AnalyticsSource` that uses Firebase Analytics. - `SyncAnalyticsRepository`: A repository to manage and persist sync-related analytics data, and log events. - `startSync()`: Records the start time of a sync. - `stopSync()`: Records the stop time of a sync and accumulates the duration if the sync is paused/resumed. - `completeSync()`: Persists the total sync duration, end timestamp, and a unique ID. It then logs a "user_did_complete_sync" event with these details and the current block height. - `getLastSyncMetadata()`: Retrieves the metadata of the last completed sync. - Unit tests for the new classes. - Integration of `SyncAnalyticsRepository` into `SyncManager` to call the respective methods at the start, stop, and completion of the sync process. - `FakeSharedPreferences`: A testing utility for mocking SharedPreferences. --- .../repository/SyncAnalyticsRepository.kt | 100 +++++++++++++ .../data/source/AnalyticsSource.kt | 6 + .../data/source/FirebaseAnalyticsSource.kt | 33 +++++ .../data/source/PeerManagerSource.kt | 17 +++ .../tools/manager/SyncManager.java | 13 +- .../ui/screens/home/SettingsEvent.kt | 4 +- .../ui/screens/home/SettingsState.kt | 4 +- .../home/composable/HomeSettingDrawerSheet.kt | 13 +- .../java/com/brainwallet/util/TimeProvider.kt | 12 ++ .../repository/SyncAnalyticsRepositoryTest.kt | 140 ++++++++++++++++++ .../source/FirebaseAnalyticsSourceTest.kt | 46 ++++++ .../data/source/PeerManagerSourceTest.kt | 42 ++++++ .../brainwallet/util/FakeSharedPreferences.kt | 107 +++++++++++++ .../com/brainwallet/util/TimeProviderTest.kt | 16 ++ 14 files changed, 547 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/brainwallet/data/repository/SyncAnalyticsRepository.kt create mode 100644 app/src/main/java/com/brainwallet/data/source/AnalyticsSource.kt create mode 100644 app/src/main/java/com/brainwallet/data/source/FirebaseAnalyticsSource.kt create mode 100644 app/src/main/java/com/brainwallet/data/source/PeerManagerSource.kt create mode 100644 app/src/main/java/com/brainwallet/util/TimeProvider.kt create mode 100644 app/src/test/java/com/brainwallet/data/repository/SyncAnalyticsRepositoryTest.kt create mode 100644 app/src/test/java/com/brainwallet/data/source/FirebaseAnalyticsSourceTest.kt create mode 100644 app/src/test/java/com/brainwallet/data/source/PeerManagerSourceTest.kt create mode 100644 app/src/test/java/com/brainwallet/util/FakeSharedPreferences.kt create mode 100644 app/src/test/java/com/brainwallet/util/TimeProviderTest.kt diff --git a/app/src/main/java/com/brainwallet/data/repository/SyncAnalyticsRepository.kt b/app/src/main/java/com/brainwallet/data/repository/SyncAnalyticsRepository.kt new file mode 100644 index 00000000..735bde65 --- /dev/null +++ b/app/src/main/java/com/brainwallet/data/repository/SyncAnalyticsRepository.kt @@ -0,0 +1,100 @@ +package com.brainwallet.data.repository + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.brainwallet.data.source.AnalyticsSource +import com.brainwallet.data.source.PeerManagerSource +import org.koin.core.annotation.Single +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID + +@Single +class SyncAnalyticsRepository( + private val analyticsSource: AnalyticsSource, + private val peerManagerSource: PeerManagerSource, + private val prefs: SharedPreferences, +) { + fun startSync() { + // Record the start time of the current sync segment. + prefs.edit { + putLong(KEY_CURRENT_SYNC_START_TIMESTAMP, peerManagerSource.getLastBlockTimestamp()) + } + } + + fun stopSync() { + val start = prefs.getLong(KEY_CURRENT_SYNC_START_TIMESTAMP, 0L) + if (start != 0L) { + val duration = peerManagerSource.getLastBlockTimestamp() - start + val accumulated = prefs.getLong(KEY_ACCUMULATED_DURATION, 0L) + prefs.edit { + putLong(KEY_ACCUMULATED_DURATION, accumulated + duration) + remove(KEY_CURRENT_SYNC_START_TIMESTAMP) + } + } + } + + fun completeSync() { + // Capture the duration of the final segment. + stopSync() + + val totalDuration = prefs.getLong(KEY_ACCUMULATED_DURATION, 0L) + if (totalDuration == 0L) return + + val endTimestamp = peerManagerSource.getLastBlockTimestamp() + val endBlockHeight = peerManagerSource.getCurrentBlockHeight() + val uuid = UUID.randomUUID().toString() + val totalDurationInMillis = totalDuration * 1000 + + prefs.edit { + putString(KEY_LAST_UUID, uuid) + putLong(KEY_LAST_DURATION, totalDurationInMillis) + putLong(KEY_LAST_END, endTimestamp) + remove(KEY_ACCUMULATED_DURATION) + } + + val params = mapOf( + KEY_UUID to uuid, + KEY_DURATION_MILLIS to (totalDurationInMillis), + KEY_END_TIMESTAMP to endTimestamp, + KEY_END_BLOCK_HEIGHT to endBlockHeight + ) + analyticsSource.logEventWithParams(KEY_EVENT, params) + } + + fun getLastSyncMetadata(): SyncMetadata? { + val uuid = prefs.getString(KEY_LAST_UUID, null) ?: return null + val duration = prefs.getLong(KEY_LAST_DURATION, 0L) + val end = prefs.getLong(KEY_LAST_END, 0L) + return SyncMetadata(uuid, duration, end) + } + + data class SyncMetadata( + val uuid: String, + val durationMillis: Long, + val endTimestamp: Long + ) { + fun getFormatted(): String { + val durationSeconds = durationMillis / 1000.0 + val date = Date(endTimestamp * 1000) + val dateFormat = SimpleDateFormat("MMMM dd, yyyy h:mm:ss a", Locale.getDefault()) + val dateString = dateFormat.format(date) + + return "Duration: %.1f seconds\nTimestamp: %s".format(durationSeconds, dateString) + } + } + + companion object { + private const val KEY_CURRENT_SYNC_START_TIMESTAMP = "current_sync_start_timestamp" + private const val KEY_ACCUMULATED_DURATION = "accumulated_sync_duration" + private const val KEY_LAST_UUID = "last_sync_uuid" + private const val KEY_LAST_DURATION = "last_sync_duration" + private const val KEY_LAST_END = "last_sync_end_timestamp" + private const val KEY_DURATION_MILLIS = "duration_millis" + private const val KEY_END_TIMESTAMP = "end_timestamp" + private const val KEY_END_BLOCK_HEIGHT = "end_block_height" + private const val KEY_UUID = "uuid" + private const val KEY_EVENT = "user_did_complete_sync" + } +} diff --git a/app/src/main/java/com/brainwallet/data/source/AnalyticsSource.kt b/app/src/main/java/com/brainwallet/data/source/AnalyticsSource.kt new file mode 100644 index 00000000..8449f188 --- /dev/null +++ b/app/src/main/java/com/brainwallet/data/source/AnalyticsSource.kt @@ -0,0 +1,6 @@ +package com.brainwallet.data.source + +interface AnalyticsSource { + fun logEvent(event: String) + fun logEventWithParams(event: String, params: Map) +} diff --git a/app/src/main/java/com/brainwallet/data/source/FirebaseAnalyticsSource.kt b/app/src/main/java/com/brainwallet/data/source/FirebaseAnalyticsSource.kt new file mode 100644 index 00000000..0360d489 --- /dev/null +++ b/app/src/main/java/com/brainwallet/data/source/FirebaseAnalyticsSource.kt @@ -0,0 +1,33 @@ +package com.brainwallet.data.source + +import android.content.Context +import android.os.Bundle +import com.google.firebase.analytics.FirebaseAnalytics +import org.koin.core.annotation.Single + +@Single +class FirebaseAnalyticsSource( + private val context: Context, + private val analytics: FirebaseAnalytics = FirebaseAnalytics.getInstance(context) +) : AnalyticsSource { + + override fun logEvent(event: String) { + analytics.logEvent(event, null) + } + + override fun logEventWithParams(event: String, params: Map) { + val bundle = Bundle().apply { + params.forEach { (key, value) -> + when (value) { + is String -> putString(key, value) + is Int -> putInt(key, value) + is Long -> putLong(key, value) + is Double -> putDouble(key, value) + is Boolean -> putBoolean(key, value) + // add more types if needed + } + } + } + analytics.logEvent(event, bundle) + } +} diff --git a/app/src/main/java/com/brainwallet/data/source/PeerManagerSource.kt b/app/src/main/java/com/brainwallet/data/source/PeerManagerSource.kt new file mode 100644 index 00000000..10eaf13b --- /dev/null +++ b/app/src/main/java/com/brainwallet/data/source/PeerManagerSource.kt @@ -0,0 +1,17 @@ +package com.brainwallet.data.source + +import com.brainwallet.wallet.BRPeerManager +import org.koin.core.annotation.Single + +interface BRPeerManagerProxy { + fun getCurrentBlockHeight(): Int + fun getLastBlockTimestamp(): Long +} + +@Single +class PeerManagerSource( + private val proxy: BRPeerManagerProxy = object : BRPeerManagerProxy { + override fun getCurrentBlockHeight(): Int = BRPeerManager.getCurrentBlockHeight() + override fun getLastBlockTimestamp(): Long = BRPeerManager.getInstance().lastBlockTimestamp + } +) : BRPeerManagerProxy by proxy diff --git a/app/src/main/java/com/brainwallet/tools/manager/SyncManager.java b/app/src/main/java/com/brainwallet/tools/manager/SyncManager.java index ace9dcc1..c33fc2c5 100644 --- a/app/src/main/java/com/brainwallet/tools/manager/SyncManager.java +++ b/app/src/main/java/com/brainwallet/tools/manager/SyncManager.java @@ -1,5 +1,6 @@ package com.brainwallet.tools.manager; +import static com.brainwallet.data.source.RemoteConfigSource.KEY_FEATURE_SELECTED_PEERS_ENABLED; import static com.brainwallet.tools.manager.BRSharedPrefs.putSyncMetadata; import android.app.AlarmManager; @@ -9,6 +10,8 @@ import android.os.Build; import android.os.Bundle; +import com.brainwallet.data.repository.SyncAnalyticsRepository; +import com.brainwallet.data.source.RemoteConfigSource; import com.brainwallet.tools.listeners.SyncReceiver; import com.brainwallet.tools.util.BRConstants; import com.brainwallet.tools.util.Utils; @@ -16,6 +19,8 @@ import com.brainwallet.presenter.activities.BreadActivity; import com.brainwallet.wallet.BRPeerManager; +import org.koin.java.KoinJavaComponent; + import java.util.concurrent.TimeUnit; import timber.log.Timber; @@ -34,6 +39,10 @@ public static SyncManager getInstance() { private SyncManager() { } + public static SyncAnalyticsRepository getSyncAnalyticsRepository() { + return KoinJavaComponent.get(SyncAnalyticsRepository.class); + } + public synchronized void startSyncingProgressThread(Context app) { try { if (syncTask != null) { @@ -45,6 +54,7 @@ public synchronized void startSyncingProgressThread(Context app) { syncTask = null; } syncTask = new SyncProgressTask(); + getSyncAnalyticsRepository().startSync(); syncTask.start(); updateStartSyncData(app); } catch (IllegalThreadStateException ex) { @@ -59,10 +69,11 @@ private synchronized void updateStartSyncData(Context app) { private synchronized void markFinishedSyncData(Context app) { Timber.d("timber: || SYNC ELAPSE markFinish threadname:%s", Thread.currentThread().getName()); final double progress = BRPeerManager.syncProgress(BRSharedPrefs.getStartHeight(app)); + getSyncAnalyticsRepository().completeSync(); } public synchronized void stopSyncingProgressThread(Context app) { - + getSyncAnalyticsRepository().stopSync(); if (app == null) { Timber.i("timber: || stopSyncingProgressThread: ctx is null"); return; diff --git a/app/src/main/java/com/brainwallet/ui/screens/home/SettingsEvent.kt b/app/src/main/java/com/brainwallet/ui/screens/home/SettingsEvent.kt index cf6b1d31..36ef087f 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/home/SettingsEvent.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/home/SettingsEvent.kt @@ -3,10 +3,12 @@ package com.brainwallet.ui.screens.home import com.brainwallet.data.model.CurrencyEntity import com.brainwallet.data.model.Language +import com.brainwallet.data.repository.SyncAnalyticsRepository + sealed class SettingsEvent { data class OnLoad( val shareAnalyticsDataEnabled: Boolean = false, - val lastSyncMetadata: String? = null, + val lastSyncMetadata: SyncAnalyticsRepository.SyncMetadata? = null, ) : SettingsEvent() object OnSecurityUpdatePinClick : SettingsEvent() object OnSecuritySeedPhraseClick : SettingsEvent() diff --git a/app/src/main/java/com/brainwallet/ui/screens/home/SettingsState.kt b/app/src/main/java/com/brainwallet/ui/screens/home/SettingsState.kt index d0336bff..b1e82c75 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/home/SettingsState.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/home/SettingsState.kt @@ -7,6 +7,8 @@ import com.brainwallet.data.model.Language import com.brainwallet.data.model.toFeeOptions import com.brainwallet.tools.manager.FeeManager +import com.brainwallet.data.repository.SyncAnalyticsRepository + data class SettingsState( val darkMode: Boolean = true, val selectedLanguage: Language = Language.ENGLISH, @@ -19,7 +21,7 @@ data class SettingsState( val languageSelectorBottomSheetVisible: Boolean = false, val fiatSelectorBottomSheetVisible: Boolean = false, val shareAnalyticsDataEnabled: Boolean = false, - val lastSyncMetadata: String? = null, + val lastSyncMetadata: SyncAnalyticsRepository.SyncMetadata? = null, val currentFeeOptions: List = Fee.Default.toFeeOptions(), val selectedFeeType: String = FeeManager.LUXURY ) \ No newline at end of file diff --git a/app/src/main/java/com/brainwallet/ui/screens/home/composable/HomeSettingDrawerSheet.kt b/app/src/main/java/com/brainwallet/ui/screens/home/composable/HomeSettingDrawerSheet.kt index d96cda42..cdb3cd45 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/home/composable/HomeSettingDrawerSheet.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/home/composable/HomeSettingDrawerSheet.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.coroutineScope import androidx.lifecycle.findViewTreeLifecycleOwner import com.brainwallet.R import com.brainwallet.data.model.AppSetting +import com.brainwallet.data.repository.SyncAnalyticsRepository import com.brainwallet.tools.manager.BRSharedPrefs import com.brainwallet.tools.util.BRConstants import com.brainwallet.ui.screens.home.SettingsEvent @@ -48,12 +49,14 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import org.koin.java.KoinJavaComponent.inject @Composable fun HomeSettingDrawerSheet( modifier: Modifier = Modifier, - viewModel: SettingsViewModel = koinInject() + syncAnalyticsRepository: SyncAnalyticsRepository = koinInject(), + viewModel: SettingsViewModel = koinViewModel() ) { val state by viewModel.state.collectAsState() val context = LocalContext.current @@ -61,7 +64,7 @@ fun HomeSettingDrawerSheet( LaunchedEffect(Unit) { viewModel.onEvent(SettingsEvent.OnLoad( shareAnalyticsDataEnabled = BRSharedPrefs.getShareData(context), //currently just load analytics share data here - lastSyncMetadata = BRSharedPrefs.getSyncMetadata(context), //currently just load sync metadata here + lastSyncMetadata = syncAnalyticsRepository.getLastSyncMetadata(), //currently just load sync metadata here )) } @@ -192,10 +195,14 @@ fun HomeSettingDrawerSheet( } item { + val description = state.lastSyncMetadata?.let { + it.getFormatted() + } ?: "No sync metadata" + SettingRowItem( modifier = Modifier.height(100.dp), title = stringResource(R.string.settings_title_sync_metadata), - description = state.lastSyncMetadata ?: "No sync metadata" + description = description ) } diff --git a/app/src/main/java/com/brainwallet/util/TimeProvider.kt b/app/src/main/java/com/brainwallet/util/TimeProvider.kt new file mode 100644 index 00000000..0fdebf7d --- /dev/null +++ b/app/src/main/java/com/brainwallet/util/TimeProvider.kt @@ -0,0 +1,12 @@ +package com.brainwallet.util + +import org.koin.core.annotation.Single + +@Single +class TimeProvider( + private val nowGetter: () -> Long = { System.currentTimeMillis() } +) { + fun now(): Long { + return nowGetter() + } +} diff --git a/app/src/test/java/com/brainwallet/data/repository/SyncAnalyticsRepositoryTest.kt b/app/src/test/java/com/brainwallet/data/repository/SyncAnalyticsRepositoryTest.kt new file mode 100644 index 00000000..e5e37d2b --- /dev/null +++ b/app/src/test/java/com/brainwallet/data/repository/SyncAnalyticsRepositoryTest.kt @@ -0,0 +1,140 @@ +package com.brainwallet.data.repository + +import android.content.SharedPreferences +import com.brainwallet.data.source.AnalyticsSource +import com.brainwallet.data.source.PeerManagerSource +import com.brainwallet.util.FakeSharedPreferences +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.util.UUID + +class SyncAnalyticsRepositoryTest { + private lateinit var prefs: SharedPreferences + + @MockK + private lateinit var analyticsSource: AnalyticsSource + + @MockK + private lateinit var peerManagerSource: PeerManagerSource + + private lateinit var repository: SyncAnalyticsRepository + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + prefs = FakeSharedPreferences() + repository = SyncAnalyticsRepository( + analyticsSource = analyticsSource, + peerManagerSource = peerManagerSource, + prefs = prefs + ) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given sync started when startSync called then start timestamp stored`() { + every { peerManagerSource.getLastBlockTimestamp() } returns 1000L + + repository.startSync() + + val stored = prefs.getLong("current_sync_start_timestamp", -1L) + assert(stored == 1000L) { "expected 1000L but was $stored" } + } + + @Test + fun `given sync started and stopped when stopSync called then duration accumulated`() { + prefs.edit().putLong("current_sync_start_timestamp", 1000L).apply() + prefs.edit().putLong("accumulated_sync_duration", 200L).apply() + every { peerManagerSource.getLastBlockTimestamp() } returns 1500L + + repository.stopSync() + + val accumulated = prefs.getLong("accumulated_sync_duration", 0L) + val startRemoved = prefs.getLong("current_sync_start_timestamp", -1L) + assert(accumulated == 700L) { "expected 700L but was $accumulated" } + assert(startRemoved == -1L) { "expected current_sync_start_timestamp to be removed" } + } + + @Test + fun `given sync never started when stopSync called then nothing stored`() { + prefs.edit().putLong("current_sync_start_timestamp", 0L).apply() + + repository.stopSync() + + val accumulated = prefs.getLong("accumulated_sync_duration", 0L) + assert(accumulated == 0L) { "expected no accumulation but was $accumulated" } + } + + @Test + fun `given accumulated duration when completeSync called then metadata persisted and event logged`() { + prefs.edit().putLong("accumulated_sync_duration", 5000L).apply() + every { peerManagerSource.getLastBlockTimestamp() } returns 6000L + every { peerManagerSource.getCurrentBlockHeight() } returns 456 + + mockkStatic(UUID::class) + every { UUID.randomUUID().toString() } returns "uuid-123" + + repository.completeSync() + + val lastUuid = prefs.getString("last_sync_uuid", null) + val lastDuration = prefs.getLong("last_sync_duration", 0L) + val lastEnd = prefs.getLong("last_sync_end_timestamp", 0L) + val accumulatedCleared = prefs.getLong("accumulated_sync_duration", -1L) + + assert(lastUuid == "uuid-123") { "expected uuid-123 but was $lastUuid" } + assert(lastDuration == 5000000L) { "expected duration=5000000 but was $lastDuration" } + assert(lastEnd == 6000L) { "expected end=6000 but was $lastEnd" } + assert(accumulatedCleared == -1L) { "expected accumulated_sync_duration removed" } + + val paramsSlot = slot>() + + verify { + analyticsSource.logEventWithParams( + "user_did_complete_sync", + capture(paramsSlot) + ) + } + + val captured = paramsSlot.captured + println("captured: $captured") + + assert(captured["uuid"] == "uuid-123") { "uuid should match" } + assert(captured["duration_millis"] == 5000000L) { "duration should match" } + assert(captured["end_timestamp"] == 6000L) { "end timestamp should match" } + assert(captured["end_block_height"] == 456) { "end block height should match" } + } + + @Test + fun `given metadata stored when getLastSyncMetadata called then correct SyncMetadata returned`() { + prefs.edit() + .putString("last_sync_uuid", "uuid-999") + .putLong("last_sync_duration", 8888L) + .putLong("last_sync_end_timestamp", 9999L) + .apply() + + val metadata = repository.getLastSyncMetadata() + + assert(metadata != null) { "expected metadata to be not null" } + assert(metadata!!.uuid == "uuid-999") { "expected uuid=uuid-999 but was ${metadata.uuid}" } + assert(metadata.durationMillis == 8888L) { "expected duration=8888 but was ${metadata.durationMillis}" } + assert(metadata.endTimestamp == 9999L) { "expected endTimestamp=9999 but was ${metadata.endTimestamp}" } + } + + @Test + fun `given no metadata stored when getLastSyncMetadata called then null returned`() { + val metadata = repository.getLastSyncMetadata() + assert(metadata == null) { "expected metadata to be null" } + } +} diff --git a/app/src/test/java/com/brainwallet/data/source/FirebaseAnalyticsSourceTest.kt b/app/src/test/java/com/brainwallet/data/source/FirebaseAnalyticsSourceTest.kt new file mode 100644 index 00000000..c8d79346 --- /dev/null +++ b/app/src/test/java/com/brainwallet/data/source/FirebaseAnalyticsSourceTest.kt @@ -0,0 +1,46 @@ +package com.brainwallet.data.source + +import android.content.Context +import com.google.firebase.analytics.FirebaseAnalytics +import io.mockk.MockKAnnotations +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class FirebaseAnalyticsSourceTest { + + @RelaxedMockK + private lateinit var context: Context + + @MockK + private lateinit var firebaseAnalytics: FirebaseAnalytics + private lateinit var sut: FirebaseAnalyticsSource + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + sut = FirebaseAnalyticsSource(context, firebaseAnalytics) + } + + @Test + fun `given valid event name when logCustomEvent is called then event should be logged without params`() { + val eventName = "test_event" + + sut.logEvent(eventName) + + verify(exactly = 1) { firebaseAnalytics.logEvent(eventName, null) } + assert(true) { "Expected logEvent to be called with eventName=$eventName and params=null" } + } + + @Test + fun `given valid event name and params when logCustomEventWithParams is called then event should be logged with params`() { + val eventName = "test_event_with_params" + + sut.logEventWithParams(eventName, mapOf()) + + verify(exactly = 1) { firebaseAnalytics.logEvent(eventName, any()) } + assert(true) { "Expected logEvent to be called with eventName=$eventName and provided params" } + } +} diff --git a/app/src/test/java/com/brainwallet/data/source/PeerManagerSourceTest.kt b/app/src/test/java/com/brainwallet/data/source/PeerManagerSourceTest.kt new file mode 100644 index 00000000..a415847b --- /dev/null +++ b/app/src/test/java/com/brainwallet/data/source/PeerManagerSourceTest.kt @@ -0,0 +1,42 @@ +package com.brainwallet.data.source + +import io.mockk.every +import io.mockk.mockk +import org.junit.Before +import org.junit.Test + +class PeerManagerSourceTest { + + private lateinit var proxy: BRPeerManagerProxy + private lateinit var peerManagerSource: PeerManagerSource + + @Before + fun setUp() { + proxy = mockk() + peerManagerSource = PeerManagerSource(proxy) + } + + @Test + fun `Given proxy returns block height When calling getCurrentBlockHeight Then it should return expected block height`() { + val expectedHeight = 12345 + every { proxy.getCurrentBlockHeight() } returns expectedHeight + + val actualHeight = peerManagerSource.getCurrentBlockHeight() + + assert(actualHeight == expectedHeight) { + "Expected block height to be $expectedHeight but was $actualHeight" + } + } + + @Test + fun `Given proxy returns last block timestamp When calling getLastBlockTimestamp Then it should return expected timestamp`() { + val expectedTimestamp = 1699999999L + every { proxy.getLastBlockTimestamp() } returns expectedTimestamp + + val actualTimestamp = peerManagerSource.getLastBlockTimestamp() + + assert(actualTimestamp == expectedTimestamp) { + "Expected last block timestamp to be $expectedTimestamp but was $actualTimestamp" + } + } +} diff --git a/app/src/test/java/com/brainwallet/util/FakeSharedPreferences.kt b/app/src/test/java/com/brainwallet/util/FakeSharedPreferences.kt new file mode 100644 index 00000000..bebdffb3 --- /dev/null +++ b/app/src/test/java/com/brainwallet/util/FakeSharedPreferences.kt @@ -0,0 +1,107 @@ +package com.brainwallet.util + +import android.content.SharedPreferences + +class FakeSharedPreferences : SharedPreferences { + + private val data = mutableMapOf() + private val listeners = mutableSetOf() + + override fun getAll(): MutableMap = data.toMutableMap() + + override fun getString(key: String?, defValue: String?): String? = + data[key] as? String ?: defValue + + override fun getLong(key: String?, defValue: Long): Long = + (data[key] as? Long) ?: defValue + + override fun getInt(key: String?, defValue: Int): Int = + (data[key] as? Int) ?: defValue + + override fun getBoolean(key: String?, defValue: Boolean): Boolean = + (data[key] as? Boolean) ?: defValue + + override fun getFloat(key: String?, defValue: Float): Float = + (data[key] as? Float) ?: defValue + + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + (data[key] as? MutableSet) ?: defValues + + override fun contains(key: String?): Boolean = data.containsKey(key) + + override fun edit(): SharedPreferences.Editor = FakeEditor() + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { + listener?.let { listeners.add(it) } + } + + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { + listeners.remove(listener) + } + + inner class FakeEditor : SharedPreferences.Editor { + private val temp = mutableMapOf() + private val toRemove = mutableSetOf() + private var clear = false + + override fun putString(key: String?, value: String?): SharedPreferences.Editor { + temp[key!!] = value + return this + } + + override fun putLong(key: String?, value: Long): SharedPreferences.Editor { + temp[key!!] = value + return this + } + + override fun putInt(key: String?, value: Int): SharedPreferences.Editor { + temp[key!!] = value + return this + } + + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor { + temp[key!!] = value + return this + } + + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor { + temp[key!!] = value + return this + } + + override fun putStringSet( + key: String?, + values: MutableSet? + ): SharedPreferences.Editor { + temp[key!!] = values + return this + } + + override fun remove(key: String?): SharedPreferences.Editor { + toRemove.add(key!!) + return this + } + + override fun clear(): SharedPreferences.Editor { + clear = true + return this + } + + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + if (clear) { + data.clear() + } + toRemove.forEach { data.remove(it) } + data.putAll(temp) + // notify listeners + (temp.keys + toRemove).forEach { key -> + listeners.forEach { it.onSharedPreferenceChanged(this@FakeSharedPreferences, key) } + } + } + } +} diff --git a/app/src/test/java/com/brainwallet/util/TimeProviderTest.kt b/app/src/test/java/com/brainwallet/util/TimeProviderTest.kt new file mode 100644 index 00000000..1278c010 --- /dev/null +++ b/app/src/test/java/com/brainwallet/util/TimeProviderTest.kt @@ -0,0 +1,16 @@ +package com.brainwallet.util + +import org.junit.Test + +class TimeProviderTest { + + @Test + fun `returns provided nowGetter value`() { + val fixedTime = 123456789L + val timeProvider = TimeProvider { fixedTime } + + val result = timeProvider.now() + + assert(result == fixedTime) { "Expected $fixedTime but got $result" } + } +} From a65686b91f245f3ae6ba42acd6276e2f64cdb25e Mon Sep 17 00:00:00 2001 From: Joseph Sanjaya Date: Sat, 30 Aug 2025 08:39:02 +0700 Subject: [PATCH 2/2] chore(#225): Move SyncMetadata formatting to Formatter class This commit introduces a `Formatter` class within `SyncAnalyticsRepository.SyncMetadata` to handle the formatting of sync metadata. The `getFormatted()` method has been removed from `SyncAnalyticsRepository.SyncMetadata` and its logic is now encapsulated in the `SyncMetadata.Formatter.format()` method. This change improves the separation of concerns and makes the formatting logic more testable. The `HomeSettingDrawerSheet` has been updated to use the new `Formatter` class. Unit tests have been added to `SyncAnalyticsRepositoryTest` to verify the correctness of the `SyncMetadata.Formatter`. --- .../repository/SyncAnalyticsRepository.kt | 18 ++++--- .../home/composable/HomeSettingDrawerSheet.kt | 3 +- .../repository/SyncAnalyticsRepositoryTest.kt | 52 +++++++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/brainwallet/data/repository/SyncAnalyticsRepository.kt b/app/src/main/java/com/brainwallet/data/repository/SyncAnalyticsRepository.kt index 735bde65..3486dd02 100644 --- a/app/src/main/java/com/brainwallet/data/repository/SyncAnalyticsRepository.kt +++ b/app/src/main/java/com/brainwallet/data/repository/SyncAnalyticsRepository.kt @@ -75,13 +75,19 @@ class SyncAnalyticsRepository( val durationMillis: Long, val endTimestamp: Long ) { - fun getFormatted(): String { - val durationSeconds = durationMillis / 1000.0 - val date = Date(endTimestamp * 1000) - val dateFormat = SimpleDateFormat("MMMM dd, yyyy h:mm:ss a", Locale.getDefault()) - val dateString = dateFormat.format(date) + class Formatter( + private val dateFormat: SimpleDateFormat = SimpleDateFormat( + "MMMM dd, yyyy h:mm:ss a", + Locale.getDefault() + ) + ) { + fun format(syncMetadata: SyncMetadata): String { + val durationSeconds = syncMetadata.durationMillis / 1000.0 + val date = Date(syncMetadata.endTimestamp * 1000) + val dateString = dateFormat.format(date) - return "Duration: %.1f seconds\nTimestamp: %s".format(durationSeconds, dateString) + return "Duration: %.1f seconds\nTimestamp: %s".format(durationSeconds, dateString) + } } } diff --git a/app/src/main/java/com/brainwallet/ui/screens/home/composable/HomeSettingDrawerSheet.kt b/app/src/main/java/com/brainwallet/ui/screens/home/composable/HomeSettingDrawerSheet.kt index cdb3cd45..fbb01438 100644 --- a/app/src/main/java/com/brainwallet/ui/screens/home/composable/HomeSettingDrawerSheet.kt +++ b/app/src/main/java/com/brainwallet/ui/screens/home/composable/HomeSettingDrawerSheet.kt @@ -196,7 +196,8 @@ fun HomeSettingDrawerSheet( item { val description = state.lastSyncMetadata?.let { - it.getFormatted() + SyncAnalyticsRepository.SyncMetadata.Formatter() + .format(it) } ?: "No sync metadata" SettingRowItem( diff --git a/app/src/test/java/com/brainwallet/data/repository/SyncAnalyticsRepositoryTest.kt b/app/src/test/java/com/brainwallet/data/repository/SyncAnalyticsRepositoryTest.kt index e5e37d2b..0167e25f 100644 --- a/app/src/test/java/com/brainwallet/data/repository/SyncAnalyticsRepositoryTest.kt +++ b/app/src/test/java/com/brainwallet/data/repository/SyncAnalyticsRepositoryTest.kt @@ -14,6 +14,10 @@ import io.mockk.verify import org.junit.After import org.junit.Before import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone import java.util.UUID class SyncAnalyticsRepositoryTest { @@ -137,4 +141,52 @@ class SyncAnalyticsRepositoryTest { val metadata = repository.getLastSyncMetadata() assert(metadata == null) { "expected metadata to be null" } } + + @Test + fun `given sync metadata when format then return formatted string containing correct duration and timestamp`() { + val fixedDateFormat = SimpleDateFormat("MMMM dd, yyyy h:mm:ss a", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val formatter = SyncAnalyticsRepository.SyncMetadata.Formatter(fixedDateFormat) + + val syncMetadata = SyncAnalyticsRepository.SyncMetadata( + uuid = "1234", + durationMillis = 2500L, + endTimestamp = 1633072800L + ) + + val formattedOutput = formatter.format(syncMetadata) + + assert(formattedOutput.contains("Duration: 2.5 seconds")) { + "Expected formatted string to contain 'Duration: 2.5 seconds' but got '$formattedOutput'" + } + + val expectedDatePart = fixedDateFormat.format(Date(syncMetadata.endTimestamp * 1000)) + assert(formattedOutput.contains(expectedDatePart)) { + "Expected formatted string to contain '$expectedDatePart' but got '$formattedOutput'" + } + } + + @Test + fun `given sync metadata when format then return exactly formatted string with correct structure`() { + val fixedDateFormat = SimpleDateFormat("MMMM dd, yyyy h:mm:ss a", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val formatter = SyncAnalyticsRepository.SyncMetadata.Formatter(fixedDateFormat) + + val syncMetadata = SyncAnalyticsRepository.SyncMetadata( + uuid = "1234", + durationMillis = 2500L, + endTimestamp = 1633072800L + ) + + val actual = formatter.format(syncMetadata) + + val expectedDate = fixedDateFormat.format(Date(syncMetadata.endTimestamp * 1000)) + val expected = "Duration: 2.5 seconds\nTimestamp: $expectedDate" + + assert(actual == expected) { + "Expected exactly '$expected' but got '$actual'" + } + } }