Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-30 15:29:56 +04:00
parent 91c2fb2d58
commit d718cf557d
18 changed files with 1135 additions and 280 deletions

View file

@ -384,7 +384,6 @@ dependencies {
implementation(deps.googlePlay.services)
implementation(deps.googlePlay.advertising)
coreLibraryDesugaring(deps.desugar)
implementation(deps.kermit)
implementation(deps.zxing.qrCore)
implementation(deps.coil)
implementation(deps.coil.gif)
@ -405,7 +404,6 @@ dependencies {
implementation(deps.kotlin.serialization)
implementation(deps.reownCore)
implementation(deps.reownWeb3)
implementation(deps.prettyLogger)
implementation(deps.decompose.ext.compose)
implementation(deps.moshi.adapters)
implementation(deps.moshi.kotlin)

View file

@ -164,7 +164,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
appScope.launch {
launch(Dispatchers.IO) {
loadNativeLibraries()
updateLogFiles()
}
}
@ -196,22 +195,6 @@ 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

@ -0,0 +1,36 @@
package com.tangem.tap.common.log
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.utils.logging.Severity
import com.tangem.utils.logging.TangemLogger
/**
* [TangemLogger.LogWriter] that persists log entries to [AppLogsStore].
*
* Only [Severity.Error] and [Severity.Info] are written. The `shouldSanitize` flag is forwarded to
* [AppLogsStore.saveLogMessage], so callers that deliberately log unsanitized content
* (`shouldSanitize = false`) bypass the sanitizer.
*/
internal class FileLogWriter(
private val appLogsStore: AppLogsStore,
) : TangemLogger.LogWriter {
override fun isLoggable(severity: Severity, tag: String): Boolean {
return severity == Severity.Error || severity == Severity.Info
}
override fun write(
severity: Severity,
tag: String,
message: String,
throwable: Throwable?,
shouldSanitize: Boolean,
) {
appLogsStore.saveLogMessage(
tag = tag,
message = message,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.tap.common.log
import android.os.Build
import android.util.Log
import com.tangem.utils.logging.Severity
import com.tangem.utils.logging.TangemLogger
/**
* [TangemLogger.LogWriter] that pretty-prints log entries to Logcat.
*
* Wraps each entry in unicode borders and chunks long messages so that they fit
* Android's per-entry byte limit (~4076 bytes).
*/
internal class LogcatLogWriter : TangemLogger.LogWriter {
override fun write(
severity: Severity,
tag: String,
message: String,
throwable: Throwable?,
shouldSanitize: Boolean,
) {
val priority = severity.toAndroidPriority()
val truncatedTag = tag.truncateForLogcat()
val finalMessage = if (throwable != null) {
"$message\n${Log.getStackTraceString(throwable)}"
} else {
message
}
printBoxed(priority, truncatedTag, finalMessage)
}
private fun printBoxed(priority: Int, tag: String, message: String) {
Log.println(priority, tag, TOP_BORDER)
val bytes = message.toByteArray()
val length = bytes.size
if (length <= CHUNK_SIZE) {
printContent(priority, tag, message)
} else {
var i = 0
while (i < length) {
val count = (length - i).coerceAtMost(CHUNK_SIZE)
printContent(priority, tag, String(bytes, i, count))
i += CHUNK_SIZE
}
}
Log.println(priority, tag, BOTTOM_BORDER)
}
private fun printContent(priority: Int, tag: String, chunk: String) {
chunk.split(System.lineSeparator()).forEach { line ->
Log.println(priority, tag, "$HORIZONTAL_LINE $line")
}
}
@Suppress("MagicNumber")
private fun String.truncateForLogcat(): String {
// Tag length limit was removed in API 26.
return if (length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) {
this
} else {
substring(0, MAX_TAG_LENGTH)
}
}
private fun Severity.toAndroidPriority(): Int = when (this) {
Severity.Verbose -> Log.VERBOSE
Severity.Debug -> Log.DEBUG
Severity.Info -> Log.INFO
Severity.Warn -> Log.WARN
Severity.Error -> Log.ERROR
Severity.Assert -> Log.ASSERT
}
private companion object {
// Android's max per-entry byte limit is ~4076; leave headroom for borders.
const val CHUNK_SIZE = 4000
const val MAX_TAG_LENGTH = 23
const val HORIZONTAL_LINE = ""
const val DIVIDER = "────────────────────────────────────────────────────────"
const val TOP_BORDER = "$DIVIDER$DIVIDER"
const val BOTTOM_BORDER = "$DIVIDER$DIVIDER"
}
}

View file

@ -1,17 +1,8 @@
package com.tangem.tap.common.log
import android.os.Build
import android.util.Log
import co.touchlab.kermit.BaseLogger
import co.touchlab.kermit.LogWriter
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import com.orhanobut.logger.AndroidLogAdapter
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.BuildConfig
import java.util.regex.Pattern
import com.orhanobut.logger.Logger as PrettyLogger
/**
* Tangem app logger
@ -24,98 +15,14 @@ class TangemAppLoggerInitializer(
private val appLogsStore: AppLogsStore,
) {
/** Initialize */
fun initialize() {
if (IS_LOG_ENABLED) {
PrettyLogger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy()))
}
Logger.setLogWriters(KermitLogWriter(::finalLogOutput))
}
private fun finalLogOutput(priority: Int, tag: String?, message: String, t: Throwable?) {
if (IS_LOG_ENABLED) {
PrettyLogger.log(priority, tag, message, t)
}
if (PERMITTED_PRIORITY.contains(priority)) {
appLogsStore.saveLogMessage(
tag = tag ?: "TangemAppLogger",
message = message,
)
}
}
@Suppress("BooleanPropertyNaming")
private companion object {
val IS_LOG_ENABLED: Boolean = BuildConfig.LOG_ENABLED
val PERMITTED_PRIORITY = listOf(Log.ERROR, Log.INFO)
}
}
private class KermitLogWriter(
private val finalLogOutput: (priority: Int, tag: String?, message: String, t: Throwable?) -> Unit,
) : LogWriter() {
private val fqcnIgnore = setOf(
LogWriter::class.java.name,
KermitLogWriter::class.java.name,
BaseLogger::class.java.name,
Logger::class.java.name,
TangemLogger::class.java.name,
TangemLogger.TaggedLogger::class.java.name,
)
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
val priority = when (severity) {
Severity.Verbose -> PrettyLogger.VERBOSE
Severity.Debug -> PrettyLogger.DEBUG
Severity.Info -> PrettyLogger.INFO
Severity.Warn -> PrettyLogger.WARN
Severity.Error -> PrettyLogger.ERROR
Severity.Assert -> PrettyLogger.ASSERT
}
val finalTag = if (tag != KERMIT_LOGGER_DEFAULT_TAG) {
tag
} else {
/**
* like in [Logger.debugTree.tag]
*/
@Suppress("UnnecessaryLet", "ThrowingExceptionsWithoutMessageOrCause")
Throwable().stackTrace
.first { it.className !in fqcnIgnore }
.let(::createStackElementTag)
}
finalLogOutput(priority, finalTag, message, throwable)
}
/**
* copy from [Logger.debugTree.createStackElementTag]
*/
@Suppress("MagicNumber")
private fun createStackElementTag(element: StackTraceElement): String? {
var tag = element.className.substringAfterLast('.')
val m = ANONYMOUS_CLASS.matcher(tag)
if (m.find()) {
tag = m.replaceAll("")
}
// Tag length limit was removed in API 26.
return if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) {
tag
} else {
tag.substring(0, MAX_TAG_LENGTH)
}
}
private companion object {
private const val KERMIT_LOGGER_DEFAULT_TAG = ""
/**
* copy from [Logger.debugTree.Companion]
*/
private const val MAX_TAG_LENGTH = 23
private val ANONYMOUS_CLASS = Pattern.compile("(\\$\\d+)+$")
TangemLogger.setLogWriters(
buildList {
if (BuildConfig.LOG_ENABLED) {
add(LogcatLogWriter())
}
add(FileLogWriter(appLogsStore))
},
)
}
}

View file

@ -1,63 +0,0 @@
package com.tangem.tap.common.log
import com.orhanobut.logger.FormatStrategy
import com.orhanobut.logger.LogStrategy
import com.orhanobut.logger.LogcatLogStrategy
class TimberFormatStrategy : FormatStrategy {
private val logStrategy: LogStrategy = LogcatLogStrategy()
override fun log(priority: Int, tag: String?, message: String) {
logTopBorder(priority, tag)
val bytes = message.toByteArray()
val length = bytes.size
if (length <= CHUNK_SIZE) {
logContent(priority, tag, message)
logBottomBorder(priority, tag)
return
}
var i = 0
while (i < length) {
val count = (length - i).coerceAtMost(CHUNK_SIZE)
// create a new String with system's default charset (which is UTF-8 for Android)
logContent(priority, tag, String(bytes, i, count))
i += CHUNK_SIZE
}
logBottomBorder(priority, tag)
}
private fun logTopBorder(logType: Int, tag: String?) {
logChunk(logType, tag, TOP_BORDER)
}
private fun logBottomBorder(logType: Int, tag: String?) {
logChunk(logType, tag, BOTTOM_BORDER)
}
private fun logContent(logType: Int, tag: String?, chunk: String) {
chunk.split(System.lineSeparator()).forEach { line ->
logChunk(logType, tag, "$HORIZONTAL_LINE $line")
}
}
private fun logChunk(priority: Int, tag: String?, chunk: String) {
logStrategy.log(priority, tag, chunk)
}
private companion object {
/**
* Android's max limit for a log entry is ~4076 bytes,
* so 4000 bytes is used as chunk size since default charset
* is UTF-8
*/
private const val CHUNK_SIZE = 4000
const val TOP_LEFT_CORNER = ""
const val BOTTOM_LEFT_CORNER = ""
const val HORIZONTAL_LINE = ""
const val DOUBLE_DIVIDER = "────────────────────────────────────────────────────────"
const val TOP_BORDER = TOP_LEFT_CORNER + DOUBLE_DIVIDER + DOUBLE_DIVIDER
const val BOTTOM_BORDER = BOTTOM_LEFT_CORNER + DOUBLE_DIVIDER + DOUBLE_DIVIDER
}
}

View file

@ -0,0 +1,157 @@
package com.tangem.tap.common.log
import com.google.common.truth.Truth
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.utils.logging.Severity
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class FileLogWriterTest {
private val appLogsStore: AppLogsStore = mockk(relaxUnitFun = true)
private val writer = FileLogWriter(appLogsStore)
// region isLoggable filter
@Test
fun `isLoggable returns true for Error severity`() {
Truth.assertThat(writer.isLoggable(Severity.Error, "tag")).isTrue()
}
@Test
fun `isLoggable returns true for Info severity`() {
Truth.assertThat(writer.isLoggable(Severity.Info, "tag")).isTrue()
}
@Test
fun `isLoggable returns false for Verbose severity`() {
Truth.assertThat(writer.isLoggable(Severity.Verbose, "tag")).isFalse()
}
@Test
fun `isLoggable returns false for Debug severity`() {
Truth.assertThat(writer.isLoggable(Severity.Debug, "tag")).isFalse()
}
@Test
fun `isLoggable returns false for Warn severity`() {
Truth.assertThat(writer.isLoggable(Severity.Warn, "tag")).isFalse()
}
@Test
fun `isLoggable returns false for Assert severity`() {
Truth.assertThat(writer.isLoggable(Severity.Assert, "tag")).isFalse()
}
@Test
fun `isLoggable result is independent of the tag value`() {
Truth.assertThat(writer.isLoggable(Severity.Info, "")).isTrue()
Truth.assertThat(writer.isLoggable(Severity.Info, "anything")).isTrue()
Truth.assertThat(writer.isLoggable(Severity.Debug, "")).isFalse()
Truth.assertThat(writer.isLoggable(Severity.Debug, "anything")).isFalse()
}
// endregion
// region write delegation
@Test
fun `write forwards tag, message, throwable and shouldSanitize to AppLogsStore`() {
// Arrange
val throwable = IllegalStateException("boom")
// Act
writer.write(
severity = Severity.Error,
tag = "MyTag",
message = "error happened",
throwable = throwable,
shouldSanitize = true,
)
// Assert
verify(exactly = 1) {
appLogsStore.saveLogMessage(
tag = "MyTag",
message = "error happened",
throwable = throwable,
shouldSanitize = true,
)
}
}
@Test
fun `write forwards null throwable as null`() {
// Act
writer.write(
severity = Severity.Info,
tag = "Tag",
message = "info",
throwable = null,
shouldSanitize = true,
)
// Assert
verify(exactly = 1) {
appLogsStore.saveLogMessage(
tag = "Tag",
message = "info",
throwable = null,
shouldSanitize = true,
)
}
}
@Test
fun `write forwards shouldSanitize false to AppLogsStore so sanitizer is bypassed`() {
// Act
writer.write(
severity = Severity.Info,
tag = "Tag",
message = "raw payload",
throwable = null,
shouldSanitize = false,
)
// Assert
verify(exactly = 1) {
appLogsStore.saveLogMessage(
tag = "Tag",
message = "raw payload",
throwable = null,
shouldSanitize = false,
)
}
}
@Test
fun `write delegates regardless of severity (filtering is the caller's job)`() {
// The contract: TangemLogger asks isLoggable first; if a caller bypasses that and
// invokes write directly, the writer should still delegate to the store.
Severity.entries.forEach { severity ->
// Act
writer.write(
severity = severity,
tag = "Tag",
message = "msg-$severity",
throwable = null,
shouldSanitize = true,
)
// Assert
verify(exactly = 1) {
appLogsStore.saveLogMessage(
tag = "Tag",
message = "msg-$severity",
throwable = null,
shouldSanitize = true,
)
}
}
}
// endregion
}

View file

@ -0,0 +1,213 @@
package com.tangem.tap.common.log
import android.util.Log
import com.tangem.utils.logging.Severity
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class LogcatLogWriterTest {
private val writer = LogcatLogWriter()
@BeforeEach
fun setUp() {
mockkStatic(Log::class)
every { Log.println(any(), any(), any()) } returns 0
every { Log.getStackTraceString(any()) } returns "STACK"
}
@AfterEach
fun tearDown() {
unmockkStatic(Log::class)
}
// region Severity → Android priority mapping
@Test
fun `Verbose severity maps to Log VERBOSE priority`() {
// Act
writer.write(Severity.Verbose, "Tag", "msg", throwable = null, shouldSanitize = true)
// Assert
verify { Log.println(Log.VERBOSE, "Tag", any()) }
}
@Test
fun `Debug severity maps to Log DEBUG priority`() {
// Act
writer.write(Severity.Debug, "Tag", "msg", throwable = null, shouldSanitize = true)
// Assert
verify { Log.println(Log.DEBUG, "Tag", any()) }
}
@Test
fun `Info severity maps to Log INFO priority`() {
// Act
writer.write(Severity.Info, "Tag", "msg", throwable = null, shouldSanitize = true)
// Assert
verify { Log.println(Log.INFO, "Tag", any()) }
}
@Test
fun `Warn severity maps to Log WARN priority`() {
// Act
writer.write(Severity.Warn, "Tag", "msg", throwable = null, shouldSanitize = true)
// Assert
verify { Log.println(Log.WARN, "Tag", any()) }
}
@Test
fun `Error severity maps to Log ERROR priority`() {
// Act
writer.write(Severity.Error, "Tag", "msg", throwable = null, shouldSanitize = true)
// Assert
verify { Log.println(Log.ERROR, "Tag", any()) }
}
@Test
fun `Assert severity maps to Log ASSERT priority`() {
// Act
writer.write(Severity.Assert, "Tag", "msg", throwable = null, shouldSanitize = true)
// Assert
verify { Log.println(Log.ASSERT, "Tag", any()) }
}
// endregion
// region Box layout
@Test
fun `single-line message is wrapped between top and bottom borders`() {
// Act
writer.write(Severity.Info, "Tag", "hello", throwable = null, shouldSanitize = true)
// Assert
verifySequence {
Log.println(Log.INFO, "Tag", match<String> { it.startsWith("") })
Log.println(Log.INFO, "Tag", "│ hello")
Log.println(Log.INFO, "Tag", match<String> { it.startsWith("") })
}
}
@Test
fun `each line of a multi-line message is printed as a separate logcat entry`() {
// Arrange
val sep = System.lineSeparator()
val message = "first${sep}second${sep}third"
// Act
writer.write(Severity.Debug, "Tag", message, throwable = null, shouldSanitize = true)
// Assert
verifySequence {
Log.println(Log.DEBUG, "Tag", match<String> { it.startsWith("") })
Log.println(Log.DEBUG, "Tag", "│ first")
Log.println(Log.DEBUG, "Tag", "│ second")
Log.println(Log.DEBUG, "Tag", "│ third")
Log.println(Log.DEBUG, "Tag", match<String> { it.startsWith("") })
}
}
// endregion
// region Throwable handling
@Test
fun `throwable is rendered via Log getStackTraceString`() {
// Arrange
val throwable = RuntimeException("boom")
every { Log.getStackTraceString(throwable) } returns "STACK"
// Act
writer.write(Severity.Error, "Tag", "fail", throwable = throwable, shouldSanitize = true)
// Assert
verify(exactly = 1) { Log.getStackTraceString(throwable) }
}
@Test
fun `null throwable does not invoke getStackTraceString`() {
// Act
writer.write(Severity.Info, "Tag", "no throwable", throwable = null, shouldSanitize = true)
// Assert
verify(exactly = 0) { Log.getStackTraceString(any()) }
}
// endregion
// region Chunking of long messages
@Test
fun `message under CHUNK_SIZE bytes produces a single content line`() {
// Arrange
val message = "a".repeat(3999)
// Act
writer.write(Severity.Info, "Tag", message, throwable = null, shouldSanitize = true)
// Assert — top border + 1 content line + bottom border
verify(exactly = 3) { Log.println(Log.INFO, "Tag", any()) }
}
@Test
fun `message exceeding CHUNK_SIZE bytes is split into multiple chunks`() {
// Arrange — 9000 ASCII bytes → chunks of 4000 + 4000 + 1000 = 3 chunks
val message = "a".repeat(9000)
// Act
writer.write(Severity.Info, "Tag", message, throwable = null, shouldSanitize = true)
// Assert — top border + 3 content lines + bottom border
verify(exactly = 5) { Log.println(Log.INFO, "Tag", any()) }
}
// endregion
// region Tag truncation
@Test
fun `tag longer than 23 chars is truncated on legacy Android API stub`() {
// Arrange — in the unit-test Android stub, Build.VERSION.SDK_INT == 0,
// triggering the legacy truncation path.
val longTag = "a".repeat(50)
// Act
writer.write(Severity.Info, longTag, "msg", throwable = null, shouldSanitize = true)
// Assert
verify { Log.println(Log.INFO, "a".repeat(23), any()) }
}
@Test
fun `tag of MAX_TAG_LENGTH chars is not truncated`() {
// Arrange
val tag = "a".repeat(23)
// Act
writer.write(Severity.Info, tag, "msg", throwable = null, shouldSanitize = true)
// Assert
verify { Log.println(Log.INFO, "a".repeat(23), any()) }
}
@Test
fun `short tag is forwarded verbatim`() {
// Act
writer.write(Severity.Info, "Short", "msg", throwable = null, shouldSanitize = true)
// Assert
verify { Log.println(Log.INFO, "Short", any()) }
}
// endregion
}

View file

@ -4,34 +4,6 @@ import android.util.Log
import com.ihsanbal.logging.Level
import com.ihsanbal.logging.LoggingInterceptor
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
@Suppress("MagicNumber")
@Deprecated("Create and provide by DI")
fun createRetrofitInstance(
baseUrl: String,
okHttpBuilder: OkHttpClient.Builder = OkHttpClient.Builder(),
interceptors: List<Interceptor> = emptyList(),
logEnabled: Boolean,
): Retrofit {
okHttpBuilder.apply {
callTimeout(10, TimeUnit.SECONDS)
connectTimeout(20, TimeUnit.SECONDS)
readTimeout(20, TimeUnit.SECONDS)
writeTimeout(20, TimeUnit.SECONDS)
}
interceptors.forEach { okHttpBuilder.addInterceptor(it) }
if (logEnabled) okHttpBuilder.addInterceptor(createNetworkLoggingInterceptor())
return Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(MoshiConverter.networkMoshiConverter)
.client(okHttpBuilder.build())
.build()
}
fun createNetworkLoggingInterceptor(): Interceptor {
return LoggingInterceptor.Builder()

View file

@ -70,12 +70,17 @@ class AppLogsStore @Inject constructor(
}
}
/** Save log [message] */
fun saveLogMessage(tag: String, message: String) {
/**
* Save log [message]. Pass [shouldSanitize] = false to bypass [LogsSanitizer].
* The optional [throwable]'s stack trace is appended verbatim (never sanitized),
* since stack traces routinely contain hex-like sequences that the sanitizer would
* otherwise destroy.
*/
fun saveLogMessage(tag: String, message: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) {
launchWithLock {
createFileIfNotExist()
writeMessage(tag = tag, message)
writeMessage(tag = tag, shouldSanitize = shouldSanitize, throwable = throwable, message)
}
}
@ -84,7 +89,7 @@ class AppLogsStore @Inject constructor(
launchWithLock {
createFileIfNotExist()
writeMessage(tag = tag, *messages)
writeMessage(tag = tag, shouldSanitize = true, throwable = null, messages = messages)
}
}
@ -97,24 +102,16 @@ class AppLogsStore @Inject constructor(
}
}
fun deleteOldLogsFile() {
val file = File(applicationContext.filesDir, LOG_FILE_NAME)
if (file.exists()) file.delete()
}
fun deleteLastLogFile() {
val file = File(applicationContext.filesDir, NEW_LOG_FILE_NAME)
if (file.exists()) file.delete()
}
private fun writeMessage(tag: String, vararg messages: String) {
private fun writeMessage(tag: String, shouldSanitize: Boolean, throwable: Throwable?, vararg messages: String) {
BufferedWriter(FileWriter(logFile, true)).use { writer ->
writer.append(formatter.print(DateTime.now()))
writer.append(": $tag ")
messages.map(LogsSanitizer::sanitize)
.forEach(writer::append)
val processed = if (shouldSanitize) messages.map(LogsSanitizer::sanitize) else messages.toList()
processed.forEach(writer::append)
if (throwable != null) {
writer.newLine()
writer.append(throwable.stackTraceToString().trimEnd())
}
writer.newLine()
}
}
@ -166,8 +163,6 @@ class AppLogsStore @Inject constructor(
private companion object {
const val BUFFER_SIZE = 1024
const val LOG_FILE_NAME = "logs.txt"
const val NEW_LOG_FILE_NAME = "app_logs.txt"
// the only name that we allow to send as email to company addresses
const val PERMITTED_FILE_NAME = "log.txt"
const val PERMITTED_FILE_NAME_ZIP = "log.zip"

View file

@ -23,10 +23,6 @@ dependencies {
implementation(deps.jodatime)
// endregion
// region Logging
implementation(deps.kermit)
// endregion
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)

View file

@ -0,0 +1,13 @@
package com.tangem.utils.logging
/**
* Common contract for application loggers.
*/
internal interface BaseLogger {
fun v(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true)
fun d(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true)
fun i(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true)
fun w(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true)
fun e(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true)
fun a(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true)
}

View file

@ -0,0 +1,35 @@
package com.tangem.utils.logging
import java.util.regex.Pattern
/**
* Resolves a log tag from the call site's class name when the caller didn't supply one
* via [TangemLogger.withTag]. Used by [TangemLogger.write] before dispatching to writers.
*/
internal object LogTagResolver {
private const val FALLBACK_TAG = "TangemAppLogger"
private val ANONYMOUS_CLASS_REGEX: Pattern = Pattern.compile("(\\$\\d+)+$")
private val FQCN_IGNORE = setOf(
LogTagResolver::class.java.name,
TangemLogger::class.java.name,
TangemLogger.TaggedLogger::class.java.name,
// Synthetic class generated for BaseLogger's default-arg trampolines (d$default, etc.).
// Without this, every call that omits default args resolves to BaseLogger.DefaultImpls.
"${BaseLogger::class.java.name}\$DefaultImpls",
)
@Suppress("ThrowingExceptionsWithoutMessageOrCause")
fun resolveTag(): String {
val element = Throwable().stackTrace.firstOrNull { it.className !in FQCN_IGNORE }
?: return FALLBACK_TAG
var tag = element.className.substringAfterLast('.')
val matcher = ANONYMOUS_CLASS_REGEX.matcher(tag)
if (matcher.find()) {
tag = matcher.replaceAll("")
}
return tag
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.utils.logging
enum class Severity {
Verbose,
Debug,
Info,
Warn,
Error,
Assert,
}

View file

@ -1,63 +1,173 @@
package com.tangem.utils.logging
import co.touchlab.kermit.Logger
import java.util.concurrent.CopyOnWriteArrayList
/**
* Application-level logger that wraps Kermit [Logger] with the same API.
* All modules should use [TangemLogger] instead of importing Kermit directly.
* Application-level logger
*/
object TangemLogger {
object TangemLogger : BaseLogger {
fun v(messageString: String, throwable: Throwable? = null) {
Logger.v(messageString, throwable)
private val logWriters = CopyOnWriteArrayList<LogWriter>()
fun setLogWriters(writers: List<LogWriter>) {
logWriters.clear()
logWriters.addAll(writers)
}
fun d(messageString: String, throwable: Throwable? = null) {
Logger.d(messageString, throwable)
fun addLogWriter(writer: LogWriter) {
logWriters.add(writer)
}
fun i(messageString: String, throwable: Throwable? = null) {
Logger.i(messageString, throwable)
override fun v(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Verbose,
tag = null,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
fun w(messageString: String, throwable: Throwable? = null) {
Logger.w(messageString, throwable)
override fun d(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Debug,
tag = null,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
fun e(messageString: String, throwable: Throwable? = null) {
Logger.e(messageString, throwable)
override fun i(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Info,
tag = null,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
fun a(messageString: String, throwable: Throwable? = null) {
Logger.a(messageString, throwable)
override fun w(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Warn,
tag = null,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun e(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Error,
tag = null,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun a(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Assert,
tag = null,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
fun withTag(tag: String): TaggedLogger = TaggedLogger(tag)
class TaggedLogger internal constructor(private val tag: String) {
fun v(messageString: String, throwable: Throwable? = null) {
Logger.withTag(tag).v(messageString, throwable)
}
fun d(messageString: String, throwable: Throwable? = null) {
Logger.withTag(tag).d(messageString, throwable)
}
fun i(messageString: String, throwable: Throwable? = null) {
Logger.withTag(tag).i(messageString, throwable)
}
fun w(messageString: String, throwable: Throwable? = null) {
Logger.withTag(tag).w(messageString, throwable)
}
fun e(messageString: String, throwable: Throwable? = null) {
Logger.withTag(tag).e(messageString, throwable)
}
fun a(messageString: String, throwable: Throwable? = null) {
Logger.withTag(tag).a(messageString, throwable)
private fun write(
severity: Severity,
tag: String?,
message: String,
throwable: Throwable?,
shouldSanitize: Boolean,
) {
val resolvedTag = tag ?: LogTagResolver.resolveTag()
logWriters.forEach { writer ->
if (writer.isLoggable(severity, resolvedTag)) {
writer.write(
severity = severity,
tag = resolvedTag,
message = message,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
}
}
class TaggedLogger internal constructor(private val tag: String) : BaseLogger {
override fun v(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Verbose,
tag = tag,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun d(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Debug,
tag = tag,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun i(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Info,
tag = tag,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun w(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Warn,
tag = tag,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun e(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Error,
tag = tag,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun a(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Assert,
tag = tag,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
}
interface LogWriter {
fun isLoggable(severity: Severity, tag: String): Boolean = true
fun write(severity: Severity, tag: String, message: String, throwable: Throwable?, shouldSanitize: Boolean)
}
}

View file

@ -0,0 +1,100 @@
package com.tangem.utils.logging
import com.google.common.truth.Truth
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class LogTagResolverTest {
@AfterEach
fun tearDown() {
// Clean up shared TangemLogger state used in some cases
TangemLogger.setLogWriters(emptyList())
}
@Test
fun `resolveTag returns the simple class name of the direct caller`() {
// Act
val tag = LogTagResolver.resolveTag()
// Assert
Truth.assertThat(tag).isEqualTo("LogTagResolverTest")
}
@Test
fun `resolveTag does not include package qualifier`() {
// Act
val tag = LogTagResolver.resolveTag()
// Assert
Truth.assertThat(tag).doesNotContain(".")
}
@Test
fun `resolveTag never returns its own class name`() {
// Act
val tag = LogTagResolver.resolveTag()
// Assert
Truth.assertThat(tag).isNotEqualTo("LogTagResolver")
}
@Test
fun `resolveTag skips TangemLogger frames when invoked through it`() {
// Arrange
var capturedTag: String? = null
val writer = object : TangemLogger.LogWriter {
override fun write(
severity: Severity,
tag: String,
message: String,
throwable: Throwable?,
shouldSanitize: Boolean,
) {
capturedTag = tag
}
}
TangemLogger.setLogWriters(listOf(writer))
// Act
TangemLogger.d("via TangemLogger")
// Assert — TangemLogger and LogTagResolver are filtered, leaving the test class
Truth.assertThat(capturedTag).isEqualTo("LogTagResolverTest")
}
@Test
fun `resolveTag is bypassed by TaggedLogger when an explicit tag is supplied`() {
// Arrange
var capturedTag: String? = null
val writer = object : TangemLogger.LogWriter {
override fun write(
severity: Severity,
tag: String,
message: String,
throwable: Throwable?,
shouldSanitize: Boolean,
) {
capturedTag = tag
}
}
TangemLogger.setLogWriters(listOf(writer))
// Act
TangemLogger.withTag("ExplicitTag").d("hi")
// Assert
Truth.assertThat(capturedTag).isEqualTo("ExplicitTag")
}
@Test
fun `resolveTag returns a non-empty string`() {
// Act
val tag = LogTagResolver.resolveTag()
// Assert
Truth.assertThat(tag).isNotEmpty()
}
}

View file

@ -0,0 +1,311 @@
package com.tangem.utils.logging
import com.google.common.truth.Truth
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyOrder
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TangemLoggerTest {
private lateinit var writer: TangemLogger.LogWriter
@BeforeEach
fun setUp() {
writer = mockk(relaxed = true)
every { writer.isLoggable(any(), any()) } returns true
TangemLogger.setLogWriters(listOf(writer))
}
@AfterEach
fun tearDown() {
// Reset singleton state to avoid cross-test pollution
TangemLogger.setLogWriters(emptyList())
}
// region Severity dispatch
@Test
fun `v dispatches Verbose severity to writer`() {
// Act
TangemLogger.v("verbose message")
// Assert
verify(exactly = 1) {
writer.write(Severity.Verbose, any(), "verbose message", null, true)
}
}
@Test
fun `d dispatches Debug severity to writer`() {
// Act
TangemLogger.d("debug message")
// Assert
verify(exactly = 1) {
writer.write(Severity.Debug, any(), "debug message", null, true)
}
}
@Test
fun `i dispatches Info severity to writer`() {
// Act
TangemLogger.i("info message")
// Assert
verify(exactly = 1) {
writer.write(Severity.Info, any(), "info message", null, true)
}
}
@Test
fun `w dispatches Warn severity to writer`() {
// Act
TangemLogger.w("warn message")
// Assert
verify(exactly = 1) {
writer.write(Severity.Warn, any(), "warn message", null, true)
}
}
@Test
fun `e dispatches Error severity to writer`() {
// Act
TangemLogger.e("error message")
// Assert
verify(exactly = 1) {
writer.write(Severity.Error, any(), "error message", null, true)
}
}
@Test
fun `a dispatches Assert severity to writer`() {
// Act
TangemLogger.a("assert message")
// Assert
verify(exactly = 1) {
writer.write(Severity.Assert, any(), "assert message", null, true)
}
}
// endregion
// region Throwable & shouldSanitize propagation
@Test
fun `throwable parameter is forwarded to writer`() {
// Arrange
val throwable = IllegalStateException("boom")
// Act
TangemLogger.e("error", throwable)
// Assert
verify(exactly = 1) {
writer.write(Severity.Error, any(), "error", throwable, true)
}
}
@Test
fun `shouldSanitize flag is forwarded to writer`() {
// Act
TangemLogger.w("not checked", shouldSanitize = false)
// Assert
verify(exactly = 1) {
writer.write(Severity.Warn, any(), "not checked", null, false)
}
}
// endregion
// region setLogWriters / addLogWriter
@Test
fun `setLogWriters replaces previously registered writers`() {
// Arrange
val previous: TangemLogger.LogWriter = mockk(relaxed = true)
every { previous.isLoggable(any(), any()) } returns true
val replacement: TangemLogger.LogWriter = mockk(relaxed = true)
every { replacement.isLoggable(any(), any()) } returns true
TangemLogger.setLogWriters(listOf(previous))
TangemLogger.setLogWriters(listOf(replacement))
// Act
TangemLogger.i("after replace")
// Assert
verify(exactly = 0) { previous.write(any(), any(), any(), any(), any()) }
verify(exactly = 1) {
replacement.write(Severity.Info, any(), "after replace", null, true)
}
}
@Test
fun `addLogWriter appends without removing existing writers`() {
// Arrange
val first: TangemLogger.LogWriter = mockk(relaxed = true)
every { first.isLoggable(any(), any()) } returns true
val second: TangemLogger.LogWriter = mockk(relaxed = true)
every { second.isLoggable(any(), any()) } returns true
TangemLogger.setLogWriters(listOf(first))
TangemLogger.addLogWriter(second)
// Act
TangemLogger.d("broadcast")
// Assert
verify(exactly = 1) { first.write(Severity.Debug, any(), "broadcast", null, true) }
verify(exactly = 1) { second.write(Severity.Debug, any(), "broadcast", null, true) }
}
@Test
fun `setLogWriters with empty list silences all output`() {
// Arrange
TangemLogger.setLogWriters(emptyList())
// Act
TangemLogger.i("nobody listening")
// Assert
verify(exactly = 0) { writer.write(any(), any(), any(), any(), any()) }
}
// endregion
// region isLoggable filtering
@Test
fun `write is skipped when isLoggable returns false`() {
// Arrange
every { writer.isLoggable(any(), any()) } returns false
// Act
TangemLogger.w("filtered out")
// Assert
verify(exactly = 1) { writer.isLoggable(Severity.Warn, any()) }
verify(exactly = 0) { writer.write(any(), any(), any(), any(), any()) }
}
@Test
fun `each writer is filtered independently by its own isLoggable`() {
// Arrange
val accepting: TangemLogger.LogWriter = mockk(relaxed = true)
every { accepting.isLoggable(any(), any()) } returns true
val rejecting: TangemLogger.LogWriter = mockk(relaxed = true)
every { rejecting.isLoggable(any(), any()) } returns false
TangemLogger.setLogWriters(listOf(accepting, rejecting))
// Act
TangemLogger.i("partial")
// Assert
verify(exactly = 1) { accepting.write(Severity.Info, any(), "partial", null, true) }
verify(exactly = 0) { rejecting.write(any(), any(), any(), any(), any()) }
}
@Test
fun `LogWriter isLoggable defaults to true`() {
// Arrange
val realWriter = object : TangemLogger.LogWriter {
override fun write(
severity: Severity,
tag: String,
message: String,
throwable: Throwable?,
shouldSanitize: Boolean,
) = Unit
}
// Act + Assert
Severity.entries.forEach { severity ->
Truth.assertThat(realWriter.isLoggable(severity, "anyTag")).isTrue()
}
}
// endregion
// region Tag resolution
@Test
fun `resolved tag falls back to caller class name when no tag is provided`() {
// Act
TangemLogger.d("no tag")
// Assert
verify(exactly = 1) {
writer.write(Severity.Debug, "TangemLoggerTest", "no tag", null, true)
}
}
@Test
fun `withTag returns a TaggedLogger that uses the supplied tag`() {
// Arrange
val tagged = TangemLogger.withTag("MyFeature")
// Act
tagged.i("hello")
// Assert
verify(exactly = 1) {
writer.write(Severity.Info, "MyFeature", "hello", null, true)
}
}
// endregion
// region TaggedLogger
@Test
fun `TaggedLogger dispatches each severity with its tag, throwable and shouldSanitize flag`() {
// Arrange
val tagged = TangemLogger.withTag("Tag")
val throwable = RuntimeException("oops")
// Act
tagged.v("v")
tagged.d("d")
tagged.i("i")
tagged.w("w")
tagged.e("e", throwable)
tagged.a("a", shouldSanitize = false)
// Assert
verifyOrder {
writer.write(Severity.Verbose, "Tag", "v", null, true)
writer.write(Severity.Debug, "Tag", "d", null, true)
writer.write(Severity.Info, "Tag", "i", null, true)
writer.write(Severity.Warn, "Tag", "w", null, true)
writer.write(Severity.Error, "Tag", "e", throwable, true)
writer.write(Severity.Assert, "Tag", "a", null, false)
}
}
@Test
fun `TaggedLogger respects writer isLoggable filtering`() {
// Arrange
every { writer.isLoggable(any(), any()) } returns false
val tagged = TangemLogger.withTag("Filtered")
// Act
tagged.e("ignored")
// Assert
verify(exactly = 1) { writer.isLoggable(Severity.Error, "Filtered") }
verify(exactly = 0) { writer.write(any(), any(), any(), any(), any()) }
}
// endregion
}

View file

@ -76,7 +76,6 @@ okhttp = "4.9.3"
retrofit = "2.11.0"
retrofitMoshiConverter = "2.9.0"
spongycastleCryptoCore = "1.58.0.0"
kermit = "2.1.0"
viewBindingDelegate = "1.5.9"
xmlShimmer = "1.1.3"
zxingQrCode = "3.5.1"
@ -85,7 +84,6 @@ kotlinDatetime = "0.6.2"
arrow = "1.2.4" # 2.0.1 breaks the build
reownCore = "1.4.11"
reownWeb3 = "1.4.11"
prettyLogger = "2.2.0"
okHttp-prettyLogging = "3.1.0"
chucker = "4.2.0"
mlKit-barcodeScanning = "17.3.0"
@ -282,7 +280,6 @@ spongecastle-core = { module = "com.madgag.spongycastle:core", version.ref = "sp
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-response-type-keeper = { module = "com.squareup.retrofit2:response-type-keeper", version.ref = "retrofit" }
retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofitMoshiConverter" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
viewBindingDelegate = { module = "com.github.kirich1409:viewbindingpropertydelegate-noreflection", version.ref = "viewBindingDelegate" }
xmlShimmer = { module = "com.github.skydoves:androidveil", version.ref = "xmlShimmer" }
zxing-qrCore = { module = "com.google.zxing:core", version.ref = "zxingQrCode" }
@ -292,7 +289,6 @@ arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" }
arrow-fx = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" }
reownCore = { module = "com.reown:android-core", version.ref = "reownCore" }
reownWeb3 = { module = "com.reown:walletkit", version.ref = "reownWeb3" }
prettyLogger = { module = "com.orhanobut:logger", version.ref = "prettyLogger" }
chucker = { module = "com.github.chuckerteam.chucker:library", version.ref = "chucker" }
chuckerStub = { module = "com.github.chuckerteam.chucker:library-no-op", version.ref = "chucker" }
mlKit-barcodeScanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlKit-barcodeScanning" }