Add a customizable logger and try/finally network connections (#1162)

diff --git a/lottie/src/main/java/com/airbnb/lottie/L.java b/lottie/src/main/java/com/airbnb/lottie/L.java
index 319958b..f26b6ae 100644
--- a/lottie/src/main/java/com/airbnb/lottie/L.java
+++ b/lottie/src/main/java/com/airbnb/lottie/L.java
@@ -2,20 +2,12 @@
 
 import androidx.annotation.RestrictTo;
 import androidx.core.os.TraceCompat;
-import android.util.Log;
-
-import java.util.HashSet;
-import java.util.Set;
 
 @RestrictTo(RestrictTo.Scope.LIBRARY)
 public class L {
-  public static final String TAG = "LOTTIE";
-  public static boolean DBG = false;
 
-  /**
-   * Set to ensure that we only log each message one time max.
-   */
-  private static final Set<String> loggedMessages = new HashSet<>();
+  public static boolean DBG = false;
+  public static final String TAG = "LOTTIE";
 
   private static final int MAX_DEPTH = 20;
   private static boolean traceEnabled = false;
@@ -24,21 +16,6 @@
   private static int traceDepth = 0;
   private static int depthPastMaxDepth = 0;
 
-  public static void debug(String msg) {
-    if (DBG) Log.d(TAG, msg);
-  }
-
-  /**
-   * Warn to logcat. Keeps track of messages so they are only logged once ever.
-   */
-  public static void warn(String msg) {
-    if (loggedMessages.contains(msg)) {
-      return;
-    }
-    Log.w(TAG, msg);
-    loggedMessages.add(msg);
-  }
-
   public static void setTraceEnabled(boolean enabled) {
     if (traceEnabled == enabled) {
       return;
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieComposition.java b/lottie/src/main/java/com/airbnb/lottie/LottieComposition.java
index 5edb57e..4ffe434 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieComposition.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieComposition.java
@@ -10,12 +10,12 @@
 import androidx.collection.LongSparseArray;
 import androidx.collection.SparseArrayCompat;
 import android.util.JsonReader;
-import android.util.Log;
 
 import com.airbnb.lottie.model.Font;
 import com.airbnb.lottie.model.FontCharacter;
 import com.airbnb.lottie.model.Marker;
 import com.airbnb.lottie.model.layer.Layer;
+import com.airbnb.lottie.utils.Logger;
 
 import org.json.JSONObject;
 
@@ -85,7 +85,7 @@
 
   @RestrictTo(RestrictTo.Scope.LIBRARY)
   public void addWarning(String warning) {
-    Log.w(L.TAG, warning);
+    Logger.warning(warning);
     warnings.add(warning);
   }
 
@@ -301,7 +301,7 @@
     @Deprecated
     public static LottieComposition fromInputStreamSync(InputStream stream, boolean close) {
       if (close) {
-        Log.w(L.TAG, "Lottie now auto-closes input stream!");
+        Logger.warning("Lottie now auto-closes input stream!");
       }
       return LottieCompositionFactory.fromJsonInputStreamSync(stream, null).getValue();
     }
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
index 1f0a2c9..4769035 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
@@ -16,7 +16,6 @@
 import android.graphics.drawable.Animatable;
 import android.graphics.drawable.Drawable;
 import android.os.Build;
-import android.util.Log;
 import android.view.View;
 
 import androidx.annotation.FloatRange;
@@ -32,6 +31,7 @@
 import com.airbnb.lottie.model.Marker;
 import com.airbnb.lottie.model.layer.CompositionLayer;
 import com.airbnb.lottie.parser.LayerParser;
+import com.airbnb.lottie.utils.Logger;
 import com.airbnb.lottie.utils.LottieValueAnimator;
 import com.airbnb.lottie.utils.MiscUtils;
 import com.airbnb.lottie.value.LottieFrameInfo;
@@ -155,7 +155,7 @@
     }
 
     if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
-      Log.w(TAG, "Merge paths are not supported pre-Kit Kat.");
+      Logger.warning("Merge paths are not supported pre-Kit Kat.");
       return;
     }
     enableMergePaths = enable;
@@ -285,7 +285,7 @@
 
   @Override
   public void setColorFilter(@Nullable ColorFilter colorFilter) {
-    Log.w(L.TAG, "Use addColorFilter instead.");
+    Logger.warning("Use addColorFilter instead.");
   }
 
   @Override
@@ -857,7 +857,7 @@
    */
   public List<KeyPath> resolveKeyPath(KeyPath keyPath) {
     if (compositionLayer == null) {
-      Log.w(L.TAG, "Cannot resolve KeyPath. Composition is not set yet.");
+      Logger.warning("Cannot resolve KeyPath. Composition is not set yet.");
       return Collections.emptyList();
     }
     List<KeyPath> keyPaths = new ArrayList<>();
@@ -933,7 +933,7 @@
   public Bitmap updateBitmap(String id, @Nullable Bitmap bitmap) {
     ImageAssetManager bm = getImageAssetManager();
     if (bm == null) {
-      Log.w(L.TAG, "Cannot update bitmap. Most likely the drawable is not added to a View " +
+      Logger.warning("Cannot update bitmap. Most likely the drawable is not added to a View " +
           "which prevents Lottie from getting a Context.");
       return null;
     }
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieLogger.java b/lottie/src/main/java/com/airbnb/lottie/LottieLogger.java
new file mode 100644
index 0000000..638b162
--- /dev/null
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieLogger.java
@@ -0,0 +1,15 @@
+package com.airbnb.lottie;
+
+/**
+ * Give ability to integrators to provide another logging mechanism.
+ */
+public interface LottieLogger {
+
+  void debug(String message);
+
+  void debug(String message, Throwable exception);
+
+  void warning(String message);
+
+  void warning(String message, Throwable exception);
+}
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieTask.java b/lottie/src/main/java/com/airbnb/lottie/LottieTask.java
index f1b3aeb..c922228 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieTask.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieTask.java
@@ -4,7 +4,8 @@
 import android.os.Looper;
 import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
-import android.util.Log;
+
+import com.airbnb.lottie.utils.Logger;
 
 import java.util.ArrayList;
 import java.util.LinkedHashSet;
@@ -149,7 +150,7 @@
     // Otherwise we risk ConcurrentModificationException.
     List<LottieListener<Throwable>> listenersCopy = new ArrayList<>(failureListeners);
     if (listenersCopy.isEmpty()) {
-      Log.w(L.TAG, "Lottie encountered an error but no failure listener was added.", e);
+      Logger.warning("Lottie encountered an error but no failure listener was added:", e);
       return;
     }
 
diff --git a/lottie/src/main/java/com/airbnb/lottie/manager/FontAssetManager.java b/lottie/src/main/java/com/airbnb/lottie/manager/FontAssetManager.java
index c7b5e37..360893b 100644
--- a/lottie/src/main/java/com/airbnb/lottie/manager/FontAssetManager.java
+++ b/lottie/src/main/java/com/airbnb/lottie/manager/FontAssetManager.java
@@ -10,6 +10,7 @@
 import com.airbnb.lottie.FontAssetDelegate;
 import com.airbnb.lottie.L;
 import com.airbnb.lottie.model.MutablePair;
+import com.airbnb.lottie.utils.Logger;
 
 import java.util.HashMap;
 import java.util.Map;
@@ -28,7 +29,7 @@
   public FontAssetManager(Drawable.Callback callback, @Nullable FontAssetDelegate delegate) {
     this.delegate = delegate;
     if (!(callback instanceof View)) {
-      Log.w(L.TAG, "LottieDrawable must be inside of a view for images to work.");
+      Logger.warning("LottieDrawable must be inside of a view for images to work.");
       assetManager = null;
       return;
     }
diff --git a/lottie/src/main/java/com/airbnb/lottie/manager/ImageAssetManager.java b/lottie/src/main/java/com/airbnb/lottie/manager/ImageAssetManager.java
index da4568d..aff6f77 100644
--- a/lottie/src/main/java/com/airbnb/lottie/manager/ImageAssetManager.java
+++ b/lottie/src/main/java/com/airbnb/lottie/manager/ImageAssetManager.java
@@ -7,12 +7,11 @@
 import androidx.annotation.Nullable;
 import android.text.TextUtils;
 import android.util.Base64;
-import android.util.Log;
 import android.view.View;
 
 import com.airbnb.lottie.ImageAssetDelegate;
-import com.airbnb.lottie.L;
 import com.airbnb.lottie.LottieImageAsset;
+import com.airbnb.lottie.utils.Logger;
 
 import java.io.IOException;
 import java.io.InputStream;
@@ -36,7 +35,7 @@
     }
 
     if (!(callback instanceof View)) {
-      Log.w(L.TAG, "LottieDrawable must be inside of a view for images to work.");
+      Logger.warning("LottieDrawable must be inside of a view for images to work.");
       this.imageAssets = new HashMap<>();
       context = null;
       return;
@@ -95,7 +94,7 @@
       try {
         data = Base64.decode(filename.substring(filename.indexOf(',') + 1), Base64.DEFAULT);
       } catch (IllegalArgumentException e) {
-        Log.w(L.TAG, "data URL did not have correct base64 format.", e);
+        Logger.warning("data URL did not have correct base64 format.", e);
         return null;
       }
       bitmap = BitmapFactory.decodeByteArray(data, 0, data.length, opts);
@@ -110,7 +109,7 @@
       }
       is = context.getAssets().open(imagesFolder + filename);
     } catch (IOException e) {
-      Log.w(L.TAG, "Unable to open asset.", e);
+      Logger.warning("Unable to open asset.", e);
       return null;
     }
     bitmap = BitmapFactory.decodeStream(is, null, opts);
diff --git a/lottie/src/main/java/com/airbnb/lottie/model/content/MergePaths.java b/lottie/src/main/java/com/airbnb/lottie/model/content/MergePaths.java
index b4a8884..3d1184d 100644
--- a/lottie/src/main/java/com/airbnb/lottie/model/content/MergePaths.java
+++ b/lottie/src/main/java/com/airbnb/lottie/model/content/MergePaths.java
@@ -2,11 +2,11 @@
 
 import androidx.annotation.Nullable;
 
-import com.airbnb.lottie.L;
 import com.airbnb.lottie.LottieDrawable;
 import com.airbnb.lottie.animation.content.Content;
 import com.airbnb.lottie.animation.content.MergePathsContent;
 import com.airbnb.lottie.model.layer.BaseLayer;
+import com.airbnb.lottie.utils.Logger;
 
 
 public class MergePaths implements ContentModel {
@@ -60,7 +60,7 @@
 
   @Override @Nullable public Content toContent(LottieDrawable drawable, BaseLayer layer) {
     if (!drawable.enableMergePathsForKitKatAndAbove()) {
-      L.warn("Animation contains merge paths but they are disabled.");
+      Logger.warning("Animation contains merge paths but they are disabled.");
       return null;
     }
     return new MergePathsContent(this);
diff --git a/lottie/src/main/java/com/airbnb/lottie/model/content/ShapeData.java b/lottie/src/main/java/com/airbnb/lottie/model/content/ShapeData.java
index 4824a01..5c0cdba 100644
--- a/lottie/src/main/java/com/airbnb/lottie/model/content/ShapeData.java
+++ b/lottie/src/main/java/com/airbnb/lottie/model/content/ShapeData.java
@@ -3,8 +3,8 @@
 import android.graphics.PointF;
 import androidx.annotation.FloatRange;
 
-import com.airbnb.lottie.L;
 import com.airbnb.lottie.model.CubicCurveData;
+import com.airbnb.lottie.utils.Logger;
 import com.airbnb.lottie.utils.MiscUtils;
 
 import java.util.ArrayList;
@@ -53,7 +53,7 @@
 
 
     if (shapeData1.getCurves().size() != shapeData2.getCurves().size()) {
-      L.warn("Curves must have the same number of control points. Shape 1: " +
+      Logger.warning("Curves must have the same number of control points. Shape 1: " +
           shapeData1.getCurves().size() + "\tShape 2: " + shapeData2.getCurves().size());
     }
     
diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java
index fc63529..e828ff6 100644
--- a/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java
+++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/BaseLayer.java
@@ -21,6 +21,7 @@
 import com.airbnb.lottie.model.KeyPathElement;
 import com.airbnb.lottie.model.content.Mask;
 import com.airbnb.lottie.model.content.ShapeData;
+import com.airbnb.lottie.utils.Logger;
 import com.airbnb.lottie.value.LottieValueCallback;
 
 import java.util.ArrayList;
@@ -58,7 +59,7 @@
       case UNKNOWN:
       default:
         // Do nothing
-        L.warn("Unknown layer type " + layerModel.getLayerType());
+        Logger.warning("Unknown layer type " + layerModel.getLayerType());
         return null;
     }
   }
diff --git a/lottie/src/main/java/com/airbnb/lottie/network/FileExtension.java b/lottie/src/main/java/com/airbnb/lottie/network/FileExtension.java
index 98937f9..72a1a5f 100644
--- a/lottie/src/main/java/com/airbnb/lottie/network/FileExtension.java
+++ b/lottie/src/main/java/com/airbnb/lottie/network/FileExtension.java
@@ -1,6 +1,6 @@
 package com.airbnb.lottie.network;
 
-import com.airbnb.lottie.L;
+import com.airbnb.lottie.utils.Logger;
 
 /**
  * Helpers for known Lottie file types.
@@ -30,7 +30,7 @@
       }
     }
     // Default to Json.
-    L.warn("Unable to find correct extension for " + filename);
+    Logger.warning("Unable to find correct extension for " + filename);
     return JSON;
   }
 }
diff --git a/lottie/src/main/java/com/airbnb/lottie/network/NetworkCache.java b/lottie/src/main/java/com/airbnb/lottie/network/NetworkCache.java
index 305125d..dc8e5bd 100644
--- a/lottie/src/main/java/com/airbnb/lottie/network/NetworkCache.java
+++ b/lottie/src/main/java/com/airbnb/lottie/network/NetworkCache.java
@@ -5,7 +5,7 @@
 import androidx.annotation.WorkerThread;
 import androidx.core.util.Pair;
 
-import com.airbnb.lottie.L;
+import com.airbnb.lottie.utils.Logger;
 
 import java.io.File;
 import java.io.FileInputStream;
@@ -61,7 +61,7 @@
       extension = FileExtension.JSON;
     }
 
-    L.debug("Cache hit for " + url + " at " + cachedFile.getAbsolutePath());
+    Logger.debug("Cache hit for " + url + " at " + cachedFile.getAbsolutePath());
     return new Pair<>(extension, (InputStream) inputStream);
   }
 
@@ -104,9 +104,9 @@
     String newFileName = file.getAbsolutePath().replace(".temp", "");
     File newFile = new File(newFileName);
     boolean renamed = file.renameTo(newFile);
-    L.debug("Copying temp file to real file (" + newFile + ")");
+    Logger.debug("Copying temp file to real file (" + newFile + ")");
     if (!renamed) {
-      L.warn( "Unable to rename cache file " + file.getAbsolutePath() + " to " + newFile.getAbsolutePath() + ".");
+      Logger.warning("Unable to rename cache file " + file.getAbsolutePath() + " to " + newFile.getAbsolutePath() + ".");
     }
   }
 
diff --git a/lottie/src/main/java/com/airbnb/lottie/network/NetworkFetcher.java b/lottie/src/main/java/com/airbnb/lottie/network/NetworkFetcher.java
index 3f01a76..d698c54 100644
--- a/lottie/src/main/java/com/airbnb/lottie/network/NetworkFetcher.java
+++ b/lottie/src/main/java/com/airbnb/lottie/network/NetworkFetcher.java
@@ -5,10 +5,10 @@
 import androidx.annotation.WorkerThread;
 import androidx.core.util.Pair;
 
-import com.airbnb.lottie.L;
 import com.airbnb.lottie.LottieComposition;
 import com.airbnb.lottie.LottieCompositionFactory;
 import com.airbnb.lottie.LottieResult;
+import com.airbnb.lottie.utils.Logger;
 
 import java.io.BufferedReader;
 import java.io.File;
@@ -44,7 +44,7 @@
       return new LottieResult<>(result);
     }
 
-    L.debug("Animation for " + url + " not found in cache. Fetching from network.");
+    Logger.debug("Animation for " + url + " not found in cache. Fetching from network.");
     return fetchFromNetwork();
   }
 
@@ -84,21 +84,39 @@
 
   @WorkerThread
   private LottieResult fetchFromNetworkInternal() throws IOException {
-    L.debug( "Fetching " + url);
+    Logger.debug("Fetching " + url);
+
+
     HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
-
     connection.setRequestMethod("GET");
-    connection.connect();
 
-    if (connection.getErrorStream() != null || connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
-      BufferedReader r = new BufferedReader(new InputStreamReader(connection.getErrorStream()));
-      StringBuilder error = new StringBuilder();
-      String line;
-      while ((line = r.readLine()) != null) {
-        error.append(line).append('\n');
+    try {
+      connection.connect();
+
+      if (connection.getErrorStream() != null || connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
+
+        int responseCode = connection.getResponseCode();
+        BufferedReader r = new BufferedReader(new InputStreamReader(connection.getErrorStream()));
+        StringBuilder error = new StringBuilder();
+        String line;
+
+        try {
+          while ((line = r.readLine()) != null) {
+            error.append(line).append('\n');
+          }
+        } catch (Exception e) {
+          throw e;
+        } finally {
+          r.close();
+        }
+
+        return new LottieResult<>(new IllegalArgumentException("Unable to fetch " + url + ". Failed with " +
+            responseCode + "\n" + error));
       }
-      return new LottieResult<>(new IllegalArgumentException("Unable to fetch " + url + ". Failed with " +
-          connection.getResponseCode() + "\n" + error));
+    } catch (Exception e) {
+      return new LottieResult<>(e);
+    } finally {
+      connection.disconnect();
     }
 
     File file;
@@ -106,14 +124,14 @@
     LottieResult<LottieComposition> result;
     switch (connection.getContentType()) {
       case "application/zip":
-        L.debug("Handling zip response.");
+        Logger.debug("Handling zip response.");
         extension = FileExtension.ZIP;
         file = networkCache.writeTempCacheFile(connection.getInputStream(), extension);
         result = LottieCompositionFactory.fromZipStreamSync(new ZipInputStream(new FileInputStream(file)), url);
         break;
       case "application/json":
       default:
-        L.debug("Received json response.");
+        Logger.debug("Received json response.");
         extension = FileExtension.JSON;
         file = networkCache.writeTempCacheFile(connection.getInputStream(), extension);
         result = LottieCompositionFactory.fromJsonInputStreamSync(new FileInputStream(new File(file.getAbsolutePath())), url);
@@ -124,7 +142,7 @@
       networkCache.renameTempFile(extension);
     }
 
-    L.debug("Completed fetch from network. Success: " + (result.getValue() != null));
+    Logger.debug("Completed fetch from network. Success: " + (result.getValue() != null));
     return result;
   }
 }
diff --git a/lottie/src/main/java/com/airbnb/lottie/parser/ContentModelParser.java b/lottie/src/main/java/com/airbnb/lottie/parser/ContentModelParser.java
index 1df423c..f2430d4 100644
--- a/lottie/src/main/java/com/airbnb/lottie/parser/ContentModelParser.java
+++ b/lottie/src/main/java/com/airbnb/lottie/parser/ContentModelParser.java
@@ -2,11 +2,10 @@
 
 import androidx.annotation.Nullable;
 import android.util.JsonReader;
-import android.util.Log;
 
-import com.airbnb.lottie.L;
 import com.airbnb.lottie.LottieComposition;
 import com.airbnb.lottie.model.content.ContentModel;
+import com.airbnb.lottie.utils.Logger;
 
 import java.io.IOException;
 
@@ -87,7 +86,7 @@
         model = RepeaterParser.parse(reader, composition);
         break;
       default:
-        Log.w(L.TAG, "Unknown shape type " + type);
+        Logger.warning("Unknown shape type " + type);
     }
 
     while (reader.hasNext()) {
diff --git a/lottie/src/main/java/com/airbnb/lottie/parser/LottieCompositionParser.java b/lottie/src/main/java/com/airbnb/lottie/parser/LottieCompositionParser.java
index 0d560f5..afe8f2d 100644
--- a/lottie/src/main/java/com/airbnb/lottie/parser/LottieCompositionParser.java
+++ b/lottie/src/main/java/com/airbnb/lottie/parser/LottieCompositionParser.java
@@ -5,13 +5,13 @@
 import androidx.collection.SparseArrayCompat;
 import android.util.JsonReader;
 
-import com.airbnb.lottie.L;
 import com.airbnb.lottie.LottieComposition;
 import com.airbnb.lottie.LottieImageAsset;
 import com.airbnb.lottie.model.Font;
 import com.airbnb.lottie.model.FontCharacter;
 import com.airbnb.lottie.model.Marker;
 import com.airbnb.lottie.model.layer.Layer;
+import com.airbnb.lottie.utils.Logger;
 import com.airbnb.lottie.utils.Utils;
 
 import java.io.IOException;
@@ -114,7 +114,7 @@
       layerMap.put(layer.getId(), layer);
 
       if (imageCount > 4) {
-        L.warn("You have " + imageCount + " images. Lottie should primarily be " +
+        Logger.warning("You have " + imageCount + " images. Lottie should primarily be " +
             "used with shapes. If you are using Adobe Illustrator, convert the Illustrator layers" +
             " to shape layers.");
       }
diff --git a/lottie/src/main/java/com/airbnb/lottie/parser/MaskParser.java b/lottie/src/main/java/com/airbnb/lottie/parser/MaskParser.java
index a25300e..108bcac 100644
--- a/lottie/src/main/java/com/airbnb/lottie/parser/MaskParser.java
+++ b/lottie/src/main/java/com/airbnb/lottie/parser/MaskParser.java
@@ -1,13 +1,12 @@
 package com.airbnb.lottie.parser;
 
 import android.util.JsonReader;
-import android.util.Log;
 
-import com.airbnb.lottie.L;
 import com.airbnb.lottie.LottieComposition;
 import com.airbnb.lottie.model.animatable.AnimatableIntegerValue;
 import com.airbnb.lottie.model.animatable.AnimatableShapeValue;
 import com.airbnb.lottie.model.content.Mask;
+import com.airbnb.lottie.utils.Logger;
 
 import java.io.IOException;
 
@@ -40,7 +39,7 @@
               maskMode = Mask.MaskMode.MASK_MODE_INTERSECT;
               break;
             default:
-              Log.w(L.TAG, "Unknown mask mode " + mode + ". Defaulting to Add.");
+              Logger.warning("Unknown mask mode " + mode + ". Defaulting to Add.");
               maskMode = Mask.MaskMode.MASK_MODE_ADD;
           }
           break;
diff --git a/lottie/src/main/java/com/airbnb/lottie/utils/LogcatLogger.java b/lottie/src/main/java/com/airbnb/lottie/utils/LogcatLogger.java
new file mode 100644
index 0000000..ad166a5
--- /dev/null
+++ b/lottie/src/main/java/com/airbnb/lottie/utils/LogcatLogger.java
@@ -0,0 +1,46 @@
+package com.airbnb.lottie.utils;
+
+import android.util.Log;
+
+import com.airbnb.lottie.L;
+import com.airbnb.lottie.LottieLogger;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Default logger.
+ * Warnings with same message will only be logged once.
+ */
+public class LogcatLogger implements LottieLogger {
+
+  /**
+   * Set to ensure that we only log each message one time max.
+   */
+  private static final Set<String> loggedMessages = new HashSet<>();
+
+
+  public void debug(String message) {
+    debug(message, null);
+  }
+
+  public void debug(String message, Throwable exception) {
+    if (L.DBG) {
+      Log.d(L.TAG, message, exception);
+    }
+  }
+
+  public void warning(String message) {
+    warning(message, null);
+  }
+
+  public void warning(String message, Throwable exception) {
+    if (loggedMessages.contains(message)) {
+      return;
+    }
+
+    Log.w(L.TAG, message, exception);
+
+    loggedMessages.add(message);
+  }
+}
diff --git a/lottie/src/main/java/com/airbnb/lottie/utils/Logger.java b/lottie/src/main/java/com/airbnb/lottie/utils/Logger.java
new file mode 100644
index 0000000..acf4ef7
--- /dev/null
+++ b/lottie/src/main/java/com/airbnb/lottie/utils/Logger.java
@@ -0,0 +1,32 @@
+package com.airbnb.lottie.utils;
+
+import com.airbnb.lottie.LottieLogger;
+
+/**
+ * Singleton object for logging. If you want to provide a custom logger implementation,
+ * implements LottieLogger interface in a custom class and replace Logger.instance
+ */
+public class Logger {
+
+  private static LottieLogger INSTANCE = new LogcatLogger();
+
+  public static void setInstance(LottieLogger instance) {
+    Logger.INSTANCE = instance;
+  }
+
+  public static void debug(String message) {
+    INSTANCE.debug(message);
+  }
+
+  public static void debug(String message, Throwable exception) {
+    INSTANCE.debug(message, exception);
+  }
+
+  public static void warning(String message) {
+    INSTANCE.warning(message);
+  }
+
+  public static void warning(String message, Throwable exception) {
+    INSTANCE.warning(message, exception);
+  }
+}