Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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<ILoggingEvent> rollingFileAppender = new RollingFileAppender<>();
rollingFileAppender.setContext(loggerContext);
rollingFileAppender.setContext(fileLoggerContext);
rollingFileAppender.setName(APPENDER_NAME);
rollingFileAppender.setFile(logsDirectory + "/" + logPrefix + "-latest.log");

if (dailyRolling) {
SizeAndTimeBasedRollingPolicy<ILoggingEvent> 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));
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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));
}
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down