Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-23 14:34:39 +04:00
parent 8922d23eec
commit f3c2d98214
7 changed files with 962 additions and 30 deletions

View file

@ -174,6 +174,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
installAppTheme()
val splashScreen = installSplashScreen()
TangemLogger.i("Splash screen installed")
enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.auto(
@ -350,6 +351,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
TangemLogger.i("onNewIntent: data=${intent.data}, extras=${intent.extras?.keySet()}")
val isFromPush = intent.extras?.containsKey(OPENED_FROM_GCM_PUSH) == true
if (isFromPush) {

View file

@ -270,22 +270,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
}
}
private fun updateLogFiles() {
appLogsStore.deleteOldLogsFile()
if (!BuildConfig.TESTER_MENU_ENABLED) {
appLogsStore.deleteLastLogFile()
}
// Temporally logs are not saved
// scope.launch {
// if (!appPreferencesStore.getSyncOrDefault(WAS_LOG_FILE_CLEARED, false)) {
// appLogsStore.deleteLastLogFile()
// appPreferencesStore.store(WAS_LOG_FILE_CLEARED, true)
// }
// }
}
/**
* Initialize components that need to be initialized before [super.onCreate] is called
*/
@ -389,6 +373,22 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
)
}
private fun updateLogFiles() {
appLogsStore.deleteOldLogsFile()
if (!BuildConfig.TESTER_MENU_ENABLED) {
appLogsStore.deleteLastLogFile()
}
// Temporarily logs are not saved
// scope.launch {
// if (!appPreferencesStore.getSyncOrDefault(WAS_LOG_FILE_CLEARED, false)) {
// appLogsStore.deleteLastLogFile()
// appPreferencesStore.store(WAS_LOG_FILE_CLEARED, true)
// }
// }
}
override fun newImageLoader(): ImageLoader {
return createCoilImageLoader(
context = this,

View file

@ -149,6 +149,7 @@ internal class MainViewModel @Inject constructor(
// await while initial route stack is initialized
appRouterConfig.initializedState.first { it }
TangemLogger.withTag("MainActivity").i("Splash screen dismissed")
isSplashScreenShown = false
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.decompose.navigation.Router
import com.tangem.tap.routing.configurator.AppRouterConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.R
import kotlinx.coroutines.CoroutineScope
@ -19,6 +20,8 @@ internal class ProxyAppRouter(
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : AppRouter {
private val logger = TangemLogger.withTag("AppRouter")
private val routerScope: CoroutineScope
get() = requireNotNull(config.routerScope) {
"Router scope is not set in config"
@ -47,12 +50,8 @@ internal class ProxyAppRouter(
}
override fun replaceAll(vararg routes: AppRoute, onComplete: (isSuccess: Boolean) -> Unit) {
safeNavigate(onComplete, message = "Replace all routes with $routes") {
runCatching {
innerRouter.replaceAll(*routes, onComplete = onComplete)
}.getOrElse {
TangemLogger.e("Error", it)
}
safeNavigate(onComplete, message = "Replace all routes with ${routes.toList().ifEmpty { "<empty>" }}") {
innerRouter.replaceAll(*routes, onComplete = onComplete)
}
}
@ -76,21 +75,20 @@ internal class ProxyAppRouter(
private fun safeNavigate(onComplete: (isSuccess: Boolean) -> Unit, message: String, block: () -> Unit) {
routerScope.launch(dispatchers.mainImmediate) {
TangemLogger.i(message)
logger.i(message)
try {
block()
} catch (e: Throwable) {
TangemLogger.e("Error", e)
onComplete(false)
}
runSuspendCatching(block = { block() })
.onFailure { throwable ->
logger.e(messageString = "Error", throwable = throwable)
onComplete(false)
}
}
}
override fun defaultCompletionHandler(isSuccess: Boolean, errorMessage: String) {
if (!isSuccess) {
analyticsExceptionHandler.sendException(ExceptionAnalyticsEvent(RuntimeException(errorMessage)))
TangemLogger.w(errorMessage)
logger.w(errorMessage)
with(receiver = config.snackbarHandler ?: return) {
showSnackbar(

View file

@ -0,0 +1,245 @@
package com.tangem.tap.routing
import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.decompose.navigation.Router
import com.tangem.tap.common.SnackbarHandler
import com.tangem.tap.routing.configurator.AppRouterConfig
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ProxyAppRouterTest {
private val innerRouter = mockk<Router>(relaxed = true)
private val snackbarHandler = mockk<SnackbarHandler>(relaxed = true)
private val analyticsExceptionHandler = mockk<AnalyticsExceptionHandler>(relaxed = true)
private val dispatchers = TestingCoroutineDispatcherProvider()
private val config = mockk<AppRouterConfig>(relaxed = true) {
every { componentRouter } returns innerRouter
every { stack } returns listOf(AppRoute.Wallet)
every { snackbarHandler } returns this@ProxyAppRouterTest.snackbarHandler
every { initializedState } returns MutableStateFlow(true)
}
@AfterEach
fun tearDown() {
clearMocks(innerRouter, snackbarHandler, analyticsExceptionHandler, config)
every { config.componentRouter } returns innerRouter
every { config.stack } returns listOf(AppRoute.Wallet)
every { config.snackbarHandler } returns snackbarHandler
every { config.initializedState } returns MutableStateFlow(true)
}
private fun createRouter(routerScope: CoroutineScope): ProxyAppRouter {
every { config.routerScope } returns routerScope
return ProxyAppRouter(config, dispatchers, analyticsExceptionHandler)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Push {
@Test
fun `delegates to inner router`() = runTest {
// Arrange
val router = createRouter(this)
val route = AppRoute.AppSettings
// Act
router.push(route)
// Assert
verify { innerRouter.push(route, any()) }
}
@Test
fun `calls onComplete false when inner router throws`() = runTest {
// Arrange
val router = createRouter(this)
every { innerRouter.push(any(), any()) } throws RuntimeException("Navigation error")
var result: Boolean? = null
// Act
router.push(AppRoute.AppSettings) { result = it }
// Assert
assertThat(result).isFalse()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ReplaceCurrent {
@Test
fun `delegates to inner router`() = runTest {
// Arrange
val router = createRouter(this)
val route = AppRoute.AppSettings
// Act
router.replaceCurrent(route)
// Assert
verify { innerRouter.replaceCurrent(route, any()) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ReplaceAll {
@Test
fun `delegates to inner router`() = runTest {
// Arrange
val router = createRouter(this)
val route = AppRoute.Wallet
// Act
router.replaceAll(route)
// Assert
verify { innerRouter.replaceAll(route, onComplete = any()) }
}
@Test
fun `catches exception silently via safeNavigate`() = runTest {
// Arrange
val router = createRouter(this)
every { innerRouter.replaceAll(*anyVararg(), onComplete = any()) } throws RuntimeException("Error")
// Act
val actual = runCatching { router.replaceAll(AppRoute.Wallet) }.isSuccess
// Assert
assertThat(actual).isTrue()
verify { innerRouter.replaceAll(AppRoute.Wallet, onComplete = any()) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Pop {
@Test
fun `delegates to inner router`() = runTest {
// Arrange
val router = createRouter(this)
// Act
router.pop()
// Assert
verify { innerRouter.pop(any()) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class PopTo {
@Test
fun `delegates to inner router with route`() = runTest {
// Arrange
val router = createRouter(this)
val route = AppRoute.Wallet
// Act
router.popTo(route)
// Assert
verify { innerRouter.popTo(route, any()) }
}
@Test
fun `delegates to inner router with routeClass`() = runTest {
// Arrange
val router = createRouter(this)
// Act
router.popTo(AppRoute.Wallet::class)
// Assert
verify { innerRouter.popTo(AppRoute.Wallet::class, any()) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class DefaultCompletionHandler {
@Test
fun `does nothing on success`() {
// Arrange
val router = createRouter(mockk())
// Act
router.defaultCompletionHandler(isSuccess = true, errorMessage = "error")
// Assert
verify(exactly = 0) { analyticsExceptionHandler.sendException(any()) }
verify(exactly = 0) { snackbarHandler.showSnackbar(text = any<Int>(), buttonTitle = any(), action = any()) }
}
@Test
fun `sends analytics and shows snackbar on failure`() {
// Arrange
val router = createRouter(mockk())
// Act
router.defaultCompletionHandler(isSuccess = false, errorMessage = "Navigation failed")
// Assert
verify { analyticsExceptionHandler.sendException(any<ExceptionAnalyticsEvent>()) }
verify { snackbarHandler.showSnackbar(text = any<Int>(), buttonTitle = any(), action = any()) }
}
@Test
fun `sends analytics without snackbar when handler is null`() {
// Arrange
every { config.snackbarHandler } returns null
val router = createRouter(mockk())
// Act
router.defaultCompletionHandler(isSuccess = false, errorMessage = "Navigation failed")
// Assert
verify { analyticsExceptionHandler.sendException(any<ExceptionAnalyticsEvent>()) }
verify(exactly = 0) { snackbarHandler.showSnackbar(text = any<Int>(), buttonTitle = any(), action = any()) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Stack {
@Test
fun `returns config stack`() {
// Arrange
val router = createRouter(mockk())
val expected = listOf(AppRoute.Wallet)
// Act
val actual = router.stack
// Assert
assertThat(actual).isEqualTo(expected)
}
}
}