diff --git a/android/build.gradle b/android/build.gradle index 0e06387..c1739ee 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -88,15 +88,55 @@ 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:+" - 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' + + 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 51596f9..60527e0 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,60 @@ 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. + */ + // Package-private for testability. + 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 +136,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 +145,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 +161,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 +235,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 +254,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 +281,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); 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); + } +} 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": {