Updated on 2026-08-14
This commit is contained in:
parent
91c2fb2d58
commit
d718cf557d
18 changed files with 1135 additions and 280 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
36
app/src/main/java/com/tangem/tap/common/log/FileLogWriter.kt
Normal file
36
app/src/main/java/com/tangem/tap/common/log/FileLogWriter.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.tangem.utils.logging
|
||||
|
||||
enum class Severity {
|
||||
Verbose,
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
Assert,
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue