From 63d4abe2628a41c2c45bd83749519b0fd82b8beb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:36:38 +0000 Subject: [PATCH 1/4] build: gate the Android layer's unit-test coverage at 100% Adds what the Kotlin unit tests of the Android-specific layer need, and a coverage gate so those tests keep pinning every path of that layer: - JaCoCo 0.8.15 coverage of the debug unit tests (AGP's enableUnitTestCoverage), recording classes that Robolectric's sandbox classloader defines (includeNoLocationClasses). - androidLayerCoverageVerification / verifyAndroidLayerCoverage: fail unless the unit tests cover every line and branch of each class in ch.qos.logback.{classic,core}.android; run in CI after the unit tests, with the jdk11Debug HTML/XML report uploaded. - kotlin-test and mockito-kotlin 6.4.0 for the tests, which the existing Kotlin plugin setup compiles from src/test/kotlin. Kover was tried first, but with AGP 9's variants it never attached the unit test tasks' coverage data to its reports (always 0%), although JaCoCo recorded it correctly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AN8SJNGETCegfBbPy4iV5K --- .github/copilot-instructions.md | 7 +++- .github/workflows/build.yml | 13 ++++++ logback-android/build.gradle | 72 +++++++++++++++++++++++++++++++-- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d7a3fe705..00ff2c7f8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,8 +14,11 @@ sockets, syslog, email). range without guarding them. - **Java level:** Match the source/target compatibility already set in the Gradle build. Do not introduce language features above that level. -- **Tests:** JUnit tests live under `logback-android/src/test/java`. Run with - `./gradlew test`. +- **Tests:** JUnit tests live under `logback-android/src/test/java`, except the + tests of the Android-specific layer (`ch.qos.logback.{classic,core}.android`), + which are written in Kotlin under `logback-android/src/test/kotlin`. Run with + `./gradlew test`. That layer is gated at 100% line and branch coverage + (`./gradlew verifyAndroidLayerCoverage`); keep it there when changing it. - **Upstream parity:** Much of the code mirrors upstream logback. Prefer changes that stay close to upstream behavior and naming so the port remains easy to sync. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ebb44b05..02b4e9cb3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,6 +92,19 @@ jobs: - name: Unit tests (jdk8 variant) run: ./gradlew testJdk8DebugUnitTest + # The Android-specific layer (ch.qos.logback.{classic,core}.android) must + # stay at 100% line and branch coverage (rule in logback-android/build.gradle). + # Reuses the coverage the unit test runs above recorded. + - name: Coverage gate (Android-specific layer) + run: ./gradlew verifyAndroidLayerCoverage androidLayerCoverageReportJdk11Debug + + - name: Upload coverage report + if: ${{ !cancelled() && matrix.java == 21 }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: logback-android/build/reports/jacoco/androidLayerCoverageReportJdk11Debug/ + - name: Upload test reports if: failure() uses: actions/upload-artifact@v7 diff --git a/logback-android/build.gradle b/logback-android/build.gradle index b21fa336c..a5e53a6eb 100644 --- a/logback-android/build.gradle +++ b/logback-android/build.gradle @@ -1,10 +1,12 @@ apply plugin: 'com.android.library' -// Compiles src/main/kotlin. Applied as the standalone plugin (AGP's -// built-in Kotlin support is opted out in gradle.properties) because this -// project needs per-flavor Kotlin bytecode targets; see the afterEvaluate -// block below. +// Compiles src/main/kotlin and src/test/kotlin. Applied as the standalone +// plugin (AGP's built-in Kotlin support is opted out in gradle.properties) +// because this project needs per-flavor Kotlin bytecode targets; see the +// afterEvaluate block below. apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'org.gradle.test-retry' +// Unit-test coverage; see the coverage tasks at the end of this file. +apply plugin: 'jacoco' kotlin { // Library mode: every public declaration in the Kotlin sources must @@ -60,17 +62,29 @@ android { } debug { debuggable true + // Records JaCoCo coverage of the debug unit tests, which the + // Android-layer coverage gate at the end of this file checks. + enableUnitTestCoverage true } } lint { checkAllWarnings = true lintConfig = rootProject.file('gradle/lint.xml') } + testCoverage { + jacocoVersion = '0.8.15' + } testOptions { unitTests { includeAndroidResources = true all { + jacoco { + // Robolectric's sandbox classloader defines the classes + // under test without a code-source location; record them. + includeNoLocationClasses = true + excludes = ['jdk.internal.*'] + } testLogging { events 'failed' showStackTraces = true @@ -123,6 +137,52 @@ afterEvaluate { } } +// Coverage gate for the Android-specific layer (ch.qos.logback.{classic,core}.android): +// its unit tests must cover 100% of its lines and branches, so that they pin +// the behavior of every path. For each debug variant, +// androidLayerCoverageReport writes an HTML/XML report and +// androidLayerCoverageVerification enforces the rule; +// verifyAndroidLayerCoverage runs both variants' checks. +def androidLayerClasses = ['ch/qos/logback/classic/android/**', 'ch/qos/logback/core/android/**'] +def androidLayerCoverage = { JacocoReportBase task, String variant -> + def testTask = tasks.named("test${variant}UnitTest", Test) + task.dependsOn(testTask) + task.executionData.from(testTask.map { it.extensions.getByType(JacocoTaskExtension).destinationFile }) + task.classDirectories.from(files( + tasks.named("compile${variant}Kotlin").flatMap { it.destinationDirectory }, + tasks.named("compile${variant}JavaWithJavac").flatMap { it.destinationDirectory }, + ).asFileTree.matching { include androidLayerClasses }) + task.sourceDirectories.from(files('src/main/kotlin', 'src/main/java')) +} +def androidLayerVerifications = ['Jdk11Debug', 'Jdk8Debug'].collect { variant -> + tasks.register("androidLayerCoverageReport${variant}", JacocoReport) { + group = 'verification' + description = "Reports the ${variant} unit tests' coverage of the Android-specific layer." + androidLayerCoverage(it, variant) + reports { + html.required = true + xml.required = true + } + } + tasks.register("androidLayerCoverageVerification${variant}", JacocoCoverageVerification) { + group = 'verification' + description = "Fails unless the ${variant} unit tests cover every line and branch of the Android-specific layer." + androidLayerCoverage(it, variant) + violationRules { + rule { + element = 'CLASS' + limit { counter = 'LINE'; value = 'COVEREDRATIO'; minimum = 1.0 } + limit { counter = 'BRANCH'; value = 'COVEREDRATIO'; minimum = 1.0 } + } + } + } +} +tasks.register('verifyAndroidLayerCoverage') { + group = 'verification' + description = 'Fails unless the unit tests cover every line and branch of the Android-specific layer.' + dependsOn androidLayerVerifications +} + dependencies { testImplementation('junit:junit:4.13.2') { @@ -134,6 +194,10 @@ dependencies { // Mockito 5 uses the inline mock maker by default, which is required to // mock final/JDK classes on JDK 17+ (older versions fail with NPEs). testImplementation 'org.mockito:mockito-core:5.24.0' + // Kotlin unit tests: kotlin.test assertions (versioned by the Kotlin + // plugin) and Mockito's Kotlin API + testImplementation 'org.jetbrains.kotlin:kotlin-test-junit' + testImplementation 'org.mockito.kotlin:mockito-kotlin:6.4.0' testImplementation 'joda-time:joda-time:2.14.4' testImplementation 'com.icegreen:greenmail:2.1.14' From 45e39c7ed895398a29aba0af6decb1af571ac7c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 06:13:40 +0000 Subject: [PATCH 2/4] fix: restore the Java API and behavior that the Kotlin conversion changed The Kotlin tests of the Android-specific layer, written against the Java code #388 replaced, found these regressions in the conversion: - SystemPropertiesProxy.getBoolean returned a primitive boolean instead of java.lang.Boolean, which breaks Java callers compiled against the old signature (NoSuchMethodError). It again returns Boolean? and passes the SystemProperties result through. - SystemPropertiesProxy.get/getBoolean threw on a null key (Kotlin's parameter check) instead of returning the default; they accept null again. They also invoke SystemProperties with its class as the receiver again, so an IllegalArgumentException from the reflective call propagates as before. - The public members of AndroidContextUtil, LogcatAppender and SQLiteAppender had become final, breaking subclasses that override them (the classes are open, and these were overridable in the published Java API). They are open again; AndroidContextUtil.setupProperties once more tolerates null paths from such overrides. - LogcatAppender dropped (with an error status) events without a logger name; getTag returns String? again, so they're logged with a null tag. - AndroidContextUtil.getNoBackupFilesDirectoryPath lost its pre-Lollipop check. Also removes null checks that could never fail (the database handle once started, the history duration passed to the log cleaner), so that every branch of the layer is reachable by its tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AN8SJNGETCegfBbPy4iV5K --- .../logback/classic/android/LogcatAppender.kt | 18 +++--- .../logback/classic/android/SQLiteAppender.kt | 31 +++++---- .../core/android/AndroidContextUtil.kt | 64 +++++++++++-------- .../core/android/SystemPropertiesProxy.kt | 11 ++-- 4 files changed, 71 insertions(+), 53 deletions(-) diff --git a/logback-android/src/main/kotlin/ch/qos/logback/classic/android/LogcatAppender.kt b/logback-android/src/main/kotlin/ch/qos/logback/classic/android/LogcatAppender.kt index 229750fc5..a880df098 100644 --- a/logback-android/src/main/kotlin/ch/qos/logback/classic/android/LogcatAppender.kt +++ b/logback-android/src/main/kotlin/ch/qos/logback/classic/android/LogcatAppender.kt @@ -46,7 +46,7 @@ public open class LogcatAppender : UnsynchronizedAppenderBase() { * [LayoutWrappingEncoder] with a layout is accepted (issue #376). */ @set:DefaultClass(PatternLayoutEncoder::class) - public var encoder: LayoutWrappingEncoder? = null + public open var encoder: LayoutWrappingEncoder? = null /** * The layout-wrapping encoder for this appender's *logcat* tag @@ -66,7 +66,7 @@ public open class LogcatAppender : UnsynchronizedAppenderBase() { * (in this case: `f.f.foo.foo.bar.Test`). */ @set:DefaultClass(PatternLayoutEncoder::class) - public var tagEncoder: LayoutWrappingEncoder? = null + public open var tagEncoder: LayoutWrappingEncoder? = null /** * Whether to ask Android before logging a message with a specific @@ -74,7 +74,7 @@ public open class LogcatAppender : UnsynchronizedAppenderBase() { * * See [Log.isLoggable](https://developer.android.com/reference/android/util/Log#isLoggable(java.lang.String,%20int)) */ - public var checkLoggable: Boolean = false + public open var checkLoggable: Boolean = false /** * Checks that required parameters are set, and if everything is in order, @@ -168,14 +168,16 @@ public open class LogcatAppender : UnsynchronizedAppenderBase() { * Gets the logcat tag string of a logging event * * @param event logging event to evaluate - * @return the tag string, truncated if max length exceeded + * @return the tag string, truncated if max length exceeded; or `null` if + * the event has no logger name (and there is no tag encoder) */ - protected open fun getTag(event: ILoggingEvent): String { + protected open fun getTag(event: ILoggingEvent): String? { // format tag based on encoder layout; truncate if max length // exceeded (only necessary for isLoggable(), which throws - // IllegalArgumentException) - val tag = this.tagEncoder?.layout?.doLayout(event) ?: event.loggerName - return if (checkLoggable && tag.length > MAX_TAG_LENGTH) { + // IllegalArgumentException). The tag is null for an event without a + // logger name, which logcat accepts. + val tag: String? = this.tagEncoder?.layout?.doLayout(event) ?: event.loggerName + return if (checkLoggable && tag != null && tag.length > MAX_TAG_LENGTH) { "${tag.substring(0, MAX_TAG_LENGTH - 1)}*" } else { tag diff --git a/logback-android/src/main/kotlin/ch/qos/logback/classic/android/SQLiteAppender.kt b/logback-android/src/main/kotlin/ch/qos/logback/classic/android/SQLiteAppender.kt index a63bd6b8e..e384976b4 100644 --- a/logback-android/src/main/kotlin/ch/qos/logback/classic/android/SQLiteAppender.kt +++ b/logback-android/src/main/kotlin/ch/qos/logback/classic/android/SQLiteAppender.kt @@ -40,7 +40,7 @@ import ch.qos.logback.core.util.Duration */ public open class SQLiteAppender : UnsynchronizedAppenderBase() { - private var db: SQLiteDatabase? = null + private lateinit var db: SQLiteDatabase private lateinit var insertPropertiesSQL: String private lateinit var insertExceptionSQL: String private lateinit var insertSQL: String @@ -52,17 +52,17 @@ public open class SQLiteAppender : UnsynchronizedAppenderBase() { * The database name resolver, used to customize the names of the * table names and columns in the database. */ - public var dbNameResolver: DBNameResolver? = null + public open var dbNameResolver: DBNameResolver? = null /** * The absolute path to the SQLite database */ - public var filename: String? = null + public open var filename: String? = null /** * The maximum history in time duration (e.g., "1 day") of records to keep */ - public var maxHistory: String + public open var maxHistory: String get() = maxHistoryDuration?.toString() ?: "" set(value) { maxHistoryDuration = Duration.valueOf(value) @@ -71,7 +71,7 @@ public open class SQLiteAppender : UnsynchronizedAppenderBase() { /** * The maximum history in milliseconds */ - public val maxHistoryMs: Long + public open val maxHistoryMs: Long get() = maxHistoryDuration?.milliseconds ?: 0 /** @@ -79,7 +79,7 @@ public open class SQLiteAppender : UnsynchronizedAppenderBase() { * startup and in between logging events. Reading this property creates * the default log cleaner if none was set. */ - public var logCleaner: SQLiteLogCleaner? = null + public open var logCleaner: SQLiteLogCleaner? = null get() { if (field == null) { field = SQLiteLogCleaner { db, expiry -> @@ -101,7 +101,7 @@ public open class SQLiteAppender : UnsynchronizedAppenderBase() { * @param filename absolute path to database file * @return the file object if a valid file found; otherwise, null */ - public fun getDatabaseFile(filename: String?): File? { + public open fun getDatabaseFile(filename: String?): File? { var dbFile: File? = null if (!filename.isNullOrBlank()) { dbFile = File(filename) @@ -122,7 +122,7 @@ public open class SQLiteAppender : UnsynchronizedAppenderBase() { } val db = try { - dbFile.parentFile?.mkdirs() + dbFile.absoluteFile.parentFile.mkdirs() addInfo("db path: ${dbFile.absolutePath}") SQLiteDatabase.openOrCreateDatabase(dbFile.path, null) } catch (e: SQLiteException) { @@ -155,7 +155,9 @@ public open class SQLiteAppender : UnsynchronizedAppenderBase() { } override fun stop() { - db?.close() + if (this::db.isInitialized) { + db.close() + } this.lastCleanupTime = 0 } @@ -163,7 +165,6 @@ public open class SQLiteAppender : UnsynchronizedAppenderBase() { if (!isStarted) { return } - val db = this.db ?: return try { clearExpiredLogs(db) @@ -190,10 +191,10 @@ public open class SQLiteAppender : UnsynchronizedAppenderBase() { * Removes expired logs from the database */ private fun clearExpiredLogs(db: SQLiteDatabase) { - val maxHistory = this.maxHistoryDuration + val maxHistory = this.maxHistoryDuration ?: return if (lastCheckExpired(maxHistory, this.lastCleanupTime)) { this.lastCleanupTime = clock.currentTimeMillis() - logCleaner?.performLogCleanup(db, maxHistory!!) + logCleaner?.performLogCleanup(db, maxHistory) } } @@ -204,8 +205,8 @@ public open class SQLiteAppender : UnsynchronizedAppenderBase() { * @param lastCleanupTime timestamp (ms) of last cleanup * @return true if last check has expired */ - private fun lastCheckExpired(expiry: Duration?, lastCleanupTime: Long): Boolean { - if (expiry == null || expiry.milliseconds <= 0) { + private fun lastCheckExpired(expiry: Duration, lastCleanupTime: Long): Boolean { + if (expiry.milliseconds <= 0) { return false } val timeDiff = clock.currentTimeMillis() - lastCleanupTime @@ -319,7 +320,6 @@ public open class SQLiteAppender : UnsynchronizedAppenderBase() { if (mergedMap.isEmpty()) { return } - val db = this.db ?: return db.compileStatement(insertPropertiesSQL).use { stmt -> for ((key, value) in mergedMap) { stmt.bindLong(1, eventId) @@ -361,7 +361,6 @@ public open class SQLiteAppender : UnsynchronizedAppenderBase() { } private fun insertThrowable(throwableProxy: IThrowableProxy, eventId: Long) { - val db = this.db ?: return db.compileStatement(insertExceptionSQL).use { stmt -> var tp: IThrowableProxy? = throwableProxy var baseIndex: Short = 0 diff --git a/logback-android/src/main/kotlin/ch/qos/logback/core/android/AndroidContextUtil.kt b/logback-android/src/main/kotlin/ch/qos/logback/core/android/AndroidContextUtil.kt index 79559624d..964b15351 100644 --- a/logback-android/src/main/kotlin/ch/qos/logback/core/android/AndroidContextUtil.kt +++ b/logback-android/src/main/kotlin/ch/qos/logback/core/android/AndroidContextUtil.kt @@ -15,6 +15,7 @@ */ package ch.qos.logback.core.android +import android.annotation.SuppressLint import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build @@ -43,7 +44,7 @@ public open class AndroidContextUtil @JvmOverloads constructor( * * @param loggerContext logger context whose property map is updated */ - public fun setupProperties(loggerContext: Context) { + public open fun setupProperties(loggerContext: Context) { // legacy properties loggerContext.putProperty(CoreConstants.DATA_DIR_KEY, filesDirectoryPath) mountedExternalStorageDirectoryPath?.let { extDir -> @@ -51,10 +52,13 @@ public open class AndroidContextUtil @JvmOverloads constructor( } // Android-version-independent paths to the app-specific external // directories, writable without permissions on API 19+ (issue #181) - externalFilesDirectoryPath.takeIf { it.isNotEmpty() }?.let { extFilesDir -> + // (a subclass written in Java may report these paths as null) + val extFilesDir: String? = externalFilesDirectoryPath + if (!extFilesDir.isNullOrEmpty()) { loggerContext.putProperty(CoreConstants.EXT_FILES_DIR_KEY, extFilesDir) } - externalCacheDirectoryPath.takeIf { it.isNotEmpty() }?.let { extCacheDir -> + val extCacheDir: String? = externalCacheDirectoryPath + if (!extCacheDir.isNullOrEmpty()) { loggerContext.putProperty(CoreConstants.EXT_CACHE_DIR_KEY, extCacheDir) } loggerContext.putProperty(CoreConstants.PACKAGE_NAME_KEY, packageName) @@ -66,7 +70,7 @@ public open class AndroidContextUtil @JvmOverloads constructor( * The path to the external storage directory only if mounted; * `null` if not mounted. */ - public val mountedExternalStorageDirectoryPath: String? + public open val mountedExternalStorageDirectoryPath: String? get() { val state = try { Environment.getExternalStorageState() @@ -92,7 +96,7 @@ public open class AndroidContextUtil @JvmOverloads constructor( * replacement for the deprecated * [android.os.Environment.getExternalStorageDirectory]. */ - public val externalStorageDirectoryPath: String + public open val externalStorageDirectoryPath: String get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { externalFilesDirectoryPath } else { @@ -104,7 +108,7 @@ public open class AndroidContextUtil @JvmOverloads constructor( * The absolute path to the external files directory * ([android.content.Context.getExternalFilesDir]). */ - public val externalFilesDirectoryPath: String + public open val externalFilesDirectoryPath: String get() = absPath(context?.getExternalFilesDir(null)) /** @@ -134,19 +138,19 @@ public open class AndroidContextUtil @JvmOverloads constructor( /** * The absolute path to the application's cache directory. */ - public val cacheDirectoryPath: String + public open val cacheDirectoryPath: String get() = absPath(context?.cacheDir) /** * The absolute path to the application's external cache directory. */ - public val externalCacheDirectoryPath: String + public open val externalCacheDirectoryPath: String get() = absPath(context?.externalCacheDir) /** * The application's package name. */ - public val packageName: String + public open val packageName: String get() = context?.packageName ?: "" /** @@ -157,7 +161,7 @@ public open class AndroidContextUtil @JvmOverloads constructor( * * (example: "/data/data/com.example/files") */ - public val filesDirectoryPath: String + public open val filesDirectoryPath: String get() = absPath(context?.filesDir) /** @@ -166,10 +170,18 @@ public open class AndroidContextUtil @JvmOverloads constructor( * from automatic backup to remote storage by * `android.app.backup.BackupAgent`. * + * This API is only available on SDK 21+. On older versions, this is an + * empty string. + * * (example: "/data/data/com.example/nobackup/files") */ - public val noBackupFilesDirectoryPath: String - get() = absPath(context?.noBackupFilesDir) + public open val noBackupFilesDirectoryPath: String + @SuppressLint("ObsoleteSdkInt") + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + absPath(context?.noBackupFilesDir) + } else { + "" + } /** * The absolute path to the directory on the Android filesystem where @@ -177,7 +189,7 @@ public open class AndroidContextUtil @JvmOverloads constructor( * * (example: "/data/data/com.example/databases") */ - public val databaseDirectoryPath: String + public open val databaseDirectoryPath: String get() = context?.getDatabasePath("x")?.parent ?: "" /** @@ -186,13 +198,13 @@ public open class AndroidContextUtil @JvmOverloads constructor( * @param databaseName name of the target database * @return the absolute path to the database */ - public fun getDatabasePath(databaseName: String): String = + public open fun getDatabasePath(databaseName: String): String = absPath(context?.getDatabasePath(databaseName)) /** * The application's version code, as a string. */ - public val versionCode: String + public open val versionCode: String get() { val pkgInfo = packageInfo ?: return "" return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -206,20 +218,22 @@ public open class AndroidContextUtil @JvmOverloads constructor( /** * The application's version name. */ - public val versionName: String + public open val versionName: String get() = packageInfo?.versionName ?: "" private val packageInfo: PackageInfo? - get() = try { - val packageManager = context?.packageManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager?.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) - } else { - @Suppress("DEPRECATION") - packageManager?.getPackageInfo(packageName, 0) + get() { + val packageManager = context?.packageManager ?: return null + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageInfo(packageName, 0) + } + } catch (e: PackageManager.NameNotFoundException) { + null } - } catch (e: PackageManager.NameNotFoundException) { - null } private fun absPath(file: File?): String = file?.absolutePath ?: "" diff --git a/logback-android/src/main/kotlin/ch/qos/logback/core/android/SystemPropertiesProxy.kt b/logback-android/src/main/kotlin/ch/qos/logback/core/android/SystemPropertiesProxy.kt index 0d69017e9..7cc615ebd 100644 --- a/logback-android/src/main/kotlin/ch/qos/logback/core/android/SystemPropertiesProxy.kt +++ b/logback-android/src/main/kotlin/ch/qos/logback/core/android/SystemPropertiesProxy.kt @@ -63,11 +63,13 @@ public class SystemPropertiesProxy private constructor(cl: ClassLoader?) { * @throws IllegalArgumentException if the key exceeds 32 characters */ @Throws(IllegalArgumentException::class) - public fun get(key: String, def: String?): String? { + public fun get(key: String?, def: String?): String? { val method = getStringMethod ?: return null val ret = try { - method.invoke(null, key, def) as? String + // the receiver is ignored for the (static) SystemProperties methods; + // an IllegalArgumentException from invoke() itself is rethrown + method.invoke(systemProperties, key, def) as? String } catch (e: IllegalArgumentException) { throw e } catch (e: Exception) { @@ -94,12 +96,13 @@ public class SystemPropertiesProxy private constructor(cl: ClassLoader?) { * is not able to be parsed as a boolean. * @throws IllegalArgumentException if the key exceeds 32 characters */ + // Boolean? keeps the java.lang.Boolean return type of the Java API @Throws(IllegalArgumentException::class) - public fun getBoolean(key: String, def: Boolean): Boolean { + public fun getBoolean(key: String?, def: Boolean): Boolean? { val method = getBooleanMethod ?: return def return try { - method.invoke(null, key, def) as? Boolean ?: def + method.invoke(systemProperties, key, def) as Boolean? } catch (e: IllegalArgumentException) { throw e } catch (e: Exception) { From 335a0b341df553111da14cec12d9e57c298493b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 06:13:40 +0000 Subject: [PATCH 3/4] test: cover the Android-specific layer 100% with Kotlin tests Ports LogcatAppenderTest, SQLiteAppenderTest and AndroidContextUtilTest to Kotlin (keeping every case) and adds tests until every line and branch of ch.qos.logback.{classic,core}.android is covered, asserting behavior rather than just executing it: - LogcatAppenderTest: level-to-priority mapping, isLoggable() checks and tag truncation, start() validation, %nopex handling of tag layouts, and XML configuration through Joran. - BasicLogcatConfiguratorTest, SystemClockTest. - SQLiteAppenderTest: the rows actually written (arguments, caller data, properties, exceptions with causes), log cleanup timing, and every failure path (database open/create/insert failures, rolled-back transactions). - AndroidContextUtilTest: every path/property getter across SDK levels, storage states, package info, and the application-context lookup. - SystemPropertiesProxyTest (new). - JavaApiSignaturesTest: pins the JVM-level API that Java callers and Joran rely on, so a binary-incompatible change fails a test. Uses kotlin-test assertions, mockito-kotlin and Robolectric. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AN8SJNGETCegfBbPy4iV5K --- .../classic/android/LogcatAppenderTest.java | 249 ----- .../classic/android/SQLiteAppenderTest.java | 212 ---- .../core/android/AndroidContextUtilTest.java | 253 ----- .../android/BasicLogcatConfiguratorTest.kt | 147 +++ .../classic/android/JavaApiSignaturesTest.kt | 268 ++++++ .../classic/android/LogcatAppenderTest.kt | 669 +++++++++++++ .../classic/android/SQLiteAppenderTest.kt | 905 ++++++++++++++++++ .../classic/android/SystemClockTest.kt | 39 + .../core/android/AndroidContextUtilTest.kt | 678 +++++++++++++ .../core/android/SystemPropertiesProxyTest.kt | 323 +++++++ 10 files changed, 3029 insertions(+), 714 deletions(-) delete mode 100644 logback-android/src/test/java/ch/qos/logback/classic/android/LogcatAppenderTest.java delete mode 100644 logback-android/src/test/java/ch/qos/logback/classic/android/SQLiteAppenderTest.java delete mode 100644 logback-android/src/test/java/ch/qos/logback/core/android/AndroidContextUtilTest.java create mode 100644 logback-android/src/test/kotlin/ch/qos/logback/classic/android/BasicLogcatConfiguratorTest.kt create mode 100644 logback-android/src/test/kotlin/ch/qos/logback/classic/android/JavaApiSignaturesTest.kt create mode 100644 logback-android/src/test/kotlin/ch/qos/logback/classic/android/LogcatAppenderTest.kt create mode 100644 logback-android/src/test/kotlin/ch/qos/logback/classic/android/SQLiteAppenderTest.kt create mode 100644 logback-android/src/test/kotlin/ch/qos/logback/classic/android/SystemClockTest.kt create mode 100644 logback-android/src/test/kotlin/ch/qos/logback/core/android/AndroidContextUtilTest.kt create mode 100644 logback-android/src/test/kotlin/ch/qos/logback/core/android/SystemPropertiesProxyTest.kt diff --git a/logback-android/src/test/java/ch/qos/logback/classic/android/LogcatAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/classic/android/LogcatAppenderTest.java deleted file mode 100644 index 67501974c..000000000 --- a/logback-android/src/test/java/ch/qos/logback/classic/android/LogcatAppenderTest.java +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Copyright 2019 Anthony Trinh - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package ch.qos.logback.classic.android; - -import android.util.Log; - -import static org.hamcrest.Matchers.*; -import static org.junit.Assert.assertThat; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.shadows.ShadowLog; - -import java.io.ByteArrayInputStream; -import java.util.List; - -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.classic.encoder.PatternLayoutEncoder; -import ch.qos.logback.classic.joran.JoranConfigurator; -import ch.qos.logback.classic.spi.LoggingEvent; -import ch.qos.logback.classic.spi.ThrowableProxy; -import ch.qos.logback.core.encoder.LayoutWrappingEncoder; -import ch.qos.logback.core.joran.spi.JoranException; - -/** - * Tests the {@link LogcatAppender} class - * - * @author Anthony Trinh - */ -@RunWith(RobolectricTestRunner.class) -public class LogcatAppenderTest { - static private final String LOGGER_NAME = "LOGCAT"; - static private final int MAX_TAG_LENGTH = 23; // for android.util.Log.isLoggable() - static private final String TAG = "123456789012345678901234567890"; - static private final String TRUNCATED_TAG = TAG.substring(0, MAX_TAG_LENGTH - 1) + "*"; - - private LogcatAppender logcatAppender; - private LoggerContext context = new LoggerContext(); - private Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); - - @Before - public void before() { - context.reset(); - root.detachAndStopAllAppenders(); - configureLogcatAppender(); - } - - @Test - public void longTagAllowedIfNotCheckLoggable() { - LoggingEvent event = new LoggingEvent(); - event.setMessage(TAG); - - boolean checkLoggable = false; - setTagPattern(TAG, checkLoggable); - String actualTag = logcatAppender.getTag(event); - - assertThat(TRUNCATED_TAG, is(not(actualTag))); - assertThat(TAG, is(actualTag)); - } - - @Test - public void longTagTruncatedIfCheckLoggable() { - LoggingEvent event = new LoggingEvent(); - event.setMessage(TAG); - - boolean checkLoggable = true; - setTagPattern(TAG, checkLoggable); - String actualTag = logcatAppender.getTag(event); - - assertThat(TRUNCATED_TAG, is(actualTag)); - assertThat(TAG, is(not(actualTag))); - } - - // Issue #34 - @Test - public void tagExcludesStackTraces() { - // create logging event with throwable - LoggingEvent event = new LoggingEvent(); - Throwable throwable = new Throwable("throwable"); - ThrowableProxy tp = new ThrowableProxy(throwable); - event.setThrowableProxy(tp); - event.setMessage(TAG); - - setTagPattern(TAG, true); - - // if the tags match, it does not include the stack trace - String actualTag = logcatAppender.getTagEncoder().getLayout().doLayout(event); - assertThat(TAG, is(actualTag)); - } - - private void setTagPattern(String tag, boolean checkLoggable) { - logcatAppender.stop(); - logcatAppender.setCheckLoggable(checkLoggable); - ((PatternLayoutEncoder) logcatAppender.getTagEncoder()).setPattern(tag); - logcatAppender.start(); - } - - private void configureLogcatAppender() { - logcatAppender = new LogcatAppender(); - logcatAppender.setContext(context); - logcatAppender.setName(LOGGER_NAME); - - PatternLayoutEncoder encoder = new PatternLayoutEncoder(); - encoder.setContext(context); - encoder.setPattern("%msg"); - encoder.start(); - - PatternLayoutEncoder tagEncoder = new PatternLayoutEncoder(); - tagEncoder.setContext(context); - tagEncoder.setPattern(TAG); - tagEncoder.start(); - - logcatAppender.setTagEncoder(tagEncoder); - logcatAppender.setEncoder(encoder); - logcatAppender.start(); - } - - - private boolean logcatContains(List logs, int level, String errorMessage) { - boolean found = false; - for (ShadowLog.LogItem s : logs) { - if (level == s.type) { - if (s.msg.contains(errorMessage)) { - found = true; - break; - } - } - } - return found; - } - - private void assertLogcatContains(int level, String errorMessage) { - List logs = ShadowLog.getLogsForTag(LOGGER_NAME); - assertThat(logs, is(notNullValue())); - assertThat(logcatContains(logs, level, errorMessage), is(true)); - } - - private void addLogcatAppenderToRoot() { - PatternLayoutEncoder encoder2 = new PatternLayoutEncoder(); - encoder2.setContext(context); - encoder2.setPattern("[%thread] %method\\(\\): %msg%n"); - encoder2.start(); - - LogcatAppender logcatAppender = new LogcatAppender(); - logcatAppender.setContext(context); - logcatAppender.setName(LOGGER_NAME); - logcatAppender.setEncoder(encoder2); - logcatAppender.start(); - - root.addAppender(logcatAppender); - } - - /** - * Issue #102 - */ - @Test - public void logsExceptionWhenMessageTrailsWithNewline() { - addLogcatAppenderToRoot(); - ShadowLog.reset(); - context.getLogger(LOGGER_NAME).debug("msg\n", new NullPointerException()); - assertLogcatContains(Log.DEBUG, NullPointerException.class.getName()); - } - - /** - * Issue #102 - */ - @Test - public void logsExceptionWhenMessageHasNoTrailingNewline() { - addLogcatAppenderToRoot(); - ShadowLog.reset(); - context.getLogger(LOGGER_NAME).debug("msg", new NullPointerException()); - assertLogcatContains(Log.DEBUG, NullPointerException.class.getName()); - } - - /** - * Issue #376 - */ - @Test - public void supportsLayoutWrappingEncoder() throws JoranException { - String config = - "" + - " " + - " " + - " " + - " wrapped: %msg" + - " " + - " " + - " " + - " " + - ""; - LogcatAppender appender = configureFromXml(config); - - assertThat(appender.isStarted(), is(true)); - assertThat(appender.getEncoder(), is(instanceOf(LayoutWrappingEncoder.class))); - - LoggingEvent event = new LoggingEvent(); - event.setMessage("hello"); - assertThat(appender.getEncoder().getLayout().doLayout(event), is("wrapped: hello")); - } - - /** - * Issue #376: an encoder element without a class attribute must still - * default to PatternLayoutEncoder - */ - @Test - public void defaultEncoderTypeIsPatternLayoutEncoder() throws JoranException { - String config = - "" + - " " + - " %msg" + - " tag" + - " " + - " " + - ""; - LogcatAppender appender = configureFromXml(config); - - assertThat(appender.isStarted(), is(true)); - assertThat(appender.getEncoder(), is(instanceOf(PatternLayoutEncoder.class))); - assertThat(appender.getTagEncoder(), is(instanceOf(PatternLayoutEncoder.class))); - } - - private LogcatAppender configureFromXml(String config) throws JoranException { - LoggerContext ctx = new LoggerContext(); - JoranConfigurator configurator = new JoranConfigurator(); - configurator.setContext(ctx); - configurator.doConfigure(new ByteArrayInputStream(config.getBytes())); - - LogcatAppender appender = (LogcatAppender) ctx - .getLogger(Logger.ROOT_LOGGER_NAME).getAppender("logcat"); - assertThat(appender, is(notNullValue())); - return appender; - } -} diff --git a/logback-android/src/test/java/ch/qos/logback/classic/android/SQLiteAppenderTest.java b/logback-android/src/test/java/ch/qos/logback/classic/android/SQLiteAppenderTest.java deleted file mode 100644 index f7725cfb9..000000000 --- a/logback-android/src/test/java/ch/qos/logback/classic/android/SQLiteAppenderTest.java +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Copyright 2019 Anthony Trinh - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package ch.qos.logback.classic.android; - -import android.database.sqlite.SQLiteDatabase; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.slf4j.Logger; - -import java.io.File; -import java.io.IOException; - -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.core.CoreConstants; -import ch.qos.logback.core.util.CoreTestConstants; -import ch.qos.logback.core.util.Duration; - -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.notNullValue; -import static org.junit.Assert.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -@RunWith(RobolectricTestRunner.class) -public class SQLiteAppenderTest { - - private static final String TEST_SQLITE_FILENAME = CoreTestConstants.OUTPUT_DIR_PREFIX + "SQLiteAppenderTest/logback.db"; - private static final long EXPIRY_MS = 500; - private static final long NO_EXPIRY = 0; - - @Rule - public TemporaryFolder tmp = new TemporaryFolder(); - - private SQLiteLogCleaner logCleaner; - private LoggerContext context; - private SQLiteAppender appender; - private Long mockTimeMs; - - @Before - public void setup() throws Exception { - context = new LoggerContext(); - context.putProperty(CoreConstants.PACKAGE_NAME_KEY, "com.example"); - appender = new SQLiteAppender(); - mockTimeMs = System.currentTimeMillis(); - appender.setClock(new Clock() { - public long currentTimeMillis() { - return mockTimeMs; - } - }); - appender.setFilename(TEST_SQLITE_FILENAME); - appender.setContext(context); - logCleaner = mock(SQLiteLogCleaner.class); - } - - @After - public void teardown() { - new File(TEST_SQLITE_FILENAME).delete(); - } - - @Test - public void cleanuOccursAtAppenderStartup() { - addAppenderToContext("1 hour"); - verify(logCleaner, times(1)).performLogCleanup(any(SQLiteDatabase.class), any(Duration.class)); - } - - @Test - public void cleanupDoesNotOccurBeforeExpiration() { - addAppenderToContext("1 hour"); - - addLogEvents(3, NO_EXPIRY); - - // log-cleanup normally called between logging events if expiry time - // exceeded, but no expiration here, so call-count should still be 1 - verify(logCleaner, times(1)).performLogCleanup(any(SQLiteDatabase.class), any(Duration.class)); - } - - @Test - public void cleanupOccursAfterEveryExpiration() { - addAppenderToContext(EXPIRY_MS + " milli"); - - final int count = 7; - final long delayMs = EXPIRY_MS / 2; - final int expectedCallCount = (int)Math.ceil((double)(delayMs * count)/EXPIRY_MS); - addLogEvents(count, delayMs); - - verify(logCleaner, times(expectedCallCount)).performLogCleanup(any(SQLiteDatabase.class), any(Duration.class)); - } - - @Test - public void dirAsFilenameResultsInDefault() throws IOException { - final File file = appender.getDatabaseFile(tmp.newFolder().getAbsolutePath()); - assertThat(file, is(notNullValue())); - assertThat(file.getName(), is("logback.db")); - } - - @Test - public void nullFilenameResultsInDefault() throws IOException { - final File file = appender.getDatabaseFile(null); - assertThat(file, is(notNullValue())); - assertThat(file.getName(), is("logback.db")); - } - - @Test - public void emptyFilenameResultsInDefault() throws IOException { - final File file = appender.getDatabaseFile(""); - assertThat(file, is(notNullValue())); - assertThat(file.getName(), is("logback.db")); - } - - @Test - public void blankFilenameResultsInDefault() throws IOException { - final File file = appender.getDatabaseFile(" "); - assertThat(file, is(notNullValue())); - assertThat(file.getName(), is("logback.db")); - } - - @Test - public void setsDatabaseFilename() throws IOException { - final File tmpFile = tmp.newFile(); - final File file = appender.getDatabaseFile(tmpFile.getAbsolutePath()); - assertThat(file, is(notNullValue())); - assertThat(file.getName(), is(tmpFile.getName())); - } - - @Test - public void getMaxHistoryReturnsOriginalSetting() { - // note that Duration.toString() returns units in "milliseconds", - // "seconds", "minutes", or "hours" - appender.setMaxHistory("800 milli"); - assertThat(appender.getMaxHistory(), containsString("800 milli")); - appender.setMaxHistory("500 seconds"); - assertThat(appender.getMaxHistory(), containsString("8 minutes")); - appender.setMaxHistory("120 minutes"); - assertThat(appender.getMaxHistory(), containsString("2 hours")); - appender.setMaxHistory("1 hour"); - assertThat(appender.getMaxHistory(), containsString("1 hour")); - appender.setMaxHistory("7 days"); - assertThat(appender.getMaxHistory(), containsString("168 hours")); - } - - @Test - public void maxHistorySetsMilliseconds() { - appender.setMaxHistory("800 milli"); - assertThat(appender.getMaxHistoryMs(), is(800L)); - appender.setMaxHistory("500 seconds"); - assertThat(appender.getMaxHistoryMs(), is(500 * 1000L)); - appender.setMaxHistory("120 minutes"); - assertThat(appender.getMaxHistoryMs(), is(120 * 60 * 1000L)); - appender.setMaxHistory("1 hour"); - assertThat(appender.getMaxHistoryMs(), is(60 * 60 * 1000L)); - appender.setMaxHistory("7 days"); - assertThat(appender.getMaxHistoryMs(), is(7 * 24 * 60 * 60 * 1000L)); - } - - @Test - public void getMaxHistoryEmptyByDefault() { - assertThat(appender.getMaxHistory(), is("")); - } - - @Test - public void getMaxHistoryMsZeroByDefault() { - assertThat(appender.getMaxHistoryMs(), is(0L)); - } - - private long addLogEvents(int count, long delayMs) { - Logger log = context.getLogger(SQLiteAppenderTest.class); - final long startTimeMs = mockTimeMs; - long currTimeMs = startTimeMs; - System.out.println("start=" + startTimeMs); - for (int i = 0; i < count; i++) { - log.info("i={}", i); - - if (delayMs > 0) { - currTimeMs += delayMs; - mockTimeMs = currTimeMs; - System.out.println("mockTime=" + mockTimeMs + " actual="+ System.currentTimeMillis()); - } - } - return startTimeMs; - } - - /** Gets a SQLiteAppender with a no-op log-cleaner mock */ - private void addAppenderToContext(String maxHistory) { - appender.setMaxHistory(maxHistory); - appender.setLogCleaner(logCleaner); - appender.start(); - ch.qos.logback.classic.Logger testRoot = context.getLogger(SQLiteAppenderTest.class); - testRoot.addAppender(appender); - } -} diff --git a/logback-android/src/test/java/ch/qos/logback/core/android/AndroidContextUtilTest.java b/logback-android/src/test/java/ch/qos/logback/core/android/AndroidContextUtilTest.java deleted file mode 100644 index 645fcc6d5..000000000 --- a/logback-android/src/test/java/ch/qos/logback/core/android/AndroidContextUtilTest.java +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Copyright 2019 Anthony Trinh - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package ch.qos.logback.core.android; - -import android.os.Environment; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.endsWith; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.notNullValue; -import static org.hamcrest.Matchers.nullValue; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; -import org.robolectric.shadows.ShadowEnvironment; - -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.core.CoreConstants; - -/** - * Tests the {@link AndroidContextUtil} class - * - * @author Anthony Trinh - */ -@RunWith(RobolectricTestRunner.class) -public class AndroidContextUtilTest { - private AndroidContextUtil contextUtil; - - @Before - public void before() { - ShadowEnvironment.reset(); - // Ensure no context leaked in from a prior test's setApplicationContext() call - AndroidContextUtil.setApplicationContext(null); - contextUtil = new AndroidContextUtil(); - } - - @After - public void after() { - // Reset the static holder so it doesn't leak into other tests - AndroidContextUtil.setApplicationContext(null); - } - - @Test - public void setApplicationContextIsUsedInsteadOfReflectionWorkaround() { - android.content.Context appContext = RuntimeEnvironment.getApplication(); - AndroidContextUtil.setApplicationContext(appContext); - assertThat(AndroidContextUtil.getContext(), is(appContext.getApplicationContext())); - } - - @Test - public void noArgConstructorPicksUpProvidedContext() { - android.content.Context appContext = RuntimeEnvironment.getApplication(); - AndroidContextUtil.setApplicationContext(appContext); - // The no-arg constructor resolves its context through getContext(), so it - // should observe the externally provided context. - assertThat(new AndroidContextUtil().getPackageName(), is(appContext.getPackageName())); - } - - @Test - public void setApplicationContextNullFallsBackToReflectionWorkaround() { - AndroidContextUtil.setApplicationContext(RuntimeEnvironment.getApplication()); - AndroidContextUtil.setApplicationContext(null); - assertThat(AndroidContextUtil.getContext(), is(notNullValue())); - } - - @Test - public void getMountedExternalStorageDirectoryPathReturnsPathWhenMounted() { - ShadowEnvironment.setExternalStorageState(Environment.MEDIA_MOUNTED); - assertThat(contextUtil.getMountedExternalStorageDirectoryPath(), - is(contextUtil.getContext().getExternalFilesDir(null).getAbsolutePath())); - } - - @Test - public void getMountedExternalStorageDirectoryPathReturnsPathWhenMountedReadOnly() { - ShadowEnvironment.setExternalStorageState(Environment.MEDIA_MOUNTED_READ_ONLY); - assertThat(contextUtil.getMountedExternalStorageDirectoryPath(), - is(contextUtil.getContext().getExternalFilesDir(null).getAbsolutePath())); - } - - @Test - public void getMountedExternalStorageDirectoryPathReturnsNullWhenRemoved() { - ShadowEnvironment.setExternalStorageState(Environment.MEDIA_REMOVED); - assertThat(contextUtil.getMountedExternalStorageDirectoryPath(), is(nullValue())); - } - - @Test - public void getMountedExternalStorageDirectoryPathReturnsNullWhenBadRemoval() { - ShadowEnvironment.setExternalStorageState(Environment.MEDIA_BAD_REMOVAL); - assertThat(contextUtil.getMountedExternalStorageDirectoryPath(), is(nullValue())); - } - - @Test - public void getMountedExternalStorageDirectoryPathReturnsNullWhenChecking() { - ShadowEnvironment.setExternalStorageState(Environment.MEDIA_CHECKING); - assertThat(contextUtil.getMountedExternalStorageDirectoryPath(), is(nullValue())); - } - - @Test - public void getMountedExternalStorageDirectoryPathReturnsNullWhenEjecting() { - ShadowEnvironment.setExternalStorageState(Environment.MEDIA_EJECTING); - assertThat(contextUtil.getMountedExternalStorageDirectoryPath(), is(nullValue())); - } - - @Test - public void getMountedExternalStorageDirectoryPathReturnsNullWhenNoFs() { - ShadowEnvironment.setExternalStorageState(Environment.MEDIA_NOFS); - assertThat(contextUtil.getMountedExternalStorageDirectoryPath(), is(nullValue())); - } - - @Test - public void getMountedExternalStorageDirectoryPathReturnsNullWhenUnknown() { - ShadowEnvironment.setExternalStorageState(Environment.MEDIA_UNKNOWN); - assertThat(contextUtil.getMountedExternalStorageDirectoryPath(), is(nullValue())); - } - - @Test - public void getMountedExternalStorageDirectoryPathReturnsNullWhenUnmountable() { - ShadowEnvironment.setExternalStorageState(Environment.MEDIA_UNMOUNTABLE); - assertThat(contextUtil.getMountedExternalStorageDirectoryPath(), is(nullValue())); - } - - @Test - public void getMountedExternalStorageDirectoryPathReturnsNullWhenShared() { - ShadowEnvironment.setExternalStorageState(Environment.MEDIA_SHARED); - assertThat(contextUtil.getMountedExternalStorageDirectoryPath(), is(nullValue())); - } - - @Test - public void getExternalStorageDirectoryPathIsNotEmpty() { - assertThat(contextUtil.getExternalStorageDirectoryPath(), - is(contextUtil.getContext().getExternalFilesDir(null).getAbsolutePath())); - } - - @Test - public void getFilesDirectoryPathIsNotEmpty() { - assertThat(contextUtil.getFilesDirectoryPath(), endsWith("/files")); - } - - @Test - public void getExternalFilesDirectoryPathIsNotEmpty() { - // Robolectric 4.14+ returns the real-Android scoped path, which nests the - // package dir under the external-files root (.../external-files/Android/data/), - // so assert the path contains the external-files segment rather than ends with it. - assertThat(contextUtil.getExternalFilesDirectoryPath(), containsString("/external-files")); - } - - @Test - public void getNoBackupFilesDirectoryPathIsNotEmpty() { - assertThat(contextUtil.getNoBackupFilesDirectoryPath(), endsWith("/no_backup")); - } - - @Test - public void getCacheDirectoryPathIsNotEmpty() { - assertThat(contextUtil.getCacheDirectoryPath(), endsWith("/cache")); - } - - @Test - public void getExternalCacheDirectoryPathIsNotEmpty() { - assertThat(contextUtil.getExternalCacheDirectoryPath(), containsString("/external-cache")); - } - - @Test - public void getDatabaseDirectoryPathIsNotEmpty() { - assertThat(contextUtil.getDatabaseDirectoryPath(), endsWith("/databases")); - } - - @Test - public void getPackageName() { - assertThat(contextUtil.getPackageName(), is("com.github.tony19.logback.android.test")); - } - - @Test - public void setupProperties() { - LoggerContext loggerContext = new LoggerContext(); - - assertThat(loggerContext.getProperty(CoreConstants.DATA_DIR_KEY), is(nullValue())); - assertThat(loggerContext.getProperty(CoreConstants.EXT_DIR_KEY), is(nullValue())); - assertThat(loggerContext.getProperty(CoreConstants.VERSION_CODE_KEY), is(nullValue())); - assertThat(loggerContext.getProperty(CoreConstants.VERSION_NAME_KEY), is(nullValue())); - assertThat(loggerContext.getProperty(CoreConstants.PACKAGE_NAME_KEY), is(nullValue())); - - contextUtil.setupProperties(loggerContext); - - assertThat(loggerContext.getProperty(CoreConstants.DATA_DIR_KEY), is(contextUtil.getFilesDirectoryPath())); - assertThat(loggerContext.getProperty(CoreConstants.EXT_DIR_KEY), is(contextUtil.getMountedExternalStorageDirectoryPath())); - assertThat(loggerContext.getProperty(CoreConstants.VERSION_CODE_KEY), is(contextUtil.getVersionCode())); - assertThat(loggerContext.getProperty(CoreConstants.VERSION_NAME_KEY), is(contextUtil.getVersionName())); - assertThat(loggerContext.getProperty(CoreConstants.PACKAGE_NAME_KEY), is(contextUtil.getPackageName())); - } - - // Issue #181 - @Test - public void setupPropertiesIncludesExternalDirs() { - LoggerContext loggerContext = new LoggerContext(); - - assertThat(loggerContext.getProperty(CoreConstants.EXT_FILES_DIR_KEY), is(nullValue())); - assertThat(loggerContext.getProperty(CoreConstants.EXT_CACHE_DIR_KEY), is(nullValue())); - - contextUtil.setupProperties(loggerContext); - - assertThat(loggerContext.getProperty(CoreConstants.EXT_FILES_DIR_KEY), is(contextUtil.getExternalFilesDirectoryPath())); - assertThat(loggerContext.getProperty(CoreConstants.EXT_CACHE_DIR_KEY), is(contextUtil.getExternalCacheDirectoryPath())); - } - - // Issue #228 - @Test - public void createAppExternalStorageDirsCreatesMissingDirs() { - android.content.Context appContext = RuntimeEnvironment.getApplication(); - java.io.File extFilesDir = appContext.getExternalFilesDir(null); - java.io.File extCacheDir = appContext.getExternalCacheDir(); - - assertThat(extFilesDir.delete(), is(true)); - assertThat(extCacheDir.delete(), is(true)); - - contextUtil.createAppExternalStorageDirs(); - - assertThat(extFilesDir.exists(), is(true)); - assertThat(extCacheDir.exists(), is(true)); - } - - // Issue #228 - @Test - public void createAppExternalStorageDirsIsNoopWithoutContext() { - // must not throw - new AndroidContextUtil(null).createAppExternalStorageDirs(); - } - - // Issue #181 - @Test - public void containsPropertiesDetectsExternalDirKeys() { - assertThat(AndroidContextUtil.containsProperties("${EXT_FILES_DIR}/logs/app.log"), is(true)); - assertThat(AndroidContextUtil.containsProperties("${EXT_CACHE_DIR}/logs/app.log"), is(true)); - assertThat(AndroidContextUtil.containsProperties("/absolute/path/app.log"), is(false)); - } -} diff --git a/logback-android/src/test/kotlin/ch/qos/logback/classic/android/BasicLogcatConfiguratorTest.kt b/logback-android/src/test/kotlin/ch/qos/logback/classic/android/BasicLogcatConfiguratorTest.kt new file mode 100644 index 000000000..48d2330fa --- /dev/null +++ b/logback-android/src/test/kotlin/ch/qos/logback/classic/android/BasicLogcatConfiguratorTest.kt @@ -0,0 +1,147 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.android + +import android.util.Log +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.encoder.PatternLayoutEncoder +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.Appender +import ch.qos.logback.core.status.InfoStatus +import ch.qos.logback.core.status.Status +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowLog +import org.slf4j.LoggerFactory +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Tests the [BasicLogcatConfigurator] class + */ +@RunWith(RobolectricTestRunner::class) +class BasicLogcatConfiguratorTest { + + private val context = LoggerContext() + private val cleanups = mutableListOf<() -> Unit>() + + @Before + fun before() { + ShadowLog.reset() + } + + @After + fun after() { + cleanups.asReversed().forEach { it() } + context.stop() + ShadowLog.reset() + } + + @Test + fun `configure attaches a started logcat appender to the root logger`() { + BasicLogcatConfigurator.configure(context) + + val appender = assertIs(rootOf(context).getAppender("logcat")) + assertTrue(appender.isStarted) + assertSame(context, appender.context) + assertFalse(appender.checkLoggable) + assertNull(appender.tagEncoder) + val encoder = assertIs(appender.encoder) + assertEquals("%msg", encoder.pattern) + assertTrue(encoder.layout.isStarted) + assertSame(context, encoder.context) + } + + @Test + fun `configure reports the default configuration and no errors`() { + BasicLogcatConfigurator.configure(context) + + val statuses = context.statusManager.copyOfStatusList + val setup = statuses.single { it.message == SETUP_MESSAGE } + assertIs(setup) + assertSame(context, setup.origin) + assertEquals(emptyList(), statuses.filter { it.level == Status.ERROR }) + } + + @Test + fun `configured context logs messages to logcat without trailing newline`() { + BasicLogcatConfigurator.configure(context) + + context.getLogger("foo.Bar").debug("hello") + context.getLogger("foo.Bar").error("oops") + + val logs = ShadowLog.getLogsForTag("foo.Bar").map { it.type to it.msg } + assertEquals(listOf(Log.DEBUG to "hello", Log.ERROR to "oops"), logs) + } + + @Test + fun `configure works without a status manager`() { + val realStatusManager = context.statusManager + val lc = spy(context) + doReturn(null).whenever(lc).statusManager + + BasicLogcatConfigurator.configure(lc) + + val appender = assertIs(rootOf(lc).getAppender("logcat")) + assertTrue(appender.isStarted) + assertTrue(realStatusManager.copyOfStatusList.none { it.message == SETUP_MESSAGE }) + lc.getLogger("foo.Bar").info("hello") + assertEquals(listOf("hello"), ShadowLog.getLogsForTag("foo.Bar").map { it.msg }) + } + + @Test + fun `configureDefaultContext configures the SLF4J logger context`() { + val defaultContext = assertIs(LoggerFactory.getILoggerFactory()) + val root = rootOf(defaultContext) + val appendersBefore = appendersOf(root) + val statusesBefore = defaultContext.statusManager.copyOfStatusList + + BasicLogcatConfigurator.configureDefaultContext() + + val added = appendersOf(root) - appendersBefore + // leave the shared default context as we found it + cleanups += { added.forEach { root.detachAppender(it); it.stop() } } + + val appender = assertIs(added.single()) + assertEquals("logcat", appender.name) + assertTrue(appender.isStarted) + assertSame(defaultContext, appender.context) + assertEquals("%msg", assertIs(appender.encoder).pattern) + // compare by identity: the status buffer is shared with the other tests and bounded + val newStatuses = defaultContext.statusManager.copyOfStatusList.filter { s -> statusesBefore.none { it === s } } + assertSame(defaultContext, newStatuses.single { it.message == SETUP_MESSAGE }.origin) + } + + private fun rootOf(lc: LoggerContext): Logger = lc.getLogger(Logger.ROOT_LOGGER_NAME) + + private fun appendersOf(logger: Logger): Set> = + logger.iteratorForAppenders().asSequence().toSet() + + private companion object { + const val SETUP_MESSAGE = "Setting up default configuration." + } +} diff --git a/logback-android/src/test/kotlin/ch/qos/logback/classic/android/JavaApiSignaturesTest.kt b/logback-android/src/test/kotlin/ch/qos/logback/classic/android/JavaApiSignaturesTest.kt new file mode 100644 index 000000000..6980a11ef --- /dev/null +++ b/logback-android/src/test/kotlin/ch/qos/logback/classic/android/JavaApiSignaturesTest.kt @@ -0,0 +1,268 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.android + +import ch.qos.logback.classic.encoder.PatternLayoutEncoder +import ch.qos.logback.core.android.AndroidContextUtil +import ch.qos.logback.core.android.SystemPropertiesProxy +import ch.qos.logback.core.encoder.LayoutWrappingEncoder +import ch.qos.logback.core.joran.spi.DefaultClass +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.lang.reflect.Constructor +import java.lang.reflect.Field +import java.lang.reflect.Member +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.lang.reflect.Type +import kotlin.test.assertEquals + +/** + * Pins the JVM-level API of the Android-specific classes, which Java callers + * are compiled against and Joran looks up by reflection (to call the setters + * of components configured in XML): class modifiers, supertypes, and every + * public/protected constructor, method and field with its exact types, + * modifiers and declared exceptions. + * + * A failure here means that API changed, possibly in a binary- or + * source-incompatible way, even if all the behavioral tests pass. + */ +@RunWith(RobolectricTestRunner::class) +class JavaApiSignaturesTest { + + @Test + fun `BasicLogcatConfigurator keeps its Java API`() { + assertApi( + BasicLogcatConfigurator::class.java, + // a Kotlin object since #388: the class couldn't be extended before either + "public final class", + "public static final ch.qos.logback.classic.android.BasicLogcatConfigurator INSTANCE", + "public static void configure(ch.qos.logback.classic.LoggerContext)", + "public static void configureDefaultContext()", + ) + } + + @Test + fun `LogcatAppender keeps its Java API`() { + assertApi( + LogcatAppender::class.java, + "public class extends ch.qos.logback.core.UnsynchronizedAppenderBase<$EVENT>", + "public LogcatAppender()", + "public void start()", + "public void append($EVENT)", + "public $ENCODER getEncoder()", + "public void setEncoder($ENCODER)", + "public $ENCODER getTagEncoder()", + "public void setTagEncoder($ENCODER)", + "public boolean getCheckLoggable()", + "public void setCheckLoggable(boolean)", + "protected java.lang.String getTag($EVENT)", + ) + } + + @Test + fun `LogcatAppender encoders default to PatternLayoutEncoder in XML config`() { + // Joran instantiates this class for an or element without a class attribute + for (setter in listOf("setEncoder", "setTagEncoder")) { + val method = LogcatAppender::class.java.getMethod(setter, LayoutWrappingEncoder::class.java) + val defaultClass = method.getAnnotation(DefaultClass::class.java)?.value?.java + + assertEquals(PatternLayoutEncoder::class.java, defaultClass, setter) + } + } + + @Test + fun `SQLiteAppender keeps its Java API`() { + assertApi( + SQLiteAppender::class.java, + "public class extends ch.qos.logback.core.UnsynchronizedAppenderBase<$EVENT>", + "public SQLiteAppender()", + "public void start()", + "public void stop()", + "public void append($EVENT)", + // finalize() (which closed the database) was dropped in #388; call stop() + "public ch.qos.logback.classic.db.names.DBNameResolver getDbNameResolver()", + "public void setDbNameResolver(ch.qos.logback.classic.db.names.DBNameResolver)", + "public java.lang.String getFilename()", + "public void setFilename(java.lang.String)", + "public java.lang.String getMaxHistory()", + "public void setMaxHistory(java.lang.String)", + "public long getMaxHistoryMs()", + "public java.io.File getDatabaseFile(java.lang.String)", + "public ch.qos.logback.classic.android.SQLiteLogCleaner getLogCleaner()", + "public void setLogCleaner(ch.qos.logback.classic.android.SQLiteLogCleaner)", + // package-private test seam, not public API + ignore = setOf("setClock"), + ) + } + + @Test + fun `SQLiteLogCleaner keeps its Java API`() { + assertApi( + SQLiteLogCleaner::class.java, + "public abstract interface", + "public abstract void performLogCleanup(" + + "android.database.sqlite.SQLiteDatabase, ch.qos.logback.core.util.Duration)", + ) + } + + @Test + fun `Clock and SystemClock keep their shape`() { + // package-private types: their visibility is not part of the API + assertApi( + Class.forName("ch.qos.logback.classic.android.Clock"), + "abstract interface", + "abstract long currentTimeMillis()", + pinVisibility = false, + ) + assertApi( + Class.forName("ch.qos.logback.classic.android.SystemClock"), + "final class implements ch.qos.logback.classic.android.Clock", + "SystemClock()", + "long currentTimeMillis()", + pinVisibility = false, + ) + } + + @Test + fun `AndroidContextUtil keeps its Java API`() { + assertApi( + AndroidContextUtil::class.java, + "public class", + "public AndroidContextUtil()", + "public AndroidContextUtil(android.content.Context)", + "public static boolean containsProperties(java.lang.String)", + "public static void setApplicationContext(android.content.Context)", + // public since #388 (was protected), pairing with setApplicationContext() + "public static android.content.Context getContext()", + "public static final ch.qos.logback.core.android.AndroidContextUtil\$Companion Companion", + "public void setupProperties(ch.qos.logback.core.Context)", + "public void createAppExternalStorageDirs()", + "public java.lang.String getMountedExternalStorageDirectoryPath()", + "public java.lang.String getExternalStorageDirectoryPath()", + "public java.lang.String getExternalFilesDirectoryPath()", + "public java.lang.String getCacheDirectoryPath()", + "public java.lang.String getExternalCacheDirectoryPath()", + "public java.lang.String getPackageName()", + "public java.lang.String getFilesDirectoryPath()", + "public java.lang.String getNoBackupFilesDirectoryPath()", + "public java.lang.String getDatabaseDirectoryPath()", + "public java.lang.String getDatabasePath(java.lang.String)", + "public java.lang.String getVersionCode()", + "public java.lang.String getVersionName()", + ) + } + + @Test + fun `SystemPropertiesProxy keeps its Java API`() { + assertApi( + SystemPropertiesProxy::class.java, + // final since #388: with only a private constructor it couldn't be extended before either + "public final class", + "public static final ch.qos.logback.core.android.SystemPropertiesProxy\$Companion Companion", + "public static ch.qos.logback.core.android.SystemPropertiesProxy getInstance()", + "public void setClassLoader(java.lang.ClassLoader) throws java.lang.ClassNotFoundException, " + + "java.lang.SecurityException, java.lang.NoSuchMethodException", + "public java.lang.String get(java.lang.String, java.lang.String) throws java.lang.IllegalArgumentException", + "public java.lang.Boolean getBoolean(java.lang.String, boolean) throws java.lang.IllegalArgumentException", + ) + } + + /** + * Asserts that [type] has exactly the given [declaration] (modifiers and + * supertypes) and API [members]: its constructors, methods and fields that + * are public or protected (with [pinVisibility] off: not private), except + * those named in [ignore]. Compiler-generated (synthetic or bridge) members + * are not API. + */ + private fun assertApi( + type: Class<*>, + declaration: String, + vararg members: String, + ignore: Set = emptySet(), + pinVisibility: Boolean = true, + ) { + val renderer = ApiRenderer(pinVisibility) + val expected = listOf(declaration) + members.sorted() + val actual = listOf(renderer.declaration(type)) + renderer.members(type, ignore).sorted() + + assertEquals( + expected.joinToString("\n"), + actual.joinToString("\n"), + "API of ${type.name} changed\n missing: ${expected - actual}\n unexpected: ${actual - expected}\n", + ) + } + + /** Renders an API like Java declarations, with fully qualified (generic) types. */ + private class ApiRenderer(private val pinVisibility: Boolean) { + private val visibility = if (pinVisibility) Modifier.PUBLIC or Modifier.PROTECTED else 0 + + fun declaration(type: Class<*>): String = buildString { + val mask = visibility or Modifier.FINAL or Modifier.ABSTRACT or Modifier.INTERFACE + append(Modifier.toString(type.modifiers and mask)) + if (!type.isInterface) append(" class") + type.genericSuperclass?.takeIf { it != Any::class.java }?.let { append(" extends ${it.typeName}") } + if (type.genericInterfaces.isNotEmpty()) { + append(if (type.isInterface) " extends " else " implements ") + append(type.genericInterfaces.render()) + } + }.trim() + + fun members(type: Class<*>, ignore: Set): List { + val members: List = type.declaredConstructors.toList() + + type.declaredMethods.filterNot { it.isBridge } + type.declaredFields + return members.filter { isApi(it) && it.name !in ignore }.map(::render) + } + + private fun isApi(member: Member): Boolean = !member.isSynthetic && if (pinVisibility) { + member.modifiers and (Modifier.PUBLIC or Modifier.PROTECTED) != 0 + } else { + !Modifier.isPrivate(member.modifiers) + } + + private fun render(member: Member): String { + val declaration = when (member) { + is Constructor<*> -> member.declaringClass.simpleName + + "(${member.genericParameterTypes.render()})${member.genericExceptionTypes.throwsClause()}" + is Method -> "${member.genericReturnType.typeName} ${member.name}" + + "(${member.genericParameterTypes.render()})${member.genericExceptionTypes.throwsClause()}" + is Field -> "${member.genericType.typeName} ${member.name}" + else -> error("unexpected member: $member") + } + return listOf(modifiers(member), declaration).filter { it.isNotEmpty() }.joinToString(" ") + } + + private fun modifiers(member: Member): String { + // a final method can't be overridden, which doesn't apply to static + // methods or to the methods of a final class (pinned by its declaration) + val isFinalRelevant = member !is Method || + !(Modifier.isStatic(member.modifiers) || Modifier.isFinal(member.declaringClass.modifiers)) + val mask = visibility or Modifier.STATIC or Modifier.ABSTRACT or + if (isFinalRelevant) Modifier.FINAL else 0 + return Modifier.toString(member.modifiers and mask) + } + + private fun Array.render() = joinToString { it.typeName } + + private fun Array.throwsClause() = if (isEmpty()) "" else " throws ${render()}" + } + + private companion object { + const val EVENT = "ch.qos.logback.classic.spi.ILoggingEvent" + const val ENCODER = "ch.qos.logback.core.encoder.LayoutWrappingEncoder<$EVENT>" + } +} diff --git a/logback-android/src/test/kotlin/ch/qos/logback/classic/android/LogcatAppenderTest.kt b/logback-android/src/test/kotlin/ch/qos/logback/classic/android/LogcatAppenderTest.kt new file mode 100644 index 000000000..e6b97650d --- /dev/null +++ b/logback-android/src/test/kotlin/ch/qos/logback/classic/android/LogcatAppenderTest.kt @@ -0,0 +1,669 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.android + +import android.util.Log +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.PatternLayout +import ch.qos.logback.classic.encoder.PatternLayoutEncoder +import ch.qos.logback.classic.joran.JoranConfigurator +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.classic.spi.LoggingEvent +import ch.qos.logback.classic.spi.ThrowableProxy +import ch.qos.logback.core.LayoutBase +import ch.qos.logback.core.encoder.LayoutWrappingEncoder +import ch.qos.logback.core.status.Status +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowLog +import java.io.ByteArrayInputStream +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Tests the [LogcatAppender] class + * + * @author Anthony Trinh + */ +@RunWith(RobolectricTestRunner::class) +class LogcatAppenderTest { + + private val context = LoggerContext() + private val root: Logger = context.getLogger(Logger.ROOT_LOGGER_NAME) + private val xmlContexts = mutableListOf() + private lateinit var logcatAppender: TagExposingAppender + + @Before + fun before() { + context.reset() + root.detachAndStopAllAppenders() + ShadowLog.reset() + logcatAppender = configureLogcatAppender() + } + + @After + fun after() { + logcatAppender.stop() + root.detachAndStopAllAppenders() + context.stop() + xmlContexts.forEach { it.stop() } + ShadowLog.reset() + } + + // --- tag length --------------------------------------------------------- + + @Test + fun `long tag allowed if not checkLoggable`() { + setTagPattern(TAG, checkLoggable = false) + + assertEquals(TAG, logcatAppender.tagOf(event(message = TAG))) + } + + @Test + fun `long tag truncated if checkLoggable`() { + setTagPattern(TAG, checkLoggable = true) + + assertEquals(TRUNCATED_TAG, logcatAppender.tagOf(event(message = TAG))) + } + + @Test + fun `tag of max length is not truncated if checkLoggable`() { + val maxLengthTag = TAG.substring(0, MAX_TAG_LENGTH) + setTagPattern(maxLengthTag, checkLoggable = true) + + assertEquals(maxLengthTag, logcatAppender.tagOf(event())) + } + + @Test + fun `tag one char over max length is truncated if checkLoggable`() { + setTagPattern(TAG.substring(0, MAX_TAG_LENGTH + 1), checkLoggable = true) + + assertEquals(TRUNCATED_TAG, logcatAppender.tagOf(event())) + } + + @Test + fun `tag defaults to logger name without tag encoder`() { + val appender = startedAppender(TagExposingAppender(), checkLoggable = true) + + assertEquals("short.Name", appender.tagOf(event(loggerName = "short.Name"))) + assertEquals(TRUNCATED_TAG, appender.tagOf(event(loggerName = TAG))) + } + + @Test + fun `checkLoggable asks logcat about the truncated tag it logs with`() { + val appender = startedAppender(checkLoggable = true) + // only the truncated tag is restricted; the full logger name keeps the default INFO threshold + ShadowLog.setLoggable(TRUNCATED_TAG, Log.ERROR) + + appender.doAppend(event(Level.WARN, "warn", loggerName = TAG)) + appender.doAppend(event(Level.ERROR, "error", loggerName = TAG)) + + assertEquals(listOf(Triple(Log.ERROR, TRUNCATED_TAG, "error")), logcat()) + } + + @Test + fun `checkLoggable asks logcat about the tag from the tag encoder`() { + val appender = startedAppender(tagEncoder = patternEncoder("%logger{0}"), checkLoggable = true) + // only the tag is restricted; the logger name keeps the default INFO threshold + ShadowLog.setLoggable("Bar", Log.ERROR) + + appender.doAppend(event(Level.WARN, "warn", loggerName = "foo.Bar")) + appender.doAppend(event(Level.ERROR, "error", loggerName = "foo.Bar")) + + assertEquals(listOf(Triple(Log.ERROR, "Bar", "error")), logcat()) + } + + @Test + fun `long logger name reaches logcat untruncated if not checkLoggable`() { + val appender = startedAppender() + + appender.doAppend(event(Level.WARN, "hello", loggerName = TAG)) + + assertEquals(listOf(Triple(Log.WARN, TAG, "hello")), logcat()) + } + + // --- stack traces in tags (issue #34) ------------------------------------ + + // Issue #34 + @Test + fun `tag excludes stack traces`() { + // create logging event with throwable + val event = event(message = TAG, throwable = Throwable("throwable")) + + setTagPattern(TAG, checkLoggable = true) + + // if the tags match, it does not include the stack trace + val actualTag = assertNotNull(logcatAppender.tagEncoder).layout.doLayout(event) + assertEquals(TAG, actualTag) + } + + @Test + fun `start appends nopex to tag pattern only once`() { + val tagEncoder = patternEncoder("%logger") + val appender = startedAppender(tagEncoder = tagEncoder) + assertEquals("%logger%nopex", tagEncoder.pattern) + + // restarting must not append a second %nopex + appender.stop() + appender.start() + assertTrue(appender.isStarted) + assertEquals("%logger%nopex", tagEncoder.pattern) + + appender.doAppend(event(message = "hello", throwable = Throwable("boom"))) + assertEquals(LOGGER_NAME, logcat().single().second) + } + + @Test + fun `tag pattern already containing nopex is kept as is`() { + val tagEncoder = patternEncoder("%logger{0}%nopex") + val appender = startedAppender(tagEncoder = tagEncoder) + + assertEquals("%logger{0}%nopex", tagEncoder.pattern) + appender.doAppend(event(message = "hello", loggerName = "a.b.Tag", throwable = Throwable("boom"))) + assertEquals("Tag", logcat().single().second) + } + + @Test + fun `tag PatternLayout wrapped by LayoutWrappingEncoder gets nopex`() { + val tagLayout = patternLayout("%logger") + val appender = startedAppender(tagEncoder = layoutEncoder(tagLayout)) + + assertEquals("%logger%nopex", tagLayout.pattern) + assertTrue(tagLayout.isStarted) + appender.doAppend(event(message = "hello", throwable = Throwable("boom"))) + assertEquals(LOGGER_NAME, logcat().single().second) + } + + @Test + fun `wrapped tag PatternLayout already containing nopex is kept as is`() { + val tagLayout = patternLayout("[%logger]%nopex") + val appender = startedAppender(tagEncoder = layoutEncoder(tagLayout)) + + assertEquals("[%logger]%nopex", tagLayout.pattern) + appender.doAppend(event(message = "hello", throwable = Throwable("boom"))) + assertEquals("[$LOGGER_NAME]", logcat().single().second) + } + + @Test + fun `wrapped tag PatternLayout without pattern is left alone`() { + // a PatternLayout without a pattern fails to start and lays out nothing + val tagLayout = PatternLayout().apply { context = this@LogcatAppenderTest.context } + val appender = startedAppender(tagEncoder = layoutEncoder(tagLayout)) + + assertTrue(appender.isStarted) + assertNull(tagLayout.pattern) + assertFalse(tagLayout.isStarted) + appender.doAppend(event(Level.INFO, "hello")) + assertEquals(listOf(Triple(Log.INFO, "", "hello")), logcat()) + } + + @Test + fun `tag layout that is not a PatternLayout is used as is`() { + val tagLayout = FixedLayout("custom-tag") + val appender = startedAppender(tagEncoder = layoutEncoder(tagLayout)) + + assertTrue(appender.isStarted) + appender.doAppend(event(Level.INFO, "hello")) + assertEquals(listOf(Triple(Log.INFO, "custom-tag", "hello")), logcat()) + } + + // --- getTag() edge cases ---------------------------------------------------- + + @Test + fun `null tag from tag layout falls back to the logger name`() { + val appender = startedAppender(tagEncoder = layoutEncoder(FixedLayout(null))) + + appender.doAppend(event(Level.INFO, "hello")) + + assertEquals(listOf(Triple(Log.INFO, LOGGER_NAME, "hello")), logcat()) + assertEquals(emptyList(), errorMessages()) + } + + @Test + fun `event without logger name is logged with null tag`() { + val appender = startedAppender() + + appender.doAppend(event(Level.INFO, "hello", loggerName = null)) + + assertEquals(listOf(Triple(Log.INFO, null, "hello")), logcat()) + assertEquals(emptyList(), errorMessages()) + } + + @Test + fun `event without logger name is logged with null tag if checkLoggable`() { + val appender = startedAppender(checkLoggable = true) + + appender.doAppend(event(Level.INFO, "hello", loggerName = null)) + + assertEquals(listOf(Triple(Log.INFO, null, "hello")), logcat()) + assertEquals(emptyList(), errorMessages()) + } + + @Test + fun `tag encoder without layout set after start falls back to the logger name`() { + // start() validates the tag encoder, but setting one later bypasses that + val appender = startedAppender() + appender.tagEncoder = LayoutWrappingEncoder() + + appender.doAppend(event(Level.INFO, "hello")) + + assertEquals(listOf(Triple(Log.INFO, LOGGER_NAME, "hello")), logcat()) + assertEquals(emptyList(), errorMessages()) + } + + // --- properties and start() validation -------------------------------------- + + @Test + fun `defaults to no encoders and no loggable check`() { + val appender = LogcatAppender() + + assertNull(appender.encoder) + assertNull(appender.tagEncoder) + assertFalse(appender.checkLoggable) + assertFalse(appender.isStarted) + } + + @Test + fun `exposes configured encoders and loggable check`() { + val encoder = patternEncoder("%msg") + val tagEncoder = patternEncoder("%logger") + val appender = LogcatAppender().apply { + this.encoder = encoder + this.tagEncoder = tagEncoder + checkLoggable = true + } + + assertSame(encoder, appender.encoder) + assertSame(tagEncoder, appender.tagEncoder) + assertTrue(appender.checkLoggable) + } + + @Test + fun `start fails without encoder`() { + val appender = LogcatAppender().apply { + context = this@LogcatAppenderTest.context + name = "no-encoder" + } + + appender.start() + + assertFalse(appender.isStarted) + assertEquals(listOf("No layout set for the appender named [no-encoder]."), errorMessages()) + } + + @Test + fun `start fails when encoder has no layout`() { + val appender = LogcatAppender().apply { + context = this@LogcatAppenderTest.context + name = "no-layout" + encoder = LayoutWrappingEncoder() + } + + appender.start() + + assertFalse(appender.isStarted) + assertEquals(listOf("No layout set for the appender named [no-layout]."), errorMessages()) + } + + @Test + fun `start fails when tag encoder has no layout`() { + val appender = LogcatAppender().apply { + context = this@LogcatAppenderTest.context + name = "no-tag-layout" + encoder = patternEncoder("%msg") + tagEncoder = LayoutWrappingEncoder() + } + + appender.start() + + assertFalse(appender.isStarted) + assertEquals(listOf("No tag layout set for the appender named [no-tag-layout]."), errorMessages()) + } + + @Test + fun `start succeeds without tag encoder`() { + val appender = startedAppender() + + assertTrue(appender.isStarted) + assertEquals(emptyList(), errorMessages()) + } + + // --- append() ------------------------------------------------------------- + + @Test + fun `does not log if not started`() { + val appender = LogcatAppender().apply { + context = this@LogcatAppenderTest.context + encoder = patternEncoder("%msg") + } + + appender.append(event(Level.ERROR, "hello")) + + assertEquals(emptyList(), logcat()) + } + + @Test + fun `logs each level at its logcat priority`() { + val appender = startedAppender() + + appender.appendAtEveryLevel() + + val expected = PRIORITY_BY_LEVEL.map { (level, priority) -> Triple(priority, LOGGER_NAME, "message at $level") } + assertEquals(expected, logcat()) + } + + @Test + fun `logs every level regardless of logcat filter if not checkLoggable`() { + val appender = startedAppender() + ShadowLog.setLoggable(LOGGER_NAME, Log.ASSERT) + + appender.appendAtEveryLevel() + + assertEquals(PRIORITY_BY_LEVEL.values.toList(), logcat().map { it.first }) + } + + @Test + fun `checkLoggable logs only the levels logcat allows for the tag`() { + val appender = startedAppender(checkLoggable = true) + + for (threshold in Log.VERBOSE..Log.ASSERT) { + ShadowLog.reset() + ShadowLog.setLoggable(LOGGER_NAME, threshold) + + appender.appendAtEveryLevel() + + val expected = PRIORITY_BY_LEVEL + .filterValues { it >= threshold } + .map { (level, priority) -> Triple(priority, LOGGER_NAME, "message at $level") } + assertEquals(expected, logcat(), "logcat threshold $threshold") + } + } + + @Test + fun `checkLoggable follows logcat default threshold of INFO`() { + val appender = startedAppender(checkLoggable = true) + + appender.appendAtEveryLevel() + + assertEquals(listOf(Log.INFO, Log.WARN, Log.ERROR), logcat().map { it.first }) + } + + @Test + fun `never logs OFF or unknown levels`() { + // without checkLoggable, any level that reached a Log call would show up in logcat + val appender = startedAppender() + + appender.doAppend(event(Level.OFF, "off")) + appender.doAppend(event(CUSTOM_LEVEL, "custom")) + + assertEquals(emptyList(), logcat()) + assertEquals(emptyList(), errorMessages()) + } + + @Test + fun `message is formatted by the encoder layout`() { + val appender = startedAppender(encoder = patternEncoder("%level: %msg")) + + appender.doAppend(event(Level.DEBUG, "hello")) + + assertEquals(listOf(Triple(Log.DEBUG, LOGGER_NAME, "DEBUG: hello")), logcat()) + } + + // Issue #102 + @Test + fun `logs exception when message trails with newline`() { + addLogcatAppenderToRoot() + ShadowLog.reset() + context.getLogger(LOGGER_NAME).debug("msg\n", NullPointerException()) + assertLogcatContains(Log.DEBUG, NullPointerException::class.java.name) + } + + // Issue #102 + @Test + fun `logs exception when message has no trailing newline`() { + addLogcatAppenderToRoot() + ShadowLog.reset() + context.getLogger(LOGGER_NAME).debug("msg", NullPointerException()) + assertLogcatContains(Log.DEBUG, NullPointerException::class.java.name) + } + + // --- Joran configuration (issue #376) ---------------------------------------- + + // Issue #376 + @Test + fun `supports LayoutWrappingEncoder`() { + val ctx = configureFromXml( + """ + + + + + wrapped: %msg + + + + + + """ + ) + + val appender = ctx.logcatAppender() + assertTrue(appender.isStarted) + val encoder = assertNotNull(appender.encoder) + assertEquals>(LayoutWrappingEncoder::class.java, encoder.javaClass) + assertEquals("wrapped: hello", encoder.layout.doLayout(event(message = "hello"))) + + ctx.getLogger("foo.Bar").info("hello") + assertEquals(listOf(Triple(Log.INFO, "foo.Bar", "wrapped: hello")), logcat()) + } + + // Issue #376: an encoder element without a class attribute must still + // default to PatternLayoutEncoder + @Test + fun `default encoder type is PatternLayoutEncoder`() { + val ctx = configureFromXml( + """ + + + %msg + tag + + + + """ + ) + + val appender = ctx.logcatAppender() + assertTrue(appender.isStarted) + assertIs(appender.encoder) + assertIs(appender.tagEncoder) + + ctx.getLogger("foo.Bar").debug("hello") + assertEquals(listOf(Triple(Log.DEBUG, "tag", "hello")), logcat()) + } + + @Test + fun `checkLoggable is configurable from XML`() { + val ctx = configureFromXml( + """ + + + true + %msg + + + + """ + ) + + assertTrue(ctx.logcatAppender().checkLoggable) + // logcat's default threshold (INFO) now applies, although the root logger allows DEBUG + ctx.getLogger("foo.Bar").debug("dropped") + ctx.getLogger("foo.Bar").info("kept") + assertEquals(listOf(Triple(Log.INFO, "foo.Bar", "kept")), logcat()) + } + + // --- helpers ------------------------------------------------------------------ + + /** Exposes the protected [LogcatAppender.getTag] to the tests. */ + private class TagExposingAppender : LogcatAppender() { + fun tagOf(event: ILoggingEvent): String? = getTag(event) + } + + /** A layout that always produces the same text (or `null`). */ + private class FixedLayout(private val text: String?) : LayoutBase() { + override fun doLayout(event: ILoggingEvent): String? = text + } + + private fun setTagPattern(tag: String, checkLoggable: Boolean) { + logcatAppender.stop() + logcatAppender.checkLoggable = checkLoggable + (logcatAppender.tagEncoder as PatternLayoutEncoder).pattern = tag + logcatAppender.start() + } + + private fun configureLogcatAppender(): TagExposingAppender = + TagExposingAppender().apply { + context = this@LogcatAppenderTest.context + name = LOGGER_NAME + tagEncoder = patternEncoder(TAG) + encoder = patternEncoder("%msg") + start() + } + + private fun addLogcatAppenderToRoot() { + val appender = startedAppender(encoder = patternEncoder("[%thread] %method\\(\\): %msg%n")) + root.addAppender(appender) + } + + private fun assertLogcatContains(level: Int, errorMessage: String) { + val logs = assertNotNull(ShadowLog.getLogsForTag(LOGGER_NAME)) + assertTrue(logs.any { it.type == level && it.msg.contains(errorMessage) }, "logcat: $logs") + } + + private fun startedAppender( + appender: T, + encoder: LayoutWrappingEncoder = patternEncoder("%msg"), + tagEncoder: LayoutWrappingEncoder? = null, + checkLoggable: Boolean = false, + ): T = appender.apply { + context = this@LogcatAppenderTest.context + name = LOGGER_NAME + this.encoder = encoder + this.tagEncoder = tagEncoder + this.checkLoggable = checkLoggable + start() + } + + private fun startedAppender( + encoder: LayoutWrappingEncoder = patternEncoder("%msg"), + tagEncoder: LayoutWrappingEncoder? = null, + checkLoggable: Boolean = false, + ): LogcatAppender = startedAppender(LogcatAppender(), encoder, tagEncoder, checkLoggable) + + private fun patternEncoder(pattern: String): PatternLayoutEncoder = + PatternLayoutEncoder().apply { + context = this@LogcatAppenderTest.context + this.pattern = pattern + start() + } + + private fun patternLayout(pattern: String): PatternLayout = + PatternLayout().apply { + context = this@LogcatAppenderTest.context + this.pattern = pattern + start() + } + + private fun layoutEncoder(layout: LayoutBase): LayoutWrappingEncoder = + LayoutWrappingEncoder().apply { + context = this@LogcatAppenderTest.context + this.layout = layout + start() + } + + private fun event( + level: Level = Level.INFO, + message: String = "message", + loggerName: String? = LOGGER_NAME, + throwable: Throwable? = null, + ): LoggingEvent = LoggingEvent().apply { + this.loggerName = loggerName + this.level = level + this.message = message + if (throwable != null) setThrowableProxy(ThrowableProxy(throwable)) + } + + /** Appends one "message at " event for each level in [PRIORITY_BY_LEVEL]. */ + private fun LogcatAppender.appendAtEveryLevel() = + PRIORITY_BY_LEVEL.keys.forEach { doAppend(event(it, "message at $it")) } + + /** Everything written to logcat so far, as (priority, tag, message). */ + private fun logcat(): List> = + ShadowLog.getLogs().map { Triple(it.type, it.tag, it.msg) } + + private fun errorMessages(lc: LoggerContext = context): List = + lc.statusManager.copyOfStatusList + .filter { it.level == Status.ERROR } + .map { it.message } + + /** Configures a new context from [config], which must configure without errors. */ + private fun configureFromXml(config: String): LoggerContext { + val ctx = LoggerContext().also { xmlContexts += it } + JoranConfigurator().apply { + context = ctx + doConfigure(ByteArrayInputStream(config.toByteArray())) + } + assertEquals(emptyList(), errorMessages(ctx), "configuration errors") + return ctx + } + + private fun LoggerContext.logcatAppender(): LogcatAppender = + assertIs(getLogger(Logger.ROOT_LOGGER_NAME).getAppender("logcat")) + + private companion object { + const val LOGGER_NAME = "LOGCAT" + const val MAX_TAG_LENGTH = 23 // for android.util.Log.isLoggable() + const val TAG = "123456789012345678901234567890" + val TRUNCATED_TAG = TAG.substring(0, MAX_TAG_LENGTH - 1) + "*" + + /** The logcat priority each logback level is written at. */ + val PRIORITY_BY_LEVEL = linkedMapOf( + Level.ALL to Log.VERBOSE, + Level.TRACE to Log.VERBOSE, + Level.DEBUG to Log.DEBUG, + Level.INFO to Log.INFO, + Level.WARN to Log.WARN, + Level.ERROR to Log.ERROR, + ) + + /** A level unknown to logback (its constructor is private). */ + val CUSTOM_LEVEL: Level = Level::class.java + .getDeclaredConstructor(Int::class.javaPrimitiveType, String::class.java) + .apply { isAccessible = true } + .newInstance(Level.INFO_INT + 1, "CUSTOM") + } +} diff --git a/logback-android/src/test/kotlin/ch/qos/logback/classic/android/SQLiteAppenderTest.kt b/logback-android/src/test/kotlin/ch/qos/logback/classic/android/SQLiteAppenderTest.kt new file mode 100644 index 000000000..38ea2f8a8 --- /dev/null +++ b/logback-android/src/test/kotlin/ch/qos/logback/classic/android/SQLiteAppenderTest.kt @@ -0,0 +1,905 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.android + +import android.database.sqlite.SQLiteConstraintException +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteDatabaseLockedException +import android.database.sqlite.SQLiteException +import android.database.sqlite.SQLiteFullException +import android.database.sqlite.SQLiteStatement +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.db.names.SimpleDBNameResolver +import ch.qos.logback.classic.joran.JoranConfigurator +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.classic.spi.LoggerContextVO +import ch.qos.logback.classic.spi.LoggingEvent +import ch.qos.logback.core.status.Status +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class SQLiteAppenderTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val logCleaner = mock() + private lateinit var context: LoggerContext + private lateinit var appender: SQLiteAppender + private lateinit var dbFile: File + + /** The time reported by the appender's clock */ + private var now = START_MS + + /** Statements compiled by a [mockDatabase], in order */ + private val compiledStatements = mutableListOf() + + @Before + fun setUp() { + context = LoggerContext() + // the parent directory doesn't exist yet: start() must create it + dbFile = File(tmp.root, "databases/logback.db") + appender = newAppender() + } + + @After + fun tearDown() { + if (appender.isStarted) { + appender.stop() + } + } + + // --- maxHistory --------------------------------------------------------- + + @Test + fun `maxHistory is empty by default`() { + assertEquals("", appender.maxHistory) + } + + @Test + fun `maxHistoryMs is zero by default`() { + assertEquals(0L, appender.maxHistoryMs) + } + + @Test + fun `maxHistory reports the setting in whole units`() { + // Duration.toString() uses "milliseconds", "seconds", "minutes" or "hours" + appender.maxHistory = "800 milli" + assertEquals("800 milliseconds", appender.maxHistory) + appender.maxHistory = "500 seconds" + assertEquals("8 minutes", appender.maxHistory) + appender.maxHistory = "120 minutes" + assertEquals("2 hours", appender.maxHistory) + appender.maxHistory = "1 hour" + assertEquals("1 hours", appender.maxHistory) + appender.maxHistory = "7 days" + assertEquals("168 hours", appender.maxHistory) + } + + @Test + fun `maxHistory sets maxHistoryMs`() { + appender.maxHistory = "800 milli" + assertEquals(800L, appender.maxHistoryMs) + appender.maxHistory = "500 seconds" + assertEquals(500 * 1000L, appender.maxHistoryMs) + appender.maxHistory = "120 minutes" + assertEquals(120 * MINUTE_MS, appender.maxHistoryMs) + appender.maxHistory = "1 hour" + assertEquals(HOUR_MS, appender.maxHistoryMs) + appender.maxHistory = "7 days" + assertEquals(7 * 24 * HOUR_MS, appender.maxHistoryMs) + } + + // --- getDatabaseFile ---------------------------------------------------- + + @Test + fun `getDatabaseFile uses the given file`() { + val file = tmp.newFile() + assertEquals(file, appender.getDatabaseFile(file.absolutePath)) + } + + @Test + fun `getDatabaseFile falls back to the default database for a directory`() { + assertEquals(defaultDatabaseFile(), appender.getDatabaseFile(tmp.newFolder().absolutePath)) + } + + @Test + fun `getDatabaseFile falls back to the default database for a null filename`() { + assertEquals(defaultDatabaseFile(), appender.getDatabaseFile(null)) + } + + @Test + fun `getDatabaseFile falls back to the default database for an empty filename`() { + assertEquals(defaultDatabaseFile(), appender.getDatabaseFile("")) + } + + @Test + fun `getDatabaseFile falls back to the default database for a blank filename`() { + assertEquals(defaultDatabaseFile(), appender.getDatabaseFile(" \t\n ")) + } + + @Test + fun `getDatabaseFile keeps a filename of control characters`() { + // blank means whitespace (Char.isWhitespace), which control chars are not + assertEquals(File("\u0001"), appender.getDatabaseFile("\u0001")) + } + + @Test + fun `getDatabaseFile falls back to the default database for a filename of Unicode spaces`() { + assertEquals(defaultDatabaseFile(), appender.getDatabaseFile("\u2003")) + } + + // --- start -------------------------------------------------------------- + + @Test + fun `start creates the database and its tables`() { + appender.start() + + assertTrue(appender.isStarted) + assertEquals(setOf("logging_event", "logging_event_property", "logging_event_exception"), tableNames()) + assertStatus(Status.INFO, "db path: ${dbFile.absolutePath}") + assertEquals(emptyList(), problems()) + } + + @Test + fun `start fails when the database file cannot be determined`() { + val spied = spy(appender) + doReturn(null).whenever(spied).getDatabaseFile(anyOrNull()) + + spied.start() + + assertFalse(spied.isStarted) + assertStatus(Status.ERROR, "Cannot determine database filename") + assertFalse(dbFile.exists()) + } + + @Test + fun `start fails when the database cannot be opened`() { + // a regular file can't be the database's parent directory + appender.filename = File(tmp.newFile("not-a-directory"), "logback.db").absolutePath + + appender.start() + + assertFalse(appender.isStarted) + assertIs(assertStatus(Status.ERROR, "Cannot open database").throwable) + } + + @Test + fun `a failed restart leaves the appender stopped`() { + appender.start() + appender.filename = File(tmp.newFile("not-a-directory"), "logback.db").absolutePath + + // isStarted is still set from the first start + appender.start() + try { + assertFalse(appender.isStarted) + assertIs(assertStatus(Status.ERROR, "Cannot open database").throwable) + } finally { + appender.stop() // closes the database of the first start + } + } + + @Test + fun `start fails when the tables cannot be created`() { + // table names qualified with a database that isn't attached + val configured = configureFromXml( + """ + + missing. + + """ + ) + try { + assertFalse(configured.isStarted) + assertIs(assertStatus(Status.ERROR, "Cannot create database tables").throwable) + } finally { + configured.stop() + } + } + + @Test + fun `start fails when the startup log cleanup fails`() { + appender.maxHistory = "1 hour" + appender.logCleaner = SQLiteLogCleaner { _, _ -> throw SQLiteFullException("database or disk is full") } + + appender.start() + try { + assertFalse(appender.isStarted) + assertIs(assertStatus(Status.ERROR, "Cannot create database tables").throwable) + } finally { + appender.stop() + } + } + + @Test + fun `configures from XML with a custom DB name resolver`() { + val configured = configureFromXml( + """ + 2 hours + + app_ + c_ + + """ + ) + try { + assertTrue(configured.isStarted) + assertEquals(dbFile.absolutePath, configured.filename) + assertEquals(2 * HOUR_MS, configured.maxHistoryMs) + + context.getLogger("xml").info("configured from XML") + + assertEquals(setOf("app_logging_event", "app_logging_event_property", "app_logging_event_exception"), tableNames()) + assertEquals( + listOf("configured from XML"), + rows("app_logging_event", "c_event_id").map { it["c_formatted_message"] }, + ) + } finally { + configured.stop() + } + } + + // --- log cleanup -------------------------------------------------------- + + @Test + fun `cleanup occurs at appender startup`() { + startWithMockCleaner("1 hour") + + verify(logCleaner).performLogCleanup(argThat { path == dbFile.absolutePath }, argThat { milliseconds == HOUR_MS }) + } + + @Test + fun `cleanup occurs at startup even when the clock is near the epoch`() { + // e.g. a device whose clock was reset to 1970: less than maxHistory since time 0 + now = MINUTE_MS + + startWithMockCleaner("1 hour") + + verify(logCleaner).performLogCleanup(any(), any()) + } + + @Test + fun `cleanup does not occur before expiration`() { + startWithMockCleaner("1 hour") + + appendEvents(3, stepMs = 0) + + // log cleanup normally runs between logging events once the expiry time + // is exceeded, but nothing expired here, so only the startup cleanup ran + verify(logCleaner, times(1)).performLogCleanup(any(), any()) + assertEquals(3, loggedEvents().size) + } + + @Test + fun `cleanup occurs after every expiration`() { + startWithMockCleaner("$EXPIRY_MS milli") + + // an event every half expiry period: cleanup at startup, then every other event + appendEvents(7, stepMs = EXPIRY_MS / 2) + + verify(logCleaner, times(4)).performLogCleanup(any(), any()) + } + + @Test + fun `cleanup never occurs without maxHistory`() { + startWithMockCleaner(maxHistory = null) + + appendEvents(3, stepMs = 365 * 24 * HOUR_MS) + + verify(logCleaner, never()).performLogCleanup(any(), any()) + assertEquals(3, loggedEvents().size) + } + + @Test + fun `cleanup never occurs with a zero maxHistory`() { + startWithMockCleaner("0 seconds") + + appendEvents(3, stepMs = 365 * 24 * HOUR_MS) + + verify(logCleaner, never()).performLogCleanup(any(), any()) + } + + @Test + fun `stop resets the cleanup schedule`() { + startWithMockCleaner("1 hour") + appender.doAppend(event("before restart")) + + appender.stop() + now += MINUTE_MS + appender.start() + appender.doAppend(event("after restart")) + + // without the reset, the restart would be within the hour since the startup cleanup + verify(logCleaner, times(2)).performLogCleanup(any(), any()) + assertEquals(listOf("before restart", "after restart"), messages()) + } + + @Test + fun `a subclass without a log cleaner skips the cleanup`() { + appender = object : SQLiteAppender() { + override var logCleaner: SQLiteLogCleaner? + get() = null + set(_) {} + }.also { + it.setClock(Clock { now }) + it.filename = dbFile.absolutePath + it.context = context + it.maxHistory = "1 second" + } + appender.start() + appender.doAppend(event("old")) + + now += 2 * 1000 + appender.doAppend(event("new")) + + assertEquals(listOf("old", "new"), messages()) + } + + @Test + fun `a default log cleaner is created on demand`() { + val cleaner = assertNotNull(appender.logCleaner) + + assertSame(cleaner, appender.logCleaner) + } + + @Test + fun `default log cleaner deletes expired events between appends`() { + appender.maxHistory = "1 hour" + appender.start() + + appender.doAppend(event("expired")) + now += 40 * MINUTE_MS + appender.doAppend(event("kept")) + // an hour since the startup cleanup: deletes events up to 1 hour old + now += 40 * MINUTE_MS + appender.doAppend(event("latest")) + + assertEquals(listOf("kept", "latest"), messages()) + } + + @Test + fun `default log cleaner deletes expired events at startup from custom tables`() { + appender.dbNameResolver = SimpleDBNameResolver().apply { setTableNamePrefix("app_") } + appender.start() + appender.doAppend(event("expired")) + now += 40 * MINUTE_MS + appender.doAppend(event("kept")) + appender.stop() + + now += 40 * MINUTE_MS + appender.maxHistory = "1 hour" + appender.start() + + assertEquals(listOf("kept"), messages(table = "app_logging_event")) + assertEquals(emptyList(), problems()) + } + + // --- append ------------------------------------------------------------- + + @Test + fun `append stores the event details`() { + appender.start() + + appender.doAppend(event("hello", level = Level.WARN)) + + val row = loggedEvents().single() + assertEquals( + mapOf( + "event_id" to "1", + "timestmp" to START_MS.toString(), + "formatted_message" to "hello", + "logger_name" to LOGGER_NAME, + "level_string" to "WARN", + "thread_name" to THREAD_NAME, + "reference_flag" to "0", + "arg0" to null, + "arg1" to null, + "arg2" to null, + "arg3" to null, + "caller_filename" to "Caller.java", + "caller_class" to "com.example.Caller", + "caller_method" to "doWork", + "caller_line" to "42", + ), + row, + ) + assertEquals(emptyList(), properties()) + assertEquals(emptyList(), exceptionLines()) + assertEquals(emptyList(), problems()) + } + + @Test + fun `append stores the caller of the logging statement`() { + appender.start() + val logger = context.getLogger("caller") + logger.addAppender(appender) + + val here = Throwable().stackTrace[0] + logger.info("where am I?") // must stay on the line after `here` + + val row = loggedEvents().single() + assertEquals(here.fileName, row["caller_filename"]) + assertEquals(here.className, row["caller_class"]) + assertEquals(here.methodName, row["caller_method"]) + assertEquals((here.lineNumber + 1).toString(), row["caller_line"]) + } + + @Test + fun `append stores the first four arguments`() { + appender.start() + + appender.doAppend(event("{} {} {} {} {}", args = arrayOf("one", 2, 3.5, 'c', "five"))) + + val row = loggedEvents().single() + assertEquals("one 2 3.5 c five", row["formatted_message"]) + assertEquals(listOf("one", "2", "3.5", "c"), row.args()) + } + + @Test + fun `append leaves the columns of missing arguments null`() { + appender.start() + + appender.doAppend(event("{} {}", args = arrayOf("one", "two"))) + + assertEquals(listOf("one", "two", null, null), loggedEvents().single().args()) + } + + @Test + fun `append stores null arguments as empty strings`() { + appender.start() + val nullString = mock() + doReturn(null).whenever(nullString).toString() + + appender.doAppend(event("{} {}", args = arrayOf(null, nullString))) + + val row = loggedEvents().single() + assertEquals("null null", row["formatted_message"]) + assertEquals(listOf("", "", null, null), row.args()) + } + + @Test + fun `append truncates arguments to 254 characters`() { + appender.start() + val long = "x".repeat(300) + val limit = "y".repeat(254) + + appender.doAppend(event("{} {}", args = arrayOf(long, limit))) + + val row = loggedEvents().single() + assertEquals("$long $limit", row["formatted_message"]) + assertEquals(listOf("x".repeat(254), limit, null, null), row.args()) + } + + @Test + fun `append stores MDC and context properties with MDC taking priority`() { + appender.start() + context.putProperty("app", "demo") + context.putProperty("shared", "from context") + + appender.doAppend(event(mdc = mapOf("user" to "alice", "shared" to "from MDC"))) + + assertEquals("1", loggedEvents().single()["reference_flag"]) + assertEquals( + listOf( + mapOf("event_id" to "1", "mapped_key" to "app", "mapped_value" to "demo"), + mapOf("event_id" to "1", "mapped_key" to "shared", "mapped_value" to "from MDC"), + mapOf("event_id" to "1", "mapped_key" to "user", "mapped_value" to "alice"), + ), + properties(), + ) + } + + @Test + fun `append stores context properties when the MDC is empty`() { + appender.start() + context.putProperty("app", "demo") + + appender.doAppend(event()) + + assertEquals("1", loggedEvents().single()["reference_flag"]) + assertEquals(listOf(mapOf("event_id" to "1", "mapped_key" to "app", "mapped_value" to "demo")), properties()) + } + + @Test + fun `append flags the properties and exception of each event and links them to it`() { + appender.start() + + appender.doAppend(event("plain")) + appender.doAppend(event("with MDC", mdc = mapOf("user" to "alice"))) + appender.doAppend(event("with exception", throwable = RuntimeException("boom"))) + appender.doAppend(event("with both", mdc = mapOf("user" to "bob"), throwable = IllegalStateException("bang"))) + + // reference_flag bits: 1 = has properties, 2 = has an exception + assertEquals( + listOf( + listOf("1", "plain", "0"), + listOf("2", "with MDC", "1"), + listOf("3", "with exception", "2"), + listOf("4", "with both", "3"), + ), + loggedEvents().map { listOf(it["event_id"], it["formatted_message"], it["reference_flag"]) }, + ) + assertEquals(listOf("2" to "alice", "4" to "bob"), properties().map { it["event_id"] to it["mapped_value"] }) + assertEquals( + listOf("3" to "java.lang.RuntimeException: boom", "4" to "java.lang.IllegalStateException: bang"), + exceptionLines().filter { it["i"] == "0" }.map { it["event_id"] to it["trace_line"] }, + ) + } + + @Test + fun `append stores events without MDC or context properties or caller data`() { + appender.start() + val event = event("remote").apply { setLoggerContextRemoteView(LoggerContextVO("remote", null, 0)) } + + appender.doAppend(BareEvent(event)) + + val row = loggedEvents().single() + assertEquals("remote", row["formatted_message"]) + assertEquals("0", row["reference_flag"]) + assertEquals(listOf(null, null, null, null), row.callerColumns()) + assertEquals(emptyList(), properties()) + assertEquals(emptyList(), problems()) + } + + @Test + fun `append stores empty caller data as nulls`() { + appender.start() + + appender.doAppend(event("no frames").apply { callerData = emptyArray() }) + appender.doAppend(event("null frame").apply { callerData = arrayOf(null) }) + + val rows = loggedEvents() + assertEquals(listOf("no frames", "null frame"), rows.map { it["formatted_message"] }) + rows.forEach { assertEquals(listOf(null, null, null, null), it.callerColumns()) } + } + + @Test + fun `append stores a null caller file name as null`() { + appender.start() + val caller = StackTraceElement("com.example.Generated", "invoke", null, -2) + + appender.doAppend(event().apply { callerData = arrayOf(caller) }) + + assertEquals(listOf(null, "com.example.Generated", "invoke", "-2"), loggedEvents().single().callerColumns()) + } + + @Test + fun `append stores the exception with its causes and common frames`() { + appender.start() + val cause = IllegalStateException("inner") + val error = RuntimeException("outer", cause) + + appender.doAppend(event("failed", throwable = error)) + + assertEquals("2", loggedEvents().single()["reference_flag"]) + val expectedLines = buildList { + add("java.lang.RuntimeException: outer") + error.stackTrace.forEach { add("\tat $it") } + // the cause was created one line above the error; its other frames are shared + add("Caused by: java.lang.IllegalStateException: inner") + add("\tat ${cause.stackTrace[0]}") + add("\t... ${cause.stackTrace.size - 1} common frames omitted") + } + val lines = exceptionLines() + assertEquals(expectedLines, lines.map { it["trace_line"] }) + assertEquals(expectedLines.indices.map { it.toString() }, lines.map { it["i"] }) + assertTrue(lines.all { it["event_id"] == "1" }) + } + + @Test + fun `append warns and drops an event the database rejects`() { + appender.start() + execSql( + "CREATE TRIGGER reject_event BEFORE INSERT ON logging_event WHEN NEW.formatted_message = 'rejected' " + + "BEGIN SELECT RAISE(ABORT, 'event rejected'); END" + ) + + appender.doAppend(event("rejected", throwable = RuntimeException("boom"), mdc = mapOf("user" to "alice"))) + appender.doAppend(event("accepted")) + + assertIs(assertStatus(Status.WARN, "Failed to insert loggingEvent").throwable) + assertEquals(listOf("Failed to insert loggingEvent"), problems()) + assertEquals(listOf("accepted"), messages()) + assertEquals(emptyList(), properties()) + assertEquals(emptyList(), exceptionLines()) + } + + @Test + fun `append rolls back an event whose properties the database rejects`() { + appender.start() + execSql( + "CREATE TRIGGER reject_property BEFORE INSERT ON logging_event_property " + + "BEGIN SELECT RAISE(ABORT, 'property rejected'); END" + ) + + appender.doAppend(event("with MDC", mdc = mapOf("user" to "alice"))) + appender.doAppend(event("without MDC")) + + assertIs(assertStatus(Status.ERROR, "Cannot append event").throwable) + assertEquals(listOf("without MDC"), messages()) + assertEquals(emptyList(), properties()) + } + + @Test + fun `append rolls back an event whose exception the database rejects`() { + appender.start() + execSql( + "CREATE TRIGGER reject_exception BEFORE INSERT ON logging_event_exception " + + "BEGIN SELECT RAISE(ABORT, 'exception rejected'); END" + ) + + appender.doAppend(event("with exception", throwable = RuntimeException("boom"))) + appender.doAppend(event("without exception")) + + assertIs(assertStatus(Status.ERROR, "Cannot append event").throwable) + assertEquals(listOf("without exception"), messages()) + assertEquals(emptyList(), exceptionLines()) + } + + @Test + fun `append closes every statement it compiles`() { + val db = mockDatabase(inTransaction = true) + startWithDatabase(db) + + appender.doAppend(event(mdc = mapOf("user" to "alice"), throwable = RuntimeException("boom"))) + + assertEquals(emptyList(), problems()) + // one statement each for the event, its properties and its exception + assertEquals(3, compiledStatements.size) + compiledStatements.forEach { verify(it).close() } + verify(db).setTransactionSuccessful() + verify(db).endTransaction() + } + + @Test + fun `append reports a transaction that fails to begin without ending it`() { + val db = mockDatabase(inTransaction = false) + doThrow(SQLiteDatabaseLockedException("database is locked")).whenever(db).beginTransaction() + startWithDatabase(db) + + appender.doAppend(event()) + + // the original error is reported, not one from ending a transaction that never began + assertIs(assertStatus(Status.ERROR, "Cannot append event").throwable) + val statement = compiledStatements.single() + verify(statement, never()).executeInsert() + verify(statement).close() + verify(db, never()).endTransaction() + } + + @Test + fun `append does not end a transaction that is no longer active`() { + // a real database always has an active transaction here; only a mock can end it early + val db = mockDatabase(inTransaction = false) + startWithDatabase(db) + + appender.doAppend(event()) + + assertEquals(emptyList(), problems()) + val statement = compiledStatements.single() + verify(statement).executeInsert() + verify(statement).close() + verify(db).setTransactionSuccessful() + verify(db, never()).endTransaction() + } + + @Test + fun `append does nothing before start`() { + appender.append(event()) + + assertFalse(dbFile.exists()) + assertEquals(emptyList(), context.statusManager.copyOfStatusList) + } + + // --- stop ----------------------------------------------------------------- + + @Test + fun `stop before start does nothing`() { + appender.stop() + + assertFalse(appender.isStarted) + assertEquals(emptyList(), context.statusManager.copyOfStatusList) + } + + @Test + fun `stop closes the database`() { + appender.start() + appender.doAppend(event("before stop")) + + appender.stop() + + assertAppendFailsOnClosedDatabase() + assertEquals(listOf("before stop"), messages()) + } + + // --- helpers ------------------------------------------------------------ + + private fun newAppender() = SQLiteAppender().also { + it.setClock(Clock { now }) + it.filename = dbFile.absolutePath + it.context = context + } + + /** + * A mock database that compiles each SQL statement into a new mock statement + * (recorded in [compiledStatements]) that inserts row 1. When it is not + * [inTransaction], endTransaction() fails as the real one does. + */ + private fun mockDatabase(inTransaction: Boolean) = mock().also { db -> + whenever(db.compileStatement(any())).thenAnswer { + mock { on { executeInsert() } doReturn 1L }.also { compiledStatements += it } + } + whenever(db.inTransaction()).thenReturn(inTransaction) + if (!inTransaction) { + doThrow(IllegalStateException("Cannot perform this operation because there is no current transaction.")) + .whenever(db).endTransaction() + } + } + + /** Starts the appender with [db] as the database it opens */ + private fun startWithDatabase(db: SQLiteDatabase) { + mockStatic(SQLiteDatabase::class.java).use { sqlite -> + sqlite.`when` { + SQLiteDatabase.openOrCreateDatabase(any(), anyOrNull()) + }.thenReturn(db) + appender.start() + } + assertTrue(appender.isStarted) + } + + private fun startWithMockCleaner(maxHistory: String?) { + maxHistory?.let { appender.maxHistory = it } + appender.logCleaner = logCleaner + appender.start() + } + + private fun appendEvents(count: Int, stepMs: Long) { + repeat(count) { + appender.doAppend(event("i=$it")) + now += stepMs + } + } + + private fun event( + message: String = "message", + args: Array? = null, + throwable: Throwable? = null, + mdc: Map = emptyMap(), + level: Level = Level.INFO, + ) = LoggingEvent(Logger::class.java.name, context.getLogger(LOGGER_NAME), level, message, throwable, args).apply { + timeStamp = now + threadName = THREAD_NAME + mdcPropertyMap = mdc + callerData = arrayOf(StackTraceElement("com.example.Caller", "doWork", "Caller.java", 42)) + } + + private fun configureFromXml(appenderXml: String): SQLiteAppender { + val xml = """ + + + ${dbFile.absolutePath} + $appenderXml + + + + """ + JoranConfigurator().also { it.context = context }.doConfigure(xml.byteInputStream()) + return context.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME).getAppender("sqlite") as SQLiteAppender + } + + private fun defaultDatabaseFile() = + File(RuntimeEnvironment.getApplication().getDatabasePath("logback.db").absolutePath) + + private fun assertStatus(level: Int, message: String): Status { + val statuses = context.statusManager.copyOfStatusList + return assertNotNull( + statuses.singleOrNull { it.level == level && it.message == message }, + "expected a single status \"$message\" in $statuses", + ) + } + + /** + * Appends an event after the appender closed its database. stop() does not + * clear isStarted, so the event reaches the closed database. + */ + private fun assertAppendFailsOnClosedDatabase() { + appender.doAppend(event("after close")) + + assertIs(assertStatus(Status.ERROR, "Cannot append event").throwable) + assertFalse("after close" in messages()) + } + + /** Messages of the warnings and errors reported so far */ + private fun problems() = context.statusManager.copyOfStatusList.filter { it.level >= Status.WARN }.map { it.message } + + private fun execSql(sql: String) = + SQLiteDatabase.openDatabase(dbFile.path, null, SQLiteDatabase.OPEN_READWRITE).use { it.execSQL(sql) } + + /** Runs [sql] on a separate connection, returning each row as a map of column name to value */ + private fun query(sql: String): List> = + SQLiteDatabase.openDatabase(dbFile.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> + db.rawQuery(sql, null).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add(cursor.columnNames.associateWith { cursor.getString(cursor.getColumnIndexOrThrow(it)) }) + } + } + } + } + + private fun rows(table: String, orderBy: String) = query("SELECT * FROM $table ORDER BY $orderBy") + + private fun tableNames() = + query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT IN ('android_metadata', 'sqlite_sequence')") + .map { it["name"] } + .toSet() + + private fun loggedEvents() = rows("logging_event", "event_id") + + private fun messages(table: String = "logging_event") = rows(table, "event_id").map { it["formatted_message"] } + + private fun properties() = rows("logging_event_property", "event_id, mapped_key") + + private fun exceptionLines() = rows("logging_event_exception", "event_id, i") + + private fun Map.args() = listOf(this["arg0"], this["arg1"], this["arg2"], this["arg3"]) + + private fun Map.callerColumns() = + listOf(this["caller_filename"], this["caller_class"], this["caller_method"], this["caller_line"]) + + /** An [ILoggingEvent] implementation that provides neither an MDC map nor caller data */ + private class BareEvent(event: ILoggingEvent) : ILoggingEvent by event { + override fun getMDCPropertyMap(): Map? = null + override fun getCallerData(): Array? = null + } + + private companion object { + const val START_MS = 1_700_000_000_000L + const val MINUTE_MS = 60 * 1000L + const val HOUR_MS = 60 * MINUTE_MS + const val EXPIRY_MS = 500L + const val LOGGER_NAME = "ch.qos.logback.classic.android.SQLiteAppenderTest" + const val THREAD_NAME = "test-thread" + } +} diff --git a/logback-android/src/test/kotlin/ch/qos/logback/classic/android/SystemClockTest.kt b/logback-android/src/test/kotlin/ch/qos/logback/classic/android/SystemClockTest.kt new file mode 100644 index 000000000..8762d16cf --- /dev/null +++ b/logback-android/src/test/kotlin/ch/qos/logback/classic/android/SystemClockTest.kt @@ -0,0 +1,39 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.classic.android + +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertTrue + +/** + * Tests the [SystemClock] class + */ +@RunWith(RobolectricTestRunner::class) +class SystemClockTest { + + @Test + fun `reads the current time from System#currentTimeMillis`() { + val clock: Clock = SystemClock() + + val before = System.currentTimeMillis() + val now = clock.currentTimeMillis() + val after = System.currentTimeMillis() + + assertTrue(now in before..after, "expected $now in [$before, $after]") + } +} diff --git a/logback-android/src/test/kotlin/ch/qos/logback/core/android/AndroidContextUtilTest.kt b/logback-android/src/test/kotlin/ch/qos/logback/core/android/AndroidContextUtilTest.kt new file mode 100644 index 000000000..65035ff80 --- /dev/null +++ b/logback-android/src/test/kotlin/ch/qos/logback/core/android/AndroidContextUtilTest.kt @@ -0,0 +1,678 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.android + +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.PackageInfo +import android.os.Build +import android.os.Environment +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.core.CoreConstants +import net.bytebuddy.ByteBuddy +import net.bytebuddy.description.modifier.Ownership +import net.bytebuddy.description.modifier.Visibility +import net.bytebuddy.implementation.MethodCall +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowEnvironment +import org.robolectric.util.ReflectionHelpers +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** Hidden framework class that AndroidContextUtil falls back to via reflection */ +private const val APP_GLOBALS = "android.app.AppGlobals" + +/** Version code 42 with a version code major of 1 (only [PackageInfo.getLongVersionCode] sees the major) */ +private const val LONG_VERSION_CODE = (1L shl 32) or 42L + +/** + * Tests the [AndroidContextUtil] class + * + * @author Anthony Trinh + */ +@RunWith(RobolectricTestRunner::class) +class AndroidContextUtilTest { + private lateinit var app: Application + private lateinit var contextUtil: AndroidContextUtil + + @Before + fun before() { + ShadowEnvironment.reset() + // Ensure no context leaked in from a prior test's setApplicationContext() call + AndroidContextUtil.setApplicationContext(null) + app = RuntimeEnvironment.getApplication() + contextUtil = AndroidContextUtil() + } + + @After + fun after() { + // Reset the static holder so it doesn't leak into other tests + AndroidContextUtil.setApplicationContext(null) + } + + // --- context resolution ------------------------------------------------- + + @Test + fun `setApplicationContext is used instead of reflection workaround`() { + val provided = StandaloneContext(app, "com.example.provided") + AndroidContextUtil.setApplicationContext(provided) + assertSame(provided, AndroidContextUtil.getContext()) + } + + @Test + fun `setApplicationContext is callable from Java as a static method`() { + // Java code (e.g. an Application's onCreate) calls the static bridge, not the companion + val provided = StandaloneContext(app, "com.example.provided") + AndroidContextUtil::class.java.getMethod("setApplicationContext", Context::class.java).invoke(null, provided) + assertSame(provided, AndroidContextUtil.getContext()) + } + + @Test + fun `setApplicationContext retains the application context of the given context`() { + val appContext = StandaloneContext(app, "com.example.provided") + val component = ComponentContext(appContext) + + AndroidContextUtil.setApplicationContext(component) + repeat(2) { assertSame(appContext, AndroidContextUtil.getContext()) } + + // the component itself isn't retained (it could leak), so it's asked only once + assertEquals(1, component.applicationContextRequests) + } + + @Test + fun `no-arg constructor picks up provided context`() { + AndroidContextUtil.setApplicationContext(StandaloneContext(app, "com.example.provided")) + // The no-arg constructor resolves its context through getContext(), so it + // should observe the externally provided context. + assertEquals("com.example.provided", AndroidContextUtil().packageName) + } + + @Test + fun `setApplicationContext null falls back to reflection workaround`() { + AndroidContextUtil.setApplicationContext(StandaloneContext(app, "com.example.provided")) + AndroidContextUtil.setApplicationContext(null) + assertSame(app, AndroidContextUtil.getContext()) + } + + @Test + fun `constructor uses the application context of the given context`() { + val util = AndroidContextUtil(ComponentContext(StandaloneContext(app, "com.example.provided"))) + assertEquals("com.example.provided", util.packageName) + } + + @Test + @Config(shadows = [ShadowAppGlobalsWithoutApplication::class]) + fun `getContext is null when there is no initial application`() { + assertNull(AndroidContextUtil.getContext()) + assertEquals("", AndroidContextUtil().packageName) + } + + @Test + @Config(shadows = [ShadowFailingAppGlobals::class]) + fun `getContext is null when looking up the initial application fails`() { + assertNull(AndroidContextUtil.getContext()) + } + + @Test + fun `getContext is null when AppGlobals does not exist`() { + assertNull(getContextWithAppGlobals(null)) + } + + @Test + fun `getContext is null when AppGlobals has no getInitialApplication method`() { + val appGlobals = ByteBuddy().subclass(Any::class.java).name(APP_GLOBALS).make().bytes + assertNull(getContextWithAppGlobals(appGlobals)) + } + + @Test + fun `getContext is null when getInitialApplication is inaccessible`() { + // control: the same AppGlobals with a public getInitialApplication() works + assertSame(app, getContextWithAppGlobals(appGlobalsReturningApplication(Visibility.PUBLIC))) + + assertNull(getContextWithAppGlobals(appGlobalsReturningApplication(Visibility.PRIVATE))) + } + + // --- external storage --------------------------------------------------- + + @Test + fun `getMountedExternalStorageDirectoryPath returns path when mounted`() { + ShadowEnvironment.setExternalStorageState(Environment.MEDIA_MOUNTED) + assertEquals(externalFilesDir().absolutePath, contextUtil.mountedExternalStorageDirectoryPath) + } + + @Test + fun `getMountedExternalStorageDirectoryPath returns path when mounted read-only`() { + ShadowEnvironment.setExternalStorageState(Environment.MEDIA_MOUNTED_READ_ONLY) + assertEquals(externalFilesDir().absolutePath, contextUtil.mountedExternalStorageDirectoryPath) + } + + @Test + fun `getMountedExternalStorageDirectoryPath returns null when removed`() = + assertNotMountedWhen(Environment.MEDIA_REMOVED) + + @Test + fun `getMountedExternalStorageDirectoryPath returns null when bad removal`() = + assertNotMountedWhen(Environment.MEDIA_BAD_REMOVAL) + + @Test + fun `getMountedExternalStorageDirectoryPath returns null when checking`() = + assertNotMountedWhen(Environment.MEDIA_CHECKING) + + @Test + fun `getMountedExternalStorageDirectoryPath returns null when ejecting`() = + assertNotMountedWhen(Environment.MEDIA_EJECTING) + + @Test + fun `getMountedExternalStorageDirectoryPath returns null when no fs`() = + assertNotMountedWhen(Environment.MEDIA_NOFS) + + @Test + fun `getMountedExternalStorageDirectoryPath returns null when unknown`() = + assertNotMountedWhen(Environment.MEDIA_UNKNOWN) + + @Test + fun `getMountedExternalStorageDirectoryPath returns null when unmountable`() = + assertNotMountedWhen(Environment.MEDIA_UNMOUNTABLE) + + @Test + fun `getMountedExternalStorageDirectoryPath returns null when shared`() = + assertNotMountedWhen(Environment.MEDIA_SHARED) + + @Test + fun `getMountedExternalStorageDirectoryPath returns null when unmounted`() = + assertNotMountedWhen(Environment.MEDIA_UNMOUNTED) + + // issue #315 + @Test + @Config(shadows = [ShadowEnvironmentWithoutStorageVolume::class]) + fun `getMountedExternalStorageDirectoryPath returns null when the storage state is unavailable`() { + assertFailsWith { Environment.getExternalStorageState() } + assertNull(contextUtil.mountedExternalStorageDirectoryPath) + } + + @Test + fun `getExternalStorageDirectoryPath is the external files dir on Q and later`() { + val path = withSdkInt(Build.VERSION_CODES.Q) { contextUtil.externalStorageDirectoryPath } + assertEquals(externalFilesDir().absolutePath, path) + } + + @Test + @Suppress("DEPRECATION") + fun `getExternalStorageDirectoryPath is the shared storage root before Q`() { + val path = withSdkInt(Build.VERSION_CODES.P) { contextUtil.externalStorageDirectoryPath } + assertEquals(Environment.getExternalStorageDirectory().absolutePath, path) + assertNotEquals(externalFilesDir().absolutePath, path) + } + + // issue #228 + @Test + fun `createAppExternalStorageDirs creates missing dirs`() { + val dirs = deleteAppExternalStorageDirs() + + contextUtil.createAppExternalStorageDirs() + + dirs.forEach { assertTrue(it.isDirectory, "$it") } + } + + // issue #228 + @Test + fun `createAppExternalStorageDirs is noop without context`() { + val dirs = deleteAppExternalStorageDirs() + + AndroidContextUtil(null).createAppExternalStorageDirs() // must not throw + + dirs.forEach { assertFalse(it.exists(), "$it") } + } + + // issue #431 + @Test + fun `createAppExternalStorageDirs tolerates broken external storage`() { + var requests = 0 + val util = AndroidContextUtil(object : StandaloneContext(app) { + override fun getExternalFilesDir(type: String?): File? { + requests++ + throw IllegalStateException("Failed to resolve external storage volume") + } + }) + + util.createAppExternalStorageDirs() // must not throw + + assertEquals(1, requests) + } + + // --- directory paths ---------------------------------------------------- + + @Test + fun `getFilesDirectoryPath is the app files dir`() { + assertEquals(app.filesDir.absolutePath, contextUtil.filesDirectoryPath) + assertTrue(contextUtil.filesDirectoryPath.endsWith("/files")) + } + + @Test + fun `getExternalFilesDirectoryPath is the app external files dir`() { + assertEquals(externalFilesDir().absolutePath, contextUtil.externalFilesDirectoryPath) + } + + @Test + fun `getNoBackupFilesDirectoryPath is the app no-backup dir`() { + assertEquals(app.noBackupFilesDir.absolutePath, contextUtil.noBackupFilesDirectoryPath) + assertTrue(contextUtil.noBackupFilesDirectoryPath.endsWith("/no_backup")) + } + + @Test + fun `getNoBackupFilesDirectoryPath is the app no-backup dir as of Lollipop`() { + val path = withSdkInt(Build.VERSION_CODES.LOLLIPOP) { contextUtil.noBackupFilesDirectoryPath } + assertEquals(app.noBackupFilesDir.absolutePath, path) + } + + @Test + fun `getNoBackupFilesDirectoryPath is empty before Lollipop`() { + val path = withSdkInt(Build.VERSION_CODES.KITKAT_WATCH) { contextUtil.noBackupFilesDirectoryPath } + assertEquals("", path) + } + + @Test + fun `getCacheDirectoryPath is the app cache dir`() { + assertEquals(app.cacheDir.absolutePath, contextUtil.cacheDirectoryPath) + assertTrue(contextUtil.cacheDirectoryPath.endsWith("/cache")) + } + + @Test + fun `getExternalCacheDirectoryPath is the app external cache dir`() { + assertEquals(externalCacheDir().absolutePath, contextUtil.externalCacheDirectoryPath) + } + + @Test + fun `external dir paths are empty when shared storage is unavailable`() { + val util = AndroidContextUtil(WithoutSharedStorage(app)) + assertEquals("", util.externalFilesDirectoryPath) + assertEquals("", util.externalCacheDirectoryPath) + } + + @Test + fun `getDatabaseDirectoryPath is the parent of the app databases`() { + assertEquals(app.getDatabasePath("app.db").parent, contextUtil.databaseDirectoryPath) + assertTrue(contextUtil.databaseDirectoryPath.endsWith("/databases")) + } + + @Test + fun `getDatabasePath is the path of the named app database`() { + assertEquals(app.getDatabasePath("app.db").absolutePath, contextUtil.getDatabasePath("app.db")) + } + + @Test + fun `paths are empty without context`() { + val util = AndroidContextUtil(null) + assertEquals("", util.filesDirectoryPath) + assertEquals("", util.externalFilesDirectoryPath) + assertEquals("", util.externalStorageDirectoryPath) + assertEquals("", util.noBackupFilesDirectoryPath) + assertEquals("", util.cacheDirectoryPath) + assertEquals("", util.externalCacheDirectoryPath) + assertEquals("", util.databaseDirectoryPath) + assertEquals("", util.getDatabasePath("app.db")) + } + + // --- package info ------------------------------------------------------- + + @Test + fun `getPackageName is the app package name`() { + assertEquals(app.packageName, contextUtil.packageName) + } + + @Test + fun `getVersionCode is the long version code on Pie and later`() { + installedPackageInfo().longVersionCode = LONG_VERSION_CODE + val versionCode = withSdkInt(Build.VERSION_CODES.P) { contextUtil.versionCode } + assertEquals(LONG_VERSION_CODE.toString(), versionCode) + } + + @Test + fun `getVersionCode is the int version code before Pie`() { + installedPackageInfo().longVersionCode = LONG_VERSION_CODE + val versionCode = withSdkInt(Build.VERSION_CODES.O_MR1) { contextUtil.versionCode } + assertEquals("42", versionCode) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `version code and name come from the package manager on Tiramisu and later`() { + // API 33+ looks the package up with PackageManager.PackageInfoFlags + installedPackageInfo().longVersionCode = LONG_VERSION_CODE + installedPackageInfo().versionName = "1.2.3" + + assertEquals(LONG_VERSION_CODE.toString(), contextUtil.versionCode) + assertEquals("1.2.3", contextUtil.versionName) + } + + @Test + fun `getVersionName is the app version name`() { + installedPackageInfo().versionName = "1.2.3" + assertEquals("1.2.3", contextUtil.versionName) + } + + @Test + fun `getVersionName is empty when the app has no version name`() { + installedPackageInfo().versionName = null + assertEquals("", contextUtil.versionName) + } + + @Test + fun `version code and name are empty when the package is not installed`() { + val util = AndroidContextUtil(StandaloneContext(app, "com.example.not.installed")) + assertEquals("", util.versionCode) + assertEquals("", util.versionName) + } + + @Test + fun `package info is empty without context`() { + val util = AndroidContextUtil(null) + assertEquals("", util.packageName) + assertEquals("", util.versionCode) + assertEquals("", util.versionName) + } + + // --- setupProperties ---------------------------------------------------- + + @Test + fun setupProperties() { + ShadowEnvironment.setExternalStorageState(Environment.MEDIA_MOUNTED) + installedPackageInfo().apply { + longVersionCode = LONG_VERSION_CODE + versionName = "1.2.3" + } + val loggerContext = LoggerContext() + + assertNull(loggerContext.getProperty(CoreConstants.DATA_DIR_KEY)) + assertNull(loggerContext.getProperty(CoreConstants.EXT_DIR_KEY)) + assertNull(loggerContext.getProperty(CoreConstants.VERSION_CODE_KEY)) + assertNull(loggerContext.getProperty(CoreConstants.VERSION_NAME_KEY)) + assertNull(loggerContext.getProperty(CoreConstants.PACKAGE_NAME_KEY)) + + contextUtil.setupProperties(loggerContext) + + assertEquals(app.filesDir.absolutePath, loggerContext.getProperty(CoreConstants.DATA_DIR_KEY)) + // the external storage dir is the external files dir on Q and later + assertEquals(externalFilesDir().absolutePath, loggerContext.getProperty(CoreConstants.EXT_DIR_KEY)) + assertEquals(LONG_VERSION_CODE.toString(), loggerContext.getProperty(CoreConstants.VERSION_CODE_KEY)) + assertEquals("1.2.3", loggerContext.getProperty(CoreConstants.VERSION_NAME_KEY)) + assertEquals(app.packageName, loggerContext.getProperty(CoreConstants.PACKAGE_NAME_KEY)) + } + + // issue #181 + @Test + fun `setupProperties includes external dirs`() { + val loggerContext = LoggerContext() + + assertNull(loggerContext.getProperty(CoreConstants.EXT_FILES_DIR_KEY)) + assertNull(loggerContext.getProperty(CoreConstants.EXT_CACHE_DIR_KEY)) + + contextUtil.setupProperties(loggerContext) + + assertEquals(externalFilesDir().absolutePath, loggerContext.getProperty(CoreConstants.EXT_FILES_DIR_KEY)) + assertEquals(externalCacheDir().absolutePath, loggerContext.getProperty(CoreConstants.EXT_CACHE_DIR_KEY)) + } + + @Test + fun `setupProperties omits EXT_DIR when external storage is not mounted`() { + ShadowEnvironment.setExternalStorageState(Environment.MEDIA_UNMOUNTED) + val loggerContext = LoggerContext() + + contextUtil.setupProperties(loggerContext) + + assertNoProperty(loggerContext, CoreConstants.EXT_DIR_KEY) + } + + @Test + fun `setupProperties omits external dirs when shared storage is unavailable`() { + val loggerContext = LoggerContext() + + AndroidContextUtil(WithoutSharedStorage(app)).setupProperties(loggerContext) + + assertNoProperty(loggerContext, CoreConstants.EXT_FILES_DIR_KEY) + assertNoProperty(loggerContext, CoreConstants.EXT_CACHE_DIR_KEY) + assertEquals(app.filesDir.absolutePath, loggerContext.getProperty(CoreConstants.DATA_DIR_KEY)) + } + + @Test + fun `setupProperties omits external dirs whose paths are null`() { + // a subclass may report an unavailable dir as null rather than empty + val util = spy(AndroidContextUtil(app)) + doReturn(null).whenever(util).externalFilesDirectoryPath + doReturn(null).whenever(util).externalCacheDirectoryPath + val loggerContext = LoggerContext() + + util.setupProperties(loggerContext) + + assertNoProperty(loggerContext, CoreConstants.EXT_FILES_DIR_KEY) + assertNoProperty(loggerContext, CoreConstants.EXT_CACHE_DIR_KEY) + assertEquals(app.filesDir.absolutePath, loggerContext.getProperty(CoreConstants.DATA_DIR_KEY)) + } + + @Test + fun `setupProperties without context sets empty values`() { + // EXT_DIR depends on the storage state rather than on the context + ShadowEnvironment.setExternalStorageState(Environment.MEDIA_REMOVED) + val loggerContext = LoggerContext() + + AndroidContextUtil(null).setupProperties(loggerContext) + + assertEquals("", loggerContext.getProperty(CoreConstants.DATA_DIR_KEY)) + assertNoProperty(loggerContext, CoreConstants.EXT_DIR_KEY) + assertNoProperty(loggerContext, CoreConstants.EXT_FILES_DIR_KEY) + assertNoProperty(loggerContext, CoreConstants.EXT_CACHE_DIR_KEY) + assertEquals("", loggerContext.getProperty(CoreConstants.PACKAGE_NAME_KEY)) + assertEquals("", loggerContext.getProperty(CoreConstants.VERSION_CODE_KEY)) + assertEquals("", loggerContext.getProperty(CoreConstants.VERSION_NAME_KEY)) + } + + // --- containsProperties ------------------------------------------------- + + // issue #181 + @Test + fun `containsProperties detects external dir keys`() { + assertTrue(AndroidContextUtil.containsProperties("\${EXT_FILES_DIR}/logs/app.log")) + assertTrue(AndroidContextUtil.containsProperties("\${EXT_CACHE_DIR}/logs/app.log")) + assertFalse(AndroidContextUtil.containsProperties("/absolute/path/app.log")) + } + + @Test + fun `containsProperties detects each Android property`() { + val keys = listOf( + CoreConstants.DATA_DIR_KEY, + CoreConstants.EXT_DIR_KEY, + CoreConstants.EXT_FILES_DIR_KEY, + CoreConstants.EXT_CACHE_DIR_KEY, + CoreConstants.PACKAGE_NAME_KEY, + CoreConstants.VERSION_CODE_KEY, + CoreConstants.VERSION_NAME_KEY, + ) + for (key in keys) { + assertTrue(AndroidContextUtil.containsProperties("\${$key}/app.log"), key) + } + } + + @Test + fun `containsProperties ignores other properties`() { + assertFalse(AndroidContextUtil.containsProperties("\${LOG_DIR}/\${HOSTNAME}.log")) + } + + // --- helpers ------------------------------------------------------------ + + private fun externalFilesDir(): File = checkNotNull(app.getExternalFilesDir(null)) + + private fun externalCacheDir(): File = checkNotNull(app.externalCacheDir) + + /** Deletes the app-specific external storage dirs (issue #228), returning them */ + private fun deleteAppExternalStorageDirs(): List = + listOf(externalFilesDir(), externalCacheDir()).onEach { assertTrue(it.delete(), "$it") } + + private fun installedPackageInfo(): PackageInfo = + checkNotNull(shadowOf(app.packageManager).getInternalMutablePackageInfo(app.packageName)) + + /** Asserts that [key] isn't set at all (not even to `null`) */ + private fun assertNoProperty(loggerContext: LoggerContext, key: String) = + assertFalse(key in loggerContext.copyOfPropertyMap, "unexpected property $key") + + private fun assertNotMountedWhen(state: String) { + ShadowEnvironment.setExternalStorageState(state) + assertNull(contextUtil.mountedExternalStorageDirectoryPath) + } + + private fun withSdkInt(sdkInt: Int, block: () -> T): T { + val original = Build.VERSION.SDK_INT + ReflectionHelpers.setStaticField(Build.VERSION::class.java, "SDK_INT", sdkInt) + return try { + block() + } finally { + ReflectionHelpers.setStaticField(Build.VERSION::class.java, "SDK_INT", original) + } + } + + /** An `AppGlobals` class whose static `getInitialApplication()` has the given visibility */ + private fun appGlobalsReturningApplication(visibility: Visibility): ByteArray = + ByteBuddy().subclass(Any::class.java).name(APP_GLOBALS) + .defineMethod("getInitialApplication", Application::class.java, visibility, Ownership.STATIC) + .intercept(MethodCall.invoke(RuntimeEnvironment::class.java.getMethod("getApplication"))) + .make().bytes + + /** + * Calls `getContext()` on a fresh copy of [AndroidContextUtil] whose + * `android.app.AppGlobals` is the class defined by [appGlobals], or + * doesn't exist if that's `null` + */ + private fun getContextWithAppGlobals(appGlobals: ByteArray?): Any? { + val loader = AppGlobalsReplacingClassLoader(checkNotNull(javaClass.classLoader), appGlobals) + val util = loader.loadClass(AndroidContextUtil::class.java.name) + assertNotEquals>(AndroidContextUtil::class.java, util) + return util.getDeclaredMethod("getContext").apply { isAccessible = true }.invoke(null) + } +} + +/** + * A context that is its own application context (like an [Application]), so + * [AndroidContextUtil] uses it as is, reporting the given package name + */ +private open class StandaloneContext( + base: Context, + private val reportedPackageName: String = base.packageName, +) : ContextWrapper(base) { + override fun getApplicationContext(): Context = this + override fun getPackageName(): String = reportedPackageName +} + +/** + * A component's context (like an Activity's) whose application context is + * [application]. It reports a package name of its own, so that any use of it in + * place of its application context is detectable. + */ +private class ComponentContext(private val application: Context) : ContextWrapper(application) { + var applicationContextRequests = 0 + private set + + override fun getApplicationContext(): Context { + applicationContextRequests++ + return application + } + + override fun getPackageName(): String = "com.example.component" +} + +/** An app whose shared storage is unavailable (e.g., not mounted) */ +private class WithoutSharedStorage(base: Context) : StandaloneContext(base) { + override fun getExternalFilesDir(type: String?): File? = null + override fun getExternalCacheDir(): File? = null +} + +/** + * Child-first class loader that re-defines [AndroidContextUtil] (and its nested + * classes) from the class path, so that its reflective lookup of + * `android.app.AppGlobals` resolves through this loader: to the class defined by + * [appGlobals], or to none (`ClassNotFoundException`) if that's `null` + */ +private class AppGlobalsReplacingClassLoader( + private val delegate: ClassLoader, + private val appGlobals: ByteArray?, +) : ClassLoader(delegate) { + private val utilClassName = AndroidContextUtil::class.java.name + + override fun loadClass(name: String, resolve: Boolean): Class<*> = synchronized(getClassLoadingLock(name)) { + findLoadedClass(name) ?: when { + name == APP_GLOBALS -> define(name, appGlobals ?: throw ClassNotFoundException(name)) + name == utilClassName || name.startsWith("$utilClassName\$") -> define(name, bytesOf(name)) + else -> super.loadClass(name, resolve) + } + } + + private fun bytesOf(className: String): ByteArray { + val resource = className.replace('.', '/') + ".class" + return checkNotNull(delegate.getResourceAsStream(resource)) { resource }.use { it.readBytes() } + } + + private fun define(name: String, bytes: ByteArray): Class<*> = defineClass(name, bytes, 0, bytes.size) +} + +/** `AppGlobals` in a process that has no application (yet) */ +@Implements(className = APP_GLOBALS) +private class ShadowAppGlobalsWithoutApplication { + companion object { + @JvmStatic + @Implementation + fun getInitialApplication(): Application? = null + } +} + +/** `AppGlobals` whose lookup of the initial application fails */ +@Implements(className = APP_GLOBALS) +private class ShadowFailingAppGlobals { + companion object { + @JvmStatic + @Implementation + fun getInitialApplication(): Application = throw IllegalStateException("no activity thread") + } +} + +/** + * `Environment` of a process without an external storage volume, e.g. a + * shell-context tool started via app_process (issue #315) + */ +@Implements(Environment::class) +private class ShadowEnvironmentWithoutStorageVolume { + companion object { + @JvmStatic + @Implementation + fun getExternalStorageState(): String = throw ArrayIndexOutOfBoundsException("length=0; index=0") + } +} diff --git a/logback-android/src/test/kotlin/ch/qos/logback/core/android/SystemPropertiesProxyTest.kt b/logback-android/src/test/kotlin/ch/qos/logback/core/android/SystemPropertiesProxyTest.kt new file mode 100644 index 000000000..46c296d1c --- /dev/null +++ b/logback-android/src/test/kotlin/ch/qos/logback/core/android/SystemPropertiesProxyTest.kt @@ -0,0 +1,323 @@ +/** + * Copyright 2019 Anthony Trinh + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ch.qos.logback.core.android + +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowSystemProperties +import kotlin.reflect.KClass +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * Tests the [SystemPropertiesProxy] class + * + * Values of the real `android.os.SystemProperties` are set with Robolectric's + * [ShadowSystemProperties], which resets them after each test. The other + * cases load a fake `android.os.SystemProperties` from a custom class loader. + * Each test uses a fresh proxy (from the private constructor) so that the + * shared singleton, which `OptionHelper` uses, is never reconfigured. + */ +@RunWith(RobolectricTestRunner::class) +class SystemPropertiesProxyTest { + + @Test + fun `getInstance returns one shared proxy of the Android system properties`() { + ShadowSystemProperties.override(KEY, "hello") + + val proxy = SystemPropertiesProxy.getInstance() + + assertSame(proxy, SystemPropertiesProxy.getInstance()) + assertEquals("hello", proxy.get(KEY, "fallback")) + assertEquals("fallback", proxy.get(MISSING_KEY, "fallback")) + } + + @Test + fun `get returns the value of a system property`() { + ShadowSystemProperties.override(KEY, "hello") + + assertEquals("hello", newProxy(null).get(KEY, "fallback")) + } + + @Test + fun `get returns the default for a missing system property`() { + val proxy = newProxy(null) + + assertEquals("fallback", proxy.get(MISSING_KEY, "fallback")) + assertNull(proxy.get(MISSING_KEY, null)) + } + + @Test + fun `get returns the default for an empty system property`() { + ShadowSystemProperties.override(KEY, "") + + assertEquals("fallback", newProxy(null).get(KEY, "fallback")) + } + + @Test + fun `getBoolean parses true values`() { + val proxy = newProxy(null) + for (value in listOf("1", "y", "yes", "on", "true")) { + ShadowSystemProperties.override(KEY, value) + + assertEquals(true, proxy.getBoolean(KEY, false), value) + } + } + + @Test + fun `getBoolean parses false values`() { + val proxy = newProxy(null) + for (value in listOf("0", "n", "no", "off", "false")) { + ShadowSystemProperties.override(KEY, value) + + assertEquals(false, proxy.getBoolean(KEY, true), value) + } + } + + @Test + fun `getBoolean returns the default for a missing or unparsable system property`() { + val proxy = newProxy(null) + ShadowSystemProperties.override(KEY, "maybe") + + for (def in listOf(true, false)) { + assertEquals(def, proxy.getBoolean(MISSING_KEY, def)) + assertEquals(def, proxy.getBoolean(KEY, def)) + } + } + + @Test + fun `a null key falls back to the default`() { + // SystemProperties rejects a null key with a NullPointerException, + // which is swallowed like any other exception it throws + val proxy = newProxy(null) + + assertEquals("fallback", proxy.get(null, "fallback")) + for (def in listOf(true, false)) { + assertEquals(def, proxy.getBoolean(null, def)) + } + } + + @Test + fun `setClassLoader looks up SystemProperties in the given class loader`() { + val proxy = newProxy(null) + + proxy.setClassLoader(FakeSystemPropertiesLoader(EchoSystemProperties::class)) + + assertEquals("echo:$KEY:fallback", proxy.get(KEY, "fallback")) + assertEquals(false, proxy.getBoolean(KEY, true)) + } + + @Test + fun `setClassLoader with null uses the class loader of the proxy`() { + val proxy = proxyFor(EchoSystemProperties::class) + ShadowSystemProperties.override(KEY, "yes") + assertEquals("echo:$KEY:fallback", proxy.get(KEY, "fallback")) + assertEquals(false, proxy.getBoolean(KEY, true)) + + proxy.setClassLoader(null) + + assertEquals("yes", proxy.get(KEY, "fallback")) + assertEquals(true, proxy.getBoolean(KEY, true)) + } + + @Test + fun `setClassLoader throws when the class loader has no SystemProperties`() { + val proxy = newProxy(null) + ShadowSystemProperties.override(KEY, "hello") + + assertFailsWith { + proxy.setClassLoader(FakeSystemPropertiesLoader(null)) + } + // the previously loaded SystemProperties is still used + assertEquals("hello", proxy.get(KEY, "fallback")) + } + + @Test + fun `setClassLoader throws when SystemProperties lacks get or getBoolean`() { + // setClassLoader isn't atomic: each method is replaced once it is found + val proxy = newProxy(null) + ShadowSystemProperties.override(KEY, "yes") + + assertFailsWith { + proxy.setClassLoader(FakeSystemPropertiesLoader(NoMethodsSystemProperties::class)) + } + assertEquals("yes", proxy.get(KEY, "fallback")) + assertEquals(true, proxy.getBoolean(KEY, false)) + + assertFailsWith { + proxy.setClassLoader(FakeSystemPropertiesLoader(StringOnlySystemProperties::class)) + } + assertEquals("string-only:$KEY", proxy.get(KEY, "fallback")) + assertEquals(true, proxy.getBoolean(KEY, false)) + } + + @Test + fun `proxy without SystemProperties returns null from get and the default from getBoolean`() { + // the constructor swallows the ClassNotFoundException + val proxy = newProxy(FakeSystemPropertiesLoader(null)) + + assertNull(proxy.get(KEY, "fallback")) + assertEquals(true, proxy.getBoolean(KEY, true)) + assertEquals(false, proxy.getBoolean(KEY, false)) + } + + @Test + fun `proxy whose SystemProperties lacks get and getBoolean returns null and the default`() { + val proxy = proxyFor(NoMethodsSystemProperties::class) + + assertNull(proxy.get(KEY, "fallback")) + assertEquals(true, proxy.getBoolean(KEY, true)) + assertEquals(false, proxy.getBoolean(KEY, false)) + } + + @Test + fun `proxy whose SystemProperties lacks getBoolean still reads strings`() { + val proxy = proxyFor(StringOnlySystemProperties::class) + + assertEquals("string-only:$KEY", proxy.get(KEY, "fallback")) + assertEquals(true, proxy.getBoolean(KEY, true)) + assertEquals(false, proxy.getBoolean(KEY, false)) + } + + @Test + fun `exceptions thrown by SystemProperties fall back to the default`() { + // even an IllegalArgumentException (e.g., for a key that is too long) + // is swallowed, since Method#invoke wraps it + val proxy = proxyFor(ThrowingSystemProperties::class) + + assertEquals("fallback", proxy.get(KEY, "fallback")) + assertEquals(true, proxy.getBoolean(KEY, true)) + assertEquals(false, proxy.getBoolean(KEY, false)) + } + + @Test + fun `get returns the default when SystemProperties returns null or an empty string`() { + assertEquals("fallback", proxyFor(NullSystemProperties::class).get(KEY, "fallback")) + assertEquals("fallback", proxyFor(EmptySystemProperties::class).get(KEY, "fallback")) + assertNull(proxyFor(EmptySystemProperties::class).get(KEY, null)) + } + + @Test + fun `results of the wrong type fall back to the default`() { + val proxy = proxyFor(MistypedSystemProperties::class) + + assertEquals("fallback", proxy.get(KEY, "fallback")) + assertEquals(true, proxy.getBoolean(KEY, true)) + assertEquals(false, proxy.getBoolean(KEY, false)) + } + + @Test + fun `getBoolean returns null when SystemProperties#getBoolean returns null`() { + // getBoolean() returns a java.lang.Boolean and passes the null through + // instead of falling back to the default + assertNull(proxyFor(NullSystemProperties::class).getBoolean(KEY, true)) + } + + @Test + fun `get and getBoolean throw IllegalArgumentException when SystemProperties has instance methods`() { + // SystemProperties' methods are invoked on the Class object itself, + // which Method#invoke rejects for instance methods + val proxy = proxyFor(InstanceMethodSystemProperties::class) + + assertFailsWith { proxy.get(KEY, "fallback") } + assertFailsWith { proxy.getBoolean(KEY, true) } + } + + /** + * Serves [systemProperties] as `android.os.SystemProperties`, or throws + * [ClassNotFoundException] for it when that is null. + */ + private class FakeSystemPropertiesLoader(private val systemProperties: KClass<*>?) : + ClassLoader(FakeSystemPropertiesLoader::class.java.classLoader) { + + override fun loadClass(name: String): Class<*> = when { + name != SYSTEM_PROPERTIES -> super.loadClass(name) + systemProperties == null -> throw ClassNotFoundException(name) + else -> systemProperties.java + } + } + + private object NoMethodsSystemProperties + + private object StringOnlySystemProperties { + @JvmStatic + fun get(key: String?, def: String?): String = "string-only:$key" + } + + private object EchoSystemProperties { + @JvmStatic + fun get(key: String?, def: String?): String = "echo:$key:$def" + + @JvmStatic + fun getBoolean(key: String?, def: Boolean): Boolean = !def + } + + private object ThrowingSystemProperties { + @JvmStatic + fun get(key: String?, def: String?): String = throw IllegalArgumentException("key too long: $key") + + @JvmStatic + fun getBoolean(key: String?, def: Boolean): Boolean = throw IllegalArgumentException("key too long: $key") + } + + private object NullSystemProperties { + @JvmStatic + fun get(key: String?, def: String?): String? = null + + @JvmStatic + fun getBoolean(key: String?, def: Boolean): Boolean? = null + } + + private object EmptySystemProperties { + @JvmStatic + fun get(key: String?, def: String?): String = "" + + @JvmStatic + fun getBoolean(key: String?, def: Boolean): Boolean = def + } + + private object MistypedSystemProperties { + @JvmStatic + fun get(key: String?, def: String?): Any = 42 + + @JvmStatic + fun getBoolean(key: String?, def: Boolean): Any = "true" // not a Boolean + } + + private class InstanceMethodSystemProperties { + fun get(key: String?, def: String?): String? = def + + fun getBoolean(key: String?, def: Boolean): Boolean = def + } + + private companion object { + const val SYSTEM_PROPERTIES = "android.os.SystemProperties" + const val KEY = "debug.logback.test" + const val MISSING_KEY = "debug.logback.missing" + + /** Creates a proxy with the private constructor, like the singleton's. */ + fun newProxy(classLoader: ClassLoader?): SystemPropertiesProxy = + SystemPropertiesProxy::class.java.getDeclaredConstructor(ClassLoader::class.java) + .apply { isAccessible = true } + .newInstance(classLoader) + + fun proxyFor(systemProperties: KClass<*>): SystemPropertiesProxy = + newProxy(FakeSystemPropertiesLoader(systemProperties)) + } +} From f2429578b026a2610e1f93e1afd1447a32f4b878 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 06:16:54 +0000 Subject: [PATCH 4/4] test: don't use ClassLoader.getClassLoadingLock, which android.jar lacks AGP compiles unit tests against android.jar's java.lang.ClassLoader, which has no getClassLoadingLock(); lock on the loader itself instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AN8SJNGETCegfBbPy4iV5K --- .../ch/qos/logback/core/android/AndroidContextUtilTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logback-android/src/test/kotlin/ch/qos/logback/core/android/AndroidContextUtilTest.kt b/logback-android/src/test/kotlin/ch/qos/logback/core/android/AndroidContextUtilTest.kt index 65035ff80..e7509571e 100644 --- a/logback-android/src/test/kotlin/ch/qos/logback/core/android/AndroidContextUtilTest.kt +++ b/logback-android/src/test/kotlin/ch/qos/logback/core/android/AndroidContextUtilTest.kt @@ -628,7 +628,7 @@ private class AppGlobalsReplacingClassLoader( ) : ClassLoader(delegate) { private val utilClassName = AndroidContextUtil::class.java.name - override fun loadClass(name: String, resolve: Boolean): Class<*> = synchronized(getClassLoadingLock(name)) { + override fun loadClass(name: String, resolve: Boolean): Class<*> = synchronized(this) { findLoadedClass(name) ?: when { name == APP_GLOBALS -> define(name, appGlobals ?: throw ClassNotFoundException(name)) name == utilClassName || name.startsWith("$utilClassName\$") -> define(name, bytesOf(name))