From 20bf5db7454b4d73e2bcc8232cd5dfd7a4d95370 Mon Sep 17 00:00:00 2001 From: tian543 <14320466+tian000@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:21:09 -0400 Subject: [PATCH 1/4] fix(android): handle SLF4J 2.x on the classpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logback-android 2.0.0 registers with SLF4J using the 1.x binding mechanism (StaticLoggerBinder). When SLF4J 2.x is present — pulled in transitively by libraries like Ktor 3.x — it ignores the 1.x binding and falls back to NOPLoggerFactory. The hard cast to LoggerContext in configureLogger() then throws a ClassCastException, and because configure() had no try/catch the JS promise was never settled, hanging the calling app at startup. Changes: - Add getOrCreateLoggerContext() that falls back to a standalone LoggerContext when the SLF4J factory is not logback's - Store the context in a static field and use it consistently in renewAppender() and for the write() logger - Wrap configure() in try/catch so the promise is always resolved or rejected Fixes #95 --- .../rnfilelogger/FileLoggerModule.java | 92 +++++++++++++------ 1 file changed, 65 insertions(+), 27 deletions(-) diff --git a/android/src/main/java/com/betomorrow/rnfilelogger/FileLoggerModule.java b/android/src/main/java/com/betomorrow/rnfilelogger/FileLoggerModule.java index 51596f9..628e1e2 100644 --- a/android/src/main/java/com/betomorrow/rnfilelogger/FileLoggerModule.java +++ b/android/src/main/java/com/betomorrow/rnfilelogger/FileLoggerModule.java @@ -13,6 +13,7 @@ import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.WritableArray; +import org.slf4j.ILoggerFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -43,6 +44,17 @@ public class FileLoggerModule extends FileLoggerSpec { private static final int LOG_LEVEL_WARNING = 2; private static final int LOG_LEVEL_ERROR = 3; + /** + * The LoggerContext used for file logging. Set during configure(). + * + * When SLF4J 1.x is on the classpath with logback bound, this is the same + * context that backs LoggerFactory. Under SLF4J 2.x (where logback-android's + * 1.x binding is invisible), this is a standalone context that still writes + * to disk via logback's rolling file appender. + */ + private static LoggerContext fileLoggerContext; + + /** Logger obtained from our context — always routes to the file appender. */ private static Logger logger = LoggerFactory.getLogger(FileLoggerModule.class); private final ReactApplicationContext reactContext; @@ -60,36 +72,59 @@ public String getName() { @ReactMethod public void configure(ReadableMap options, Promise promise) { - boolean dailyRolling = options.getBoolean("dailyRolling"); - int maximumFileSize = options.getInt("maximumFileSize"); - int maximumNumberOfFiles = options.getInt("maximumNumberOfFiles"); - - - logsDirectory = options.hasKey("logsDirectory") - ? options.getString("logsDirectory") - : reactContext.getExternalCacheDir() + "/logs"; - String logPrefix = options.hasKey("logPrefix") - ? options.getString("logPrefix") - :reactContext.getPackageName(); - - - configureLogger(dailyRolling, maximumFileSize, maximumNumberOfFiles, logsDirectory, logPrefix); - - configureOptions = options; - promise.resolve(null); + try { + boolean dailyRolling = options.getBoolean("dailyRolling"); + int maximumFileSize = options.getInt("maximumFileSize"); + int maximumNumberOfFiles = options.getInt("maximumNumberOfFiles"); + + logsDirectory = options.hasKey("logsDirectory") + ? options.getString("logsDirectory") + : reactContext.getExternalCacheDir() + "/logs"; + String logPrefix = options.hasKey("logPrefix") + ? options.getString("logPrefix") + : reactContext.getPackageName(); + + configureLogger(dailyRolling, maximumFileSize, maximumNumberOfFiles, logsDirectory, logPrefix); + + configureOptions = options; + promise.resolve(null); + } catch (Exception e) { + promise.reject("ERR_FILE_LOGGER_CONFIGURE", e.getMessage(), e); + } + } + + /** + * Returns a LoggerContext, handling both SLF4J 1.x and 2.x. + * + * SLF4J 1.x discovers logback via StaticLoggerBinder — getILoggerFactory() + * returns a LoggerContext directly. SLF4J 2.x uses ServiceLoader-based + * providers; logback-android 2.0.0 only registers via the 1.x mechanism, + * so getILoggerFactory() returns NOPLoggerFactory under SLF4J 2.x. + * + * When the SLF4J factory is not a LoggerContext we create a standalone one + * so file logging works regardless of the SLF4J version on the classpath. + */ + private static LoggerContext getOrCreateLoggerContext() { + ILoggerFactory factory = LoggerFactory.getILoggerFactory(); + if (factory instanceof LoggerContext) { + return (LoggerContext) factory; + } + LoggerContext context = new LoggerContext(); + context.setName("FileLoggerContext"); + return context; } public static void configureLogger(boolean dailyRolling, int maximumFileSize, int maximumNumberOfFiles, String logsDirectory, String logPrefix) { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + fileLoggerContext = getOrCreateLoggerContext(); RollingFileAppender rollingFileAppender = new RollingFileAppender<>(); - rollingFileAppender.setContext(loggerContext); + rollingFileAppender.setContext(fileLoggerContext); rollingFileAppender.setName(APPENDER_NAME); rollingFileAppender.setFile(logsDirectory + "/" + logPrefix + "-latest.log"); if (dailyRolling) { SizeAndTimeBasedRollingPolicy rollingPolicy = new SizeAndTimeBasedRollingPolicy<>(); - rollingPolicy.setContext(loggerContext); + rollingPolicy.setContext(fileLoggerContext); rollingPolicy.setFileNamePattern(logsDirectory + "/" + logPrefix + "-%d{yyyy-MM-dd}.%i.log"); rollingPolicy.setMaxFileSize(new FileSize(maximumFileSize)); rollingPolicy.setTotalSizeCap(new FileSize(maximumNumberOfFiles * maximumFileSize)); @@ -100,7 +135,7 @@ public static void configureLogger(boolean dailyRolling, int maximumFileSize, in } else if (maximumFileSize > 0) { FixedWindowRollingPolicy rollingPolicy = new FixedWindowRollingPolicy(); - rollingPolicy.setContext(loggerContext); + rollingPolicy.setContext(fileLoggerContext); rollingPolicy.setFileNamePattern(logsDirectory + "/" + logPrefix + "-%i.log"); rollingPolicy.setMinIndex(1); rollingPolicy.setMaxIndex(maximumNumberOfFiles); @@ -109,14 +144,14 @@ public static void configureLogger(boolean dailyRolling, int maximumFileSize, in rollingFileAppender.setRollingPolicy(rollingPolicy); SizeBasedTriggeringPolicy triggeringPolicy = new SizeBasedTriggeringPolicy(); - triggeringPolicy.setContext(loggerContext); + triggeringPolicy.setContext(fileLoggerContext); triggeringPolicy.setMaxFileSize(new FileSize(maximumFileSize)); triggeringPolicy.start(); rollingFileAppender.setTriggeringPolicy(triggeringPolicy); } PatternLayoutEncoder encoder = new PatternLayoutEncoder(); - encoder.setContext(loggerContext); + encoder.setContext(fileLoggerContext); encoder.setCharset(Charset.forName("UTF-8")); encoder.setPattern("%msg%n"); encoder.start(); @@ -125,10 +160,13 @@ public static void configureLogger(boolean dailyRolling, int maximumFileSize, in rollingFileAppender.start(); renewAppender(rollingFileAppender); + + // Re-obtain the logger from our context so write() routes to the file appender. + logger = fileLoggerContext.getLogger(FileLoggerModule.class.getName()); } private static void renewAppender(Appender appender) { - ch.qos.logback.classic.Logger root = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + ch.qos.logback.classic.Logger root = fileLoggerContext.getLogger(Logger.ROOT_LOGGER_NAME); root.setLevel(Level.DEBUG); // Stopping the previous appender to release any resources it might be holding (file handles) and to ensure a clean shutdown. Appender previousFileLoggerAppender = root.getAppender(APPENDER_NAME); @@ -196,7 +234,7 @@ public void sendLogFilesByEmail(ReadableMap options, Promise promise) { Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE, Uri.parse("mailto:")); intent.setType("plain/text"); - + if (to != null) { intent.putExtra(Intent.EXTRA_EMAIL, readableArrayToStringArray(to)); } @@ -215,7 +253,7 @@ public void sendLogFilesByEmail(ReadableMap options, Promise promise) { File zipFile = new File(logsDirectory, "logs.zip"); try (FileOutputStream fos = new FileOutputStream(zipFile); ZipOutputStream zos = new ZipOutputStream(fos)) { - + for (File logFile : logFiles) { ZipEntry zipEntry = new ZipEntry(logFile.getName()); zos.putNextEntry(zipEntry); @@ -242,7 +280,7 @@ public void sendLogFilesByEmail(ReadableMap options, Promise promise) { intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION); - + reactContext.startActivity(intent); promise.resolve(null); From da5ae5d3e960782bde59961584af8a699bc82a7e Mon Sep 17 00:00:00 2001 From: tian543 <14320466+tian000@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:23:54 -0400 Subject: [PATCH 2/4] chore(android): widen slf4j-api version range to accept 1.7.x and 2.x The Java code now handles both SLF4J binding mechanisms, so the Gradle dependency should accept whichever version the host app resolves to instead of pinning 1.7.33 and risking a downgrade conflict. --- android/build.gradle | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index 0e06387..37f81d5 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -95,7 +95,10 @@ repositories { dependencies { //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" - implementation 'org.slf4j:slf4j-api:1.7.33' + // Supports both SLF4J 1.7.x (StaticLoggerBinder binding) and 2.x (standalone + // LoggerContext fallback). The range lets Gradle resolve whichever version + // the host app needs without forcing a downgrade. + implementation 'org.slf4j:slf4j-api:[1.7.25, 3.0.0)' implementation 'com.github.tony19:logback-android:2.0.0' } From b7f6922c65f3d39c71d302a61639a0f81e97dadf Mon Sep 17 00:00:00 2001 From: tian543 <14320466+tian000@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:56:43 -0400 Subject: [PATCH 3/4] test(android): add dual-classpath tests for SLF4J 1.x and 2.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the same 4 tests against real SLF4J 1.7.36 and 2.0.17 JARs to verify getOrCreateLoggerContext() works with both binding mechanisms. Two custom Gradle configurations (testSlf4j1x, testSlf4j2x) each hold a pinned SLF4J version. afterEvaluate registers test tasks that reuse the compiled test classes from testDebugUnitTest but swap the SLF4J JAR on the classpath. No mocking — the tests exercise the real SLF4J binding/provider discovery. Run with: ./gradlew testBothSlf4jVersions --- android/build.gradle | 37 ++++++ .../rnfilelogger/FileLoggerModule.java | 3 +- .../rnfilelogger/FileLoggerModuleTest.java | 125 ++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 android/src/test/java/com/betomorrow/rnfilelogger/FileLoggerModuleTest.java diff --git a/android/build.gradle b/android/build.gradle index 37f81d5..c1739ee 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -88,10 +88,19 @@ repositories { // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm url("$rootDir/../node_modules/react-native/android") } + maven { url 'https://www.jitpack.io' } google() mavenCentral() } +// Standalone configurations that hold only the pinned SLF4J JAR. +// They do NOT extend testImplementation — the full test classpath comes from +// the base testDebugUnitTest task. This avoids Android variant attribute issues. +configurations { + testSlf4j1x + testSlf4j2x +} + dependencies { //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" @@ -100,6 +109,34 @@ dependencies { // the host app needs without forcing a downgrade. implementation 'org.slf4j:slf4j-api:[1.7.25, 3.0.0)' implementation 'com.github.tony19:logback-android:2.0.0' + + testImplementation 'junit:junit:4.13.2' + testImplementation 'com.github.tony19:logback-android:2.0.0' + + testSlf4j1x 'org.slf4j:slf4j-api:1.7.36' + testSlf4j2x 'org.slf4j:slf4j-api:2.0.17' +} + +// Register test tasks that run the same tests with different SLF4J versions. +afterEvaluate { + def baseTaskProvider = tasks.named('testDebugUnitTest') + + ['1x': configurations.testSlf4j1x, '2x': configurations.testSlf4j2x].each { label, config -> + tasks.register("testSlf4j${label}DebugUnitTest", Test) { + def baseTask = baseTaskProvider.get() + group = 'verification' + description = "Runs unit tests with SLF4J ${label} on the classpath" + testClassesDirs = baseTask.testClassesDirs + classpath = baseTask.classpath.filter { !it.name.startsWith('slf4j-api-') } + config + dependsOn 'compileDebugUnitTestJavaWithJavac' + } + } + + tasks.register('testBothSlf4jVersions') { + group = 'verification' + description = 'Runs unit tests against both SLF4J 1.7.x and 2.x' + dependsOn 'testSlf4j1xDebugUnitTest', 'testSlf4j2xDebugUnitTest' + } } if (isNewArchitectureEnabled()) { diff --git a/android/src/main/java/com/betomorrow/rnfilelogger/FileLoggerModule.java b/android/src/main/java/com/betomorrow/rnfilelogger/FileLoggerModule.java index 628e1e2..60527e0 100644 --- a/android/src/main/java/com/betomorrow/rnfilelogger/FileLoggerModule.java +++ b/android/src/main/java/com/betomorrow/rnfilelogger/FileLoggerModule.java @@ -104,7 +104,8 @@ public void configure(ReadableMap options, Promise promise) { * When the SLF4J factory is not a LoggerContext we create a standalone one * so file logging works regardless of the SLF4J version on the classpath. */ - private static LoggerContext getOrCreateLoggerContext() { + // Package-private for testability. + static LoggerContext getOrCreateLoggerContext() { ILoggerFactory factory = LoggerFactory.getILoggerFactory(); if (factory instanceof LoggerContext) { return (LoggerContext) factory; diff --git a/android/src/test/java/com/betomorrow/rnfilelogger/FileLoggerModuleTest.java b/android/src/test/java/com/betomorrow/rnfilelogger/FileLoggerModuleTest.java new file mode 100644 index 0000000..65c4d27 --- /dev/null +++ b/android/src/test/java/com/betomorrow/rnfilelogger/FileLoggerModuleTest.java @@ -0,0 +1,125 @@ +package com.betomorrow.rnfilelogger; + +import static org.junit.Assert.*; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.Appender; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.slf4j.Logger; + +import java.io.File; +import java.lang.reflect.Field; +import java.nio.file.Files; + +/** + * Tests that FileLoggerModule works correctly with both SLF4J 1.x and 2.x. + * + * These tests are run twice via Gradle — once with slf4j-api:1.7.36 on the + * classpath ({@code testSlf4j1xDebugUnitTest}) and once with slf4j-api:2.0.17 + * ({@code testSlf4j2xDebugUnitTest}). No mocking is used; the tests exercise + * the real SLF4J binding/provider discovery path. + * + * Under SLF4J 1.x, logback-android binds via StaticLoggerBinder and + * {@code getOrCreateLoggerContext()} returns the factory's LoggerContext. + * Under SLF4J 2.x, logback-android's 1.x binding is invisible, so the method + * creates a standalone LoggerContext. Both paths must produce working file logging. + */ +public class FileLoggerModuleTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private String logsDir; + + @Before + public void setUp() throws Exception { + logsDir = tempFolder.newFolder("logs").getAbsolutePath(); + } + + @After + public void resetStaticState() throws Exception { + // Stop and clear the static context to prevent cross-test contamination. + Field contextField = FileLoggerModule.class.getDeclaredField("fileLoggerContext"); + contextField.setAccessible(true); + LoggerContext ctx = (LoggerContext) contextField.get(null); + if (ctx != null) { + ctx.stop(); + } + contextField.set(null, null); + + // Reset the logger field to its default (SLF4J factory logger). + Field loggerField = FileLoggerModule.class.getDeclaredField("logger"); + loggerField.setAccessible(true); + loggerField.set(null, org.slf4j.LoggerFactory.getLogger(FileLoggerModule.class)); + } + + @Test + public void getOrCreateLoggerContext_returnsLoggerContext() { + LoggerContext ctx = FileLoggerModule.getOrCreateLoggerContext(); + assertNotNull("Should return a LoggerContext regardless of SLF4J version", ctx); + } + + @Test + public void configureLogger_writesToFile() throws Exception { + FileLoggerModule.configureLogger(false, 1024 * 1024, 5, logsDir, "test"); + + Logger logger = getStaticLogger(); + logger.info("hello from test"); + + File logFile = new File(logsDir, "test-latest.log"); + assertTrue("Log file should exist", logFile.exists()); + + String contents = new String(Files.readAllBytes(logFile.toPath())); + assertTrue("Log file should contain the message", contents.contains("hello from test")); + } + + @Test + public void configureLogger_withDailyRolling_startsSuccessfully() throws Exception { + // Should not throw even under the standalone LoggerContext path. + FileLoggerModule.configureLogger(true, 1024 * 1024, 5, logsDir, "test"); + + Logger logger = getStaticLogger(); + logger.info("daily rolling test"); + + File logFile = new File(logsDir, "test-latest.log"); + assertTrue("Log file should exist with daily rolling", logFile.exists()); + } + + @Test + public void configureLogger_calledTwice_replacesAppender() throws Exception { + FileLoggerModule.configureLogger(false, 1024 * 1024, 5, logsDir, "first"); + FileLoggerModule.configureLogger(false, 1024 * 1024, 5, logsDir, "second"); + + // After reconfiguring, the root logger should have exactly one FileLoggerAppender. + Field contextField = FileLoggerModule.class.getDeclaredField("fileLoggerContext"); + contextField.setAccessible(true); + LoggerContext ctx = (LoggerContext) contextField.get(null); + + ch.qos.logback.classic.Logger root = ctx.getLogger(Logger.ROOT_LOGGER_NAME); + Appender appender = root.getAppender(FileLoggerModule.APPENDER_NAME); + assertNotNull("Should have a FileLoggerAppender after reconfigure", appender); + assertTrue("Appender should be started", appender.isStarted()); + + // Write through the logger and verify it goes to the second config's file. + Logger logger = getStaticLogger(); + logger.info("after reconfigure"); + + File logFile = new File(logsDir, "second-latest.log"); + assertTrue("Second log file should exist", logFile.exists()); + + String contents = new String(Files.readAllBytes(logFile.toPath())); + assertTrue("Should write to the reconfigured file", contents.contains("after reconfigure")); + } + + /** Reads the private static {@code logger} field via reflection. */ + private static Logger getStaticLogger() throws Exception { + Field loggerField = FileLoggerModule.class.getDeclaredField("logger"); + loggerField.setAccessible(true); + return (Logger) loggerField.get(null); + } +} From cb86db1505cf95bc7d1ec08e4c4fd47f0fecb90a Mon Sep 17 00:00:00 2001 From: tian543 <14320466+tian000@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:03:23 -0400 Subject: [PATCH 4/4] chore: add test:android script to package.json Runs the dual-classpath SLF4J 1.x/2.x unit tests via the example app's Gradle project, where all Android dependencies (including React Native) resolve correctly. Usage: npm run test:android --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 796ec06..d08cbb1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ ], "scripts": { "build": "rimraf dist && tsc", + "test:android": "cd example/android && ./gradlew :react-native-file-logger:testBothSlf4jVersions", "prepublishOnly": "npm run build" }, "repository": {