Skip to content

Commit a000241

Browse files
authored
Add log package and logging test runner (#29)
1 parent 7bd154b commit a000241

File tree

6 files changed

+317
-2
lines changed

6 files changed

+317
-2
lines changed

lib/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ plugins {
1212
}
1313

1414
android {
15-
compileSdk = 35
15+
compileSdk = 36
1616

1717
namespace = "at.bitfire.synctools"
1818

1919
defaultConfig {
2020
minSdk = 23 // Android 6
2121

22-
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
22+
testInstrumentationRunner = "at.bitfire.synctools.LoggingTestRunner"
2323

2424
buildConfigField("String", "version_ical4j", "\"${libs.versions.ical4j.get()}\"")
2525

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools
8+
9+
import android.os.Bundle
10+
import androidx.test.runner.AndroidJUnitRunner
11+
import at.bitfire.synctools.log.LogcatHandler
12+
import java.util.logging.Level
13+
import java.util.logging.Logger
14+
15+
class LoggingTestRunner: AndroidJUnitRunner() {
16+
17+
override fun onCreate(arguments: Bundle?) {
18+
super.onCreate(arguments)
19+
20+
// enable verbose logging during tests
21+
val rootLogger = Logger.getLogger("")
22+
rootLogger.level = Level.ALL
23+
rootLogger.addHandler(LogcatHandler(javaClass.packageName))
24+
}
25+
26+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.log
8+
9+
object ClassNameUtils {
10+
11+
fun shortenClassName(fullClassName: String, classNameFirst: Boolean): String {
12+
// remove $... that is appended for anonymous classes
13+
val withoutSuffix = fullClassName.replace(Regex("\\$.*$"), "")
14+
15+
val idxDot = withoutSuffix.lastIndexOf('.')
16+
if (idxDot == -1)
17+
return withoutSuffix
18+
19+
val packageName = withoutSuffix.substring(0, idxDot)
20+
val className = withoutSuffix.substring(idxDot + 1)
21+
val shortenedPackageName = packageName.removePrefix("at.bitfire")
22+
return if (classNameFirst)
23+
"$className/$shortenedPackageName"
24+
else
25+
"$shortenedPackageName.$className"
26+
}
27+
28+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.log
8+
9+
import android.os.Build
10+
import android.util.Log
11+
import com.google.common.base.Ascii
12+
import java.util.logging.Handler
13+
import java.util.logging.Level
14+
import java.util.logging.LogRecord
15+
16+
/**
17+
* Logging handler that logs to Android logcat.
18+
*
19+
* @param fallbackTag adb tag to use if class name can't be determined
20+
*/
21+
class LogcatHandler(
22+
private val fallbackTag: String
23+
): Handler() {
24+
25+
val logcatFormatter = PlainTextFormatter.LOGCAT
26+
27+
init {
28+
setFormatter(logcatFormatter)
29+
}
30+
31+
override fun publish(r: LogRecord) {
32+
val level = r.level.intValue()
33+
val text = logcatFormatter.format(r)
34+
35+
// log tag has to be truncated to 23 characters on Android <8, see Log documentation
36+
val tagLimited = Build.VERSION.SDK_INT < Build.VERSION_CODES.O
37+
38+
// get class name that calls the logger (or fall back to package name)
39+
val tag = if (r.sourceClassName != null)
40+
ClassNameUtils.shortenClassName(r.sourceClassName, classNameFirst = tagLimited)
41+
else
42+
fallbackTag
43+
44+
val tagOrTruncated = if (tagLimited)
45+
Ascii.truncate(tag, 23, "")
46+
else
47+
tag
48+
49+
when {
50+
level >= Level.SEVERE.intValue() -> Log.e(tagOrTruncated, text, r.thrown)
51+
level >= Level.WARNING.intValue() -> Log.w(tagOrTruncated, text, r.thrown)
52+
level >= Level.CONFIG.intValue() -> Log.i(tagOrTruncated, text, r.thrown)
53+
level >= Level.FINER.intValue() -> Log.d(tagOrTruncated, text, r.thrown)
54+
else -> Log.v(tagOrTruncated, text, r.thrown)
55+
}
56+
}
57+
58+
override fun flush() {}
59+
override fun close() {}
60+
61+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.log
8+
9+
import com.google.common.base.Ascii
10+
import java.io.PrintWriter
11+
import java.io.StringWriter
12+
import java.text.SimpleDateFormat
13+
import java.util.Date
14+
import java.util.Locale
15+
import java.util.logging.Formatter
16+
import java.util.logging.LogRecord
17+
18+
/**
19+
* Logging formatter for logging as formatted plain text.
20+
*/
21+
class PlainTextFormatter(
22+
private val withTime: Boolean,
23+
private val withSource: Boolean,
24+
private val padSource: Int = 0,
25+
private val withException: Boolean,
26+
private val lineSeparator: String?
27+
): Formatter() {
28+
29+
companion object {
30+
31+
/**
32+
* Formatter intended for logcat output.
33+
*/
34+
val LOGCAT = PlainTextFormatter(
35+
withTime = false,
36+
withSource = false,
37+
withException = false,
38+
lineSeparator = null
39+
)
40+
41+
/**
42+
* Formatter intended for file output.
43+
*/
44+
val DEFAULT = PlainTextFormatter(
45+
withTime = true,
46+
withSource = true,
47+
padSource = 35,
48+
withException = true,
49+
lineSeparator = System.lineSeparator()
50+
)
51+
52+
/**
53+
* Maximum length of a log line (estimate).
54+
*/
55+
const val MAX_LENGTH = 10000
56+
57+
}
58+
59+
private val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT)
60+
61+
62+
override fun format(r: LogRecord): String {
63+
val builder = StringBuilder()
64+
65+
if (withTime)
66+
builder .append(timeFormat.format(Date(r.millis)))
67+
.append(" ").append(r.threadID).append(" ")
68+
69+
if (withSource && r.sourceClassName != null) {
70+
val className = ClassNameUtils.shortenClassName(r.sourceClassName, classNameFirst = false)
71+
if (className != r.loggerName) {
72+
val classNameColumn = "[$className] ".padEnd(padSource)
73+
builder.append(classNameColumn)
74+
}
75+
}
76+
77+
builder.append(truncate(r.message))
78+
79+
if (withException && r.thrown != null) {
80+
val indentedStackTrace = stackTrace(r.thrown)
81+
.replace("\n", "\n\t")
82+
.removeSuffix("\t")
83+
builder.append("\n\tEXCEPTION ").append(indentedStackTrace)
84+
}
85+
86+
r.parameters?.let {
87+
for ((idx, param) in it.withIndex()) {
88+
builder.append("\n\tPARAMETER #").append(idx + 1).append(" = ")
89+
90+
val valStr = if (param == null)
91+
"(null)"
92+
else
93+
truncate(param.toString())
94+
builder.append(valStr)
95+
}
96+
}
97+
98+
if (lineSeparator != null)
99+
builder.append(lineSeparator)
100+
101+
return builder.toString()
102+
}
103+
104+
fun shortClassName(className: String): String {
105+
// remove $... that is appended for anonymous classes
106+
val withoutSuffix = className.replace(Regex("\\$.*$"), "")
107+
108+
// shorten all but the last part of the package name
109+
val parts = withoutSuffix.split('.')
110+
val shortened =
111+
if (parts.isNotEmpty()) {
112+
val lastIdx = parts.size - 1
113+
val shortenedParts = parts.mapIndexed { idx, part ->
114+
if (idx == lastIdx)
115+
part
116+
else
117+
part[0]
118+
}
119+
shortenedParts.joinToString(".")
120+
} else
121+
""
122+
123+
return shortened
124+
}
125+
126+
private fun stackTrace(ex: Throwable): String {
127+
val writer = StringWriter()
128+
ex.printStackTrace(PrintWriter(writer))
129+
return writer.toString()
130+
}
131+
132+
private fun truncate(s: String) =
133+
Ascii.truncate(s, MAX_LENGTH, "[…]")
134+
135+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.log
8+
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Test
11+
import java.util.logging.Level
12+
import java.util.logging.LogRecord
13+
14+
class PlainTextFormatterTest {
15+
16+
private val minimum = PlainTextFormatter(
17+
withTime = false,
18+
withSource = false,
19+
padSource = 0,
20+
withException = false,
21+
lineSeparator = null
22+
)
23+
24+
@Test
25+
fun test_format_param_null() {
26+
val result = minimum.format(LogRecord(Level.INFO, "Message").apply {
27+
parameters = arrayOf(null)
28+
})
29+
assertEquals("Message\n\tPARAMETER #1 = (null)", result)
30+
}
31+
32+
@Test
33+
fun test_format_param_object() {
34+
val result = minimum.format(LogRecord(Level.INFO, "Message").apply {
35+
parameters = arrayOf(object {
36+
override fun toString() = "SomeObject[]"
37+
})
38+
})
39+
assertEquals("Message\n\tPARAMETER #1 = SomeObject[]", result)
40+
}
41+
42+
@Test
43+
fun test_format_truncatesMessage() {
44+
val result = minimum.format(LogRecord(Level.INFO, "a".repeat(50000)))
45+
// PlainTextFormatter.MAX_LENGTH is 10,000
46+
assertEquals(10000, result.length)
47+
}
48+
49+
50+
@Test
51+
fun test_shortClassName_Empty() {
52+
assertEquals("", PlainTextFormatter.DEFAULT.shortClassName(""))
53+
}
54+
55+
@Test
56+
fun test_shortClassName_NoDot_Anonymous() {
57+
assertEquals("NoDot", PlainTextFormatter.DEFAULT.shortClassName("NoDot\$Anonymous"))
58+
}
59+
60+
@Test
61+
fun test_shortClassName_MultipleParts() {
62+
assertEquals("a.b.s.l.PlainTextFormatterTest", PlainTextFormatter.DEFAULT.shortClassName(javaClass.name))
63+
}
64+
65+
}

0 commit comments

Comments
 (0)