diff --git a/sentry-core/src/main/java/io/sentry/core/cache/CacheStrategy.java b/sentry-core/src/main/java/io/sentry/core/cache/CacheStrategy.java new file mode 100644 index 000000000..e175d7712 --- /dev/null +++ b/sentry-core/src/main/java/io/sentry/core/cache/CacheStrategy.java @@ -0,0 +1,94 @@ +package io.sentry.core.cache; + +import static io.sentry.core.SentryLevel.ERROR; + +import io.sentry.core.ISerializer; +import io.sentry.core.SentryLevel; +import io.sentry.core.SentryOptions; +import io.sentry.core.util.Objects; +import java.io.File; +import java.nio.charset.Charset; +import java.util.Arrays; +import org.jetbrains.annotations.NotNull; + +abstract class CacheStrategy { + + @SuppressWarnings("CharsetObjectCanBeUsed") + protected static final Charset UTF_8 = Charset.forName("UTF-8"); + + protected final @NotNull SentryOptions options; + protected final @NotNull ISerializer serializer; + protected final @NotNull File directory; + private final int maxSize; + + CacheStrategy( + final @NotNull SentryOptions options, + final @NotNull String directoryPath, + final int maxSize) { + Objects.requireNonNull(directoryPath, "Directory is required."); + this.options = Objects.requireNonNull(options, "SentryOptions is required."); + + this.serializer = options.getSerializer(); + this.directory = new File(directoryPath); + + this.maxSize = maxSize; + } + + /** + * Check if a dir. is valid and have write and read permission + * + * @return true if valid and has permissions or false otherwise + */ + protected boolean isDirectoryValid() { + if (!directory.isDirectory() || !directory.canWrite() || !directory.canRead()) { + options + .getLogger() + .log( + ERROR, + "The directory for caching files is inaccessible.: %s", + directory.getAbsolutePath()); + return false; + } + return true; + } + + /** + * Sort files from oldest to the newest using the lastModified method + * + * @param files the Files + */ + private void sortFilesOldestToNewest(@NotNull File[] files) { + // just sort it if more than 1 file + if (files.length > 1) { + Arrays.sort(files, (f1, f2) -> Long.compare(f1.lastModified(), f2.lastModified())); + } + } + + /** + * Rotates the caching folder if full, deleting the oldest files first + * + * @param files the Files + */ + protected void rotateCacheIfNeeded(final @NotNull File[] files) { + final int length = files.length; + if (length >= maxSize) { + options + .getLogger() + .log(SentryLevel.WARNING, "Cache folder if full (respecting maxSize). Rotating files"); + final int totalToBeDeleted = (length - maxSize) + 1; + + sortFilesOldestToNewest(files); + + // delete files from the top of the Array as its sorted by the oldest to the newest + for (int i = 0; i < totalToBeDeleted; i++) { + final File file = files[i]; + // sanity check if the file actually exists. + if (!file.delete()) { + options + .getLogger() + .log(SentryLevel.WARNING, "File can't be deleted: %s", file.getAbsolutePath()); + } + } + } + } +} diff --git a/sentry-core/src/main/java/io/sentry/core/cache/DiskCache.java b/sentry-core/src/main/java/io/sentry/core/cache/DiskCache.java index 2054c4f80..fd91f3085 100644 --- a/sentry-core/src/main/java/io/sentry/core/cache/DiskCache.java +++ b/sentry-core/src/main/java/io/sentry/core/cache/DiskCache.java @@ -5,11 +5,8 @@ import static io.sentry.core.SentryLevel.WARNING; import static java.lang.String.format; -import io.sentry.core.ISerializer; import io.sentry.core.SentryEvent; -import io.sentry.core.SentryLevel; import io.sentry.core.SentryOptions; -import io.sentry.core.util.Objects; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; @@ -22,7 +19,6 @@ import java.io.OutputStreamWriter; import java.io.Reader; import java.io.Writer; -import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -34,39 +30,19 @@ * configured directory. */ @ApiStatus.Internal -public final class DiskCache implements IEventCache { +public final class DiskCache extends CacheStrategy implements IEventCache { /** File suffix added to all serialized event files. */ public static final String FILE_SUFFIX = ".sentry-event"; - @SuppressWarnings("CharsetObjectCanBeUsed") - private static final Charset UTF_8 = Charset.forName("UTF-8"); - - private final File directory; - private final int maxSize; - private final ISerializer serializer; - private final SentryOptions options; - - public DiskCache(SentryOptions options) { - Objects.requireNonNull(options.getCacheDirPath(), "Cache dir. path is required."); - this.directory = new File(options.getCacheDirPath()); - this.maxSize = options.getCacheDirSize(); - this.serializer = options.getSerializer(); - this.options = options; + public DiskCache(final @NotNull SentryOptions options) { + super(options, options.getCacheDirPath(), options.getCacheDirSize()); } @Override - public void store(SentryEvent event) { - if (getNumberOfStoredEvents() >= maxSize) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "Disk cache full (respecting maxSize). Not storing event {}", - event); - return; - } + public void store(final @NotNull SentryEvent event) { + rotateCacheIfNeeded(allEventFiles()); - File eventFile = getEventFile(event); + final File eventFile = getEventFile(event); if (eventFile.exists()) { options .getLogger() @@ -92,8 +68,8 @@ public void store(SentryEvent event) { } @Override - public void discard(SentryEvent event) { - File eventFile = getEventFile(event); + public void discard(final @NotNull SentryEvent event) { + final File eventFile = getEventFile(event); if (eventFile.exists()) { options .getLogger() @@ -107,34 +83,17 @@ public void discard(SentryEvent event) { } } - private int getNumberOfStoredEvents() { - return allEventFiles().length; - } - - private boolean isDirectoryValid() { - if (!directory.isDirectory() || !directory.canWrite() || !directory.canRead()) { - options - .getLogger() - .log( - ERROR, - "The directory for caching Sentry events is inaccessible.: %s", - directory.getAbsolutePath()); - return false; - } - return true; - } - - private File getEventFile(SentryEvent event) { + private @NotNull File getEventFile(final @NotNull SentryEvent event) { return new File(directory.getAbsolutePath(), event.getEventId().toString() + FILE_SUFFIX); } @Override public @NotNull Iterator iterator() { - File[] allCachedEvents = allEventFiles(); + final File[] allCachedEvents = allEventFiles(); - List ret = new ArrayList<>(allCachedEvents.length); + final List ret = new ArrayList<>(allCachedEvents.length); - for (File f : allCachedEvents) { + for (final File f : allCachedEvents) { try (final Reader reader = new BufferedReader(new InputStreamReader(new FileInputStream(f), UTF_8))) { @@ -159,10 +118,12 @@ private File getEventFile(SentryEvent event) { return ret.iterator(); } - private File[] allEventFiles() { + private @NotNull File[] allEventFiles() { if (isDirectoryValid()) { - // TODO: we need to order by oldest to the newest here - return directory.listFiles((__, fileName) -> fileName.endsWith(FILE_SUFFIX)); + final File[] files = directory.listFiles((__, fileName) -> fileName.endsWith(FILE_SUFFIX)); + if (files != null) { + return files; + } } return new File[] {}; } diff --git a/sentry-core/src/main/java/io/sentry/core/cache/SessionCache.java b/sentry-core/src/main/java/io/sentry/core/cache/SessionCache.java index ce45f601e..1318a10e2 100644 --- a/sentry-core/src/main/java/io/sentry/core/cache/SessionCache.java +++ b/sentry-core/src/main/java/io/sentry/core/cache/SessionCache.java @@ -7,7 +7,6 @@ import static java.lang.String.format; import io.sentry.core.DateUtils; -import io.sentry.core.ISerializer; import io.sentry.core.SentryEnvelope; import io.sentry.core.SentryEnvelopeItem; import io.sentry.core.SentryItemType; @@ -33,7 +32,6 @@ import java.io.OutputStreamWriter; import java.io.Reader; import java.io.Writer; -import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Date; import java.util.Iterator; @@ -46,7 +44,7 @@ import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public final class SessionCache implements IEnvelopeCache { +public final class SessionCache extends CacheStrategy implements IEnvelopeCache { /** File suffix added to all serialized envelopes files. */ static final String SUFFIX_ENVELOPE_FILE = ".envelope"; @@ -55,37 +53,17 @@ public final class SessionCache implements IEnvelopeCache { static final String SUFFIX_CURRENT_SESSION_FILE = ".json"; static final String CRASH_MARKER_FILE = ".sentry-native/last_crash"; - @SuppressWarnings("CharsetObjectCanBeUsed") - private static final Charset UTF_8 = Charset.forName("UTF-8"); - - private final @NotNull File directory; - private final int maxSize; - private final @NotNull ISerializer serializer; - private final @NotNull SentryOptions options; - private final @NotNull Map fileNameMap = new WeakHashMap<>(); public SessionCache(final @NotNull SentryOptions options) { - Objects.requireNonNull(options.getSessionsPath(), "sessions dir. path is required."); - this.directory = new File(options.getSessionsPath()); - this.maxSize = options.getSessionsDirSize(); - this.serializer = options.getSerializer(); - this.options = options; + super(options, options.getSessionsPath(), options.getSessionsDirSize()); } @Override public void store(final @NotNull SentryEnvelope envelope, final @Nullable Object hint) { Objects.requireNonNull(envelope, "Envelope is required."); - if (getNumberOfStoredEnvelopes() >= maxSize) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "Disk cache full (respecting maxSize). Not storing envelope {}", - envelope); - return; - } + rotateCacheIfNeeded(allEnvelopeFiles()); final File currentSessionFile = getCurrentSessionFile(); @@ -185,7 +163,7 @@ public void store(final @NotNull SentryEnvelope envelope, final @Nullable Object * @param markerFile the marker file * @return the timestamp as Date */ - private Date getTimestampFromCrashMarkerFile(final @NotNull File markerFile) { + private @Nullable Date getTimestampFromCrashMarkerFile(final @NotNull File markerFile) { try (final BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(markerFile), UTF_8))) { final String timestamp = reader.readLine(); @@ -299,23 +277,6 @@ public void discard(final @NotNull SentryEnvelope envelope) { } } - private int getNumberOfStoredEnvelopes() { - return allEnvelopeFiles().length; - } - - private boolean isDirectoryValid() { - if (!directory.isDirectory() || !directory.canWrite() || !directory.canRead()) { - options - .getLogger() - .log( - ERROR, - "The directory for caching Sentry envelopes is inaccessible.: %s", - directory.getAbsolutePath()); - return false; - } - return true; - } - /** * Returns the envelope's file path. If the envelope has no eventId header, it generates a random * file name to it. @@ -378,7 +339,11 @@ private boolean isDirectoryValid() { private @NotNull File[] allEnvelopeFiles() { if (isDirectoryValid()) { // lets filter the session.json here - return directory.listFiles((__, fileName) -> fileName.endsWith(SUFFIX_ENVELOPE_FILE)); + final File[] files = + directory.listFiles((__, fileName) -> fileName.endsWith(SUFFIX_ENVELOPE_FILE)); + if (files != null) { + return files; + } } return new File[] {}; } diff --git a/sentry-core/src/test/java/io/sentry/core/cache/CacheStrategyTest.kt b/sentry-core/src/test/java/io/sentry/core/cache/CacheStrategyTest.kt new file mode 100644 index 000000000..4d7e7c109 --- /dev/null +++ b/sentry-core/src/test/java/io/sentry/core/cache/CacheStrategyTest.kt @@ -0,0 +1,89 @@ +package io.sentry.core.cache + +import com.nhaarman.mockitokotlin2.mock +import io.sentry.core.DateUtils +import io.sentry.core.SentryOptions +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CacheStrategyTest { + + private class Fixture { + val dir = Files.createTempDirectory("sentry-disk-cache-test").toAbsolutePath().toFile() + val options = SentryOptions().apply { + setSerializer(mock()) + } + + fun getSUT(maxSize: Int = 5): CacheStrategy { + return CustomCache(options, dir.absolutePath, maxSize) + } + } + + private val fixture = Fixture() + + @Test + fun `isDirectoryValid returns true if a valid directory`() { + val sut = fixture.getSUT() + + // sanity check + assertTrue(fixture.dir.isDirectory) + + // this test assumes that the dir. has write/read permission. + assertTrue(sut.isDirectoryValid) + } + + @Test + fun `Sort files from the oldest to the newest`() { + val sut = fixture.getSUT(3) + + val files = createTempFilesSortByOldestToNewest() + val reverseFiles = files.reversedArray() + + sut.rotateCacheIfNeeded(reverseFiles) + + assertEquals(files[0].absolutePath, reverseFiles[0].absolutePath) + assertEquals(files[1].absolutePath, reverseFiles[1].absolutePath) + assertEquals(files[2].absolutePath, reverseFiles[2].absolutePath) + } + + @Test + fun `Rotate cache folder to save new file`() { + val sut = fixture.getSUT(3) + + val files = createTempFilesSortByOldestToNewest() + val reverseFiles = files.reversedArray() + + sut.rotateCacheIfNeeded(reverseFiles) + + assertFalse(files[0].exists()) + assertTrue(files[1].exists()) + assertTrue(files[2].exists()) + } + + @AfterTest + fun shutdown() { + fixture.dir.listFiles()?.forEach { + it.deleteRecursively() + } + } + + private class CustomCache(options: SentryOptions, path: String, maxSize: Int) : CacheStrategy(options, path, maxSize) + + private fun createTempFilesSortByOldestToNewest(): Array { + val f1 = Files.createTempFile(fixture.dir.toPath(), "f1", ".json").toFile() + f1.setLastModified(DateUtils.getDateTime("2020-03-27T08:52:58.015Z").time) + + val f2 = Files.createTempFile(fixture.dir.toPath(), "f2", ".json").toFile() + f2.setLastModified(DateUtils.getDateTime("2020-03-27T08:52:59.015Z").time) + + val f3 = Files.createTempFile(fixture.dir.toPath(), "f3", ".json").toFile() + f3.setLastModified(DateUtils.getDateTime("2020-03-27T08:53:00.015Z").time) + + return arrayOf(f1, f2, f3) + } +}