diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d7a3fe705a..00ff2c7f80 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 7ebb44b05e..02b4e9cb3f 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 b21fa336cb..a5e53a6ebc 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' 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 229750fc57..a880df0980 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 a63bd6b8e0..e384976b4d 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 79559624dd..964b153512 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 0d69017e96..7cc615ebd4 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) { 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 67501974cc..0000000000 --- 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 f7725cfb90..0000000000 --- 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 645fcc6d56..0000000000 --- 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 0000000000..48d2330fab --- /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 0000000000..6980a11efa --- /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 0000000000..e6b97650dc --- /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 0000000000..38ea2f8a86 --- /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 0000000000..8762d16cfb --- /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 0000000000..e7509571ea --- /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(this) { + 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 0000000000..46c296d1ca --- /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)) + } +}