diff --git a/examples/rollbar-android/build.gradle b/examples/rollbar-android/build.gradle index 41e42406..6271b056 100644 --- a/examples/rollbar-android/build.gradle +++ b/examples/rollbar-android/build.gradle @@ -11,14 +11,14 @@ buildscript { apply plugin: 'com.android.application' android { - compileSdkVersion 27 + compileSdkVersion 33 buildToolsVersion "30.0.3" defaultConfig { applicationId "com.rollbar.example.android" - minSdkVersion 16 + minSdkVersion 21 // FIXME: Pending further discussion //noinspection ExpiredTargetSdkVersion - targetSdkVersion 27 + targetSdkVersion 33 versionCode 1 versionName "1.0" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" diff --git a/examples/rollbar-android/src/main/AndroidManifest.xml b/examples/rollbar-android/src/main/AndroidManifest.xml index 3e3d8183..7a40e790 100644 --- a/examples/rollbar-android/src/main/AndroidManifest.xml +++ b/examples/rollbar-android/src/main/AndroidManifest.xml @@ -16,7 +16,8 @@ android:theme="@style/AppTheme"> diff --git a/rollbar-android/build.gradle b/rollbar-android/build.gradle index 7a155de4..b81741da 100644 --- a/rollbar-android/build.gradle +++ b/rollbar-android/build.gradle @@ -18,14 +18,14 @@ apply from: "$rootDir/gradle/release.gradle" apply from: "$rootDir/gradle/android.quality.gradle" android { - compileSdkVersion 27 + compileSdkVersion 33 buildToolsVersion '30.0.3' // Going above here requires bumping the AGP to version 4+ defaultConfig { - minSdkVersion 16 + minSdkVersion 21 // FIXME: Pending further discussion //noinspection ExpiredTargetSdkVersion - targetSdkVersion 27 + targetSdkVersion 33 consumerProguardFiles 'proguard-rules.pro' manifestPlaceholders = [notifierVersion: VERSION_NAME] } diff --git a/rollbar-android/src/main/java/com/rollbar/android/AndroidConfiguration.java b/rollbar-android/src/main/java/com/rollbar/android/AndroidConfiguration.java new file mode 100644 index 00000000..0474da47 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/AndroidConfiguration.java @@ -0,0 +1,38 @@ +package com.rollbar.android; + +import com.rollbar.android.anr.AnrConfiguration; + +public class AndroidConfiguration { + private final AnrConfiguration anrConfiguration; + + AndroidConfiguration(Builder builder) { + anrConfiguration = builder.anrConfiguration; + } + + public AnrConfiguration getAnrConfiguration() { + return anrConfiguration; + } + + + public static final class Builder { + private AnrConfiguration anrConfiguration; + + Builder() { + anrConfiguration = new AnrConfiguration.Builder().build(); + } + + /** + * The ANR configuration, if this field is null, no ANR would be captured + * @param anrConfiguration the ANR configuration + * @return the builder instance + */ + public Builder setAnrConfiguration(AnrConfiguration anrConfiguration) { + this.anrConfiguration = anrConfiguration; + return this; + } + + public AndroidConfiguration build() { + return new AndroidConfiguration(this); + } + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/Rollbar.java b/rollbar-android/src/main/java/com/rollbar/android/Rollbar.java index 5fe245c4..c5ecc827 100644 --- a/rollbar-android/src/main/java/com/rollbar/android/Rollbar.java +++ b/rollbar-android/src/main/java/com/rollbar/android/Rollbar.java @@ -10,6 +10,10 @@ import android.os.Bundle; import android.util.Log; + +import com.rollbar.android.anr.AnrDetector; +import com.rollbar.android.anr.AnrDetectorFactory; +import com.rollbar.android.anr.AnrException; import com.rollbar.android.notifier.sender.ConnectionAwareSenderFailureStrategy; import com.rollbar.android.provider.ClientProvider; import com.rollbar.api.payload.data.TelemetryType; @@ -25,11 +29,14 @@ import com.rollbar.notifier.sender.queue.DiskQueue; import com.rollbar.notifier.util.ObjectsUtils; +import org.slf4j.LoggerFactory; + import java.io.Closeable; import java.io.File; import java.io.IOException; import java.lang.Thread.UncaughtExceptionHandler; import java.util.Collections; +import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -71,6 +78,28 @@ public static Rollbar init(Context context) { return init(context, null, null); } + /** + * Initialize the singleton instance of Rollbar. + * Defaults to reading the access token from the manifest, handling uncaught exceptions, and setting + * the environment to production. + * + * @param context Android context to use. + * @param androidConfiguration configuration for Android features. + * @return the managed instance of Rollbar. + */ + public static Rollbar init(Context context, AndroidConfiguration androidConfiguration) { + return init( + context, + null, + DEFAULT_ENVIRONMENT, + DEFAULT_REGISTER_EXCEPTION_HANDLER, + DEFAULT_INCLUDE_LOGCAT, + DEFAULT_CONFIG_PROVIDER, + DEFAULT_SUSPEND_WHEN_NETWORK_IS_UNAVAILABLE, + androidConfiguration + ); + } + /** * Initialize the singleton instance of Rollbar. * @@ -155,18 +184,72 @@ public static Rollbar init(Context context, String accessToken, String environme public static Rollbar init(Context context, String accessToken, String environment, boolean registerExceptionHandler, boolean includeLogcat, ConfigProvider provider, boolean suspendWhenNetworkIsUnavailable) { + return init( + context, + accessToken, + environment, + registerExceptionHandler, + includeLogcat, + provider, + suspendWhenNetworkIsUnavailable, + makeDefaultAndroidConfiguration() + ); + } + + /** + * Initialize the singleton instance of Rollbar. + * + * @param context Android context to use. + * @param accessToken a Rollbar access token with at least post_client_item scope + * @param environment the environment to set for items + * @param registerExceptionHandler whether or not to handle uncaught exceptions. + * @param includeLogcat whether or not to include logcat output with items + * @param provider a configuration provider that can be used to customize the configuration further. + * @param suspendWhenNetworkIsUnavailable if true, sending occurrences will be suspended while the network is unavailable + * @param androidConfiguration configuration for Android features + * @return the managed instance of Rollbar. + */ + public static Rollbar init( + Context context, + String accessToken, + String environment, + boolean registerExceptionHandler, + boolean includeLogcat, + ConfigProvider provider, + boolean suspendWhenNetworkIsUnavailable, + AndroidConfiguration androidConfiguration + ) { if (isInit()) { Log.w(TAG, "Rollbar.init() called when it was already initialized."); // This is likely an activity that was destroyed and recreated, so we need to update it notifier.updateContext(context); } else { notifier = new Rollbar(context, accessToken, environment, registerExceptionHandler, - includeLogcat, provider, DEFAULT_CAPTURE_IP, DEFAULT_MAX_LOGCAT_SIZE, - suspendWhenNetworkIsUnavailable); + includeLogcat, provider, DEFAULT_CAPTURE_IP, DEFAULT_MAX_LOGCAT_SIZE, + suspendWhenNetworkIsUnavailable); + } + + if (androidConfiguration != null && !isInit()) { + initAnrDetector(context, androidConfiguration); } + return notifier; } + private static void initAnrDetector( + Context context, + AndroidConfiguration androidConfiguration + ) { + AnrDetector anrDetector = AnrDetectorFactory.create( + context, + LoggerFactory.getLogger(AnrDetectorFactory.class), + androidConfiguration.getAnrConfiguration(), + error -> reportANR(error)); + if (anrDetector != null) { + anrDetector.init(); + } + } + private void updateContext(Context context) { if (this.senderFailureStrategy != null) { this.senderFailureStrategy.updateContext(context); @@ -1086,4 +1169,15 @@ private static void ensureInit(Runnable runnable) { } } + private static void reportANR(AnrException error){ + Map map = new HashMap<>(); + map.put("TYPE", "ANR"); + map.put("Threads", error.getThreads()); + notifier.log(error, map); + } + + private static AndroidConfiguration makeDefaultAndroidConfiguration() { + return new AndroidConfiguration.Builder().build(); + } + } diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/AnrConfiguration.java b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrConfiguration.java new file mode 100644 index 00000000..f9af3916 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrConfiguration.java @@ -0,0 +1,44 @@ +package com.rollbar.android.anr; + +import com.rollbar.android.anr.watchdog.WatchdogConfiguration; + +public class AnrConfiguration { + WatchdogConfiguration watchdogConfiguration; + boolean captureHistoricalAnr; + + public AnrConfiguration(Builder builder) { + this.watchdogConfiguration = builder.watchdogConfiguration; + this.captureHistoricalAnr = builder.captureHistoricalAnr; + } + + public static final class Builder { + private boolean captureHistoricalAnr = true; + private WatchdogConfiguration watchdogConfiguration = new WatchdogConfiguration.Builder().build(); + + /** + * The WatchdogConfiguration configuration, if this field is null, no ANR would be captured. + * By default this feature is on, in build versions < 30. + * @param watchdogConfiguration the Watchdog configuration + * @return the builder instance + */ + public Builder setWatchdogConfiguration(WatchdogConfiguration watchdogConfiguration) { + this.watchdogConfiguration = watchdogConfiguration; + return this; + } + + /** + * A flag to turn on or off the HistoricalAnr detector implementation. + * This implementation is used if the build version is >= 30 + * @param captureHistoricalAnr HistoricalAnrDetector flag + * @return the builder instance + */ + public Builder setCaptureHistoricalAnr(boolean captureHistoricalAnr) { + this.captureHistoricalAnr = captureHistoricalAnr; + return this; + } + + public AnrConfiguration build() { + return new AnrConfiguration(this); + } + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/AnrDetector.java b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrDetector.java new file mode 100644 index 00000000..2054b7ea --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrDetector.java @@ -0,0 +1,5 @@ +package com.rollbar.android.anr; + +public interface AnrDetector { + void init(); +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/AnrDetectorFactory.java b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrDetectorFactory.java new file mode 100644 index 00000000..0bee60d9 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrDetectorFactory.java @@ -0,0 +1,55 @@ +package com.rollbar.android.anr; + +import android.content.Context; +import android.os.Build; + +import com.rollbar.android.anr.historical.HistoricalAnrDetector; +import com.rollbar.android.anr.watchdog.WatchdogAnrDetector; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AnrDetectorFactory { + + public static AnrDetector create( + Context context, + Logger logger, + AnrConfiguration anrConfiguration, + AnrListener anrListener + ) { + if (anrConfiguration == null) { + logger.warn("No ANR configuration"); + return null; + } + if (context == null) { + logger.warn("No context"); + return null; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (!anrConfiguration.captureHistoricalAnr) { + logger.warn("Historical ANR capture is off"); + return null; + } + + logger.debug("Creating HistoricalAnrDetector"); + return new HistoricalAnrDetector(context, anrListener, createHistoricalAnrDetectorLogger()); + } else { + if (anrConfiguration.watchdogConfiguration == null) { + logger.warn("No Watchdog configuration"); + return null; + } + + logger.debug("Creating WatchdogAnrDetector"); + return new WatchdogAnrDetector( + context, + anrConfiguration.watchdogConfiguration, + anrListener + ); + } + } + + private static Logger createHistoricalAnrDetectorLogger() { + return LoggerFactory.getLogger(HistoricalAnrDetector.class); + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/AnrException.java b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrException.java new file mode 100644 index 00000000..7b717c1a --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrException.java @@ -0,0 +1,27 @@ +package com.rollbar.android.anr; + +import com.rollbar.android.anr.historical.stacktrace.RollbarThread; + +import java.util.ArrayList; +import java.util.List; + +public final class AnrException extends RuntimeException { + + private List threads = new ArrayList<>(); + + public AnrException(String message, Thread thread) { + super(message); + setStackTrace(thread.getStackTrace()); + } + + public AnrException(StackTraceElement[] mainStackTraceElements, List threads) { + super("Application Not Responding"); + setStackTrace(mainStackTraceElements); + this.threads = threads; + } + + public List getThreads() { + return threads; + } + +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/AnrListener.java b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrListener.java new file mode 100644 index 00000000..1224d8e1 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/AnrListener.java @@ -0,0 +1,10 @@ +package com.rollbar.android.anr; + +public interface AnrListener { + /** + * Called when an ANR is detected. + * + * @param error The error describing the ANR. + */ + void onAppNotResponding(AnrException error); +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/historical/HistoricalAnrDetector.java b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/HistoricalAnrDetector.java new file mode 100644 index 00000000..2daf751e --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/HistoricalAnrDetector.java @@ -0,0 +1,157 @@ +package com.rollbar.android.anr.historical; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.app.ApplicationExitInfo; +import android.content.Context; + +import com.rollbar.android.anr.AnrDetector; +import com.rollbar.android.anr.AnrException; +import com.rollbar.android.anr.AnrListener; +import com.rollbar.android.anr.historical.stacktrace.Lines; +import com.rollbar.android.anr.historical.stacktrace.RollbarThread; +import com.rollbar.android.anr.historical.stacktrace.ThreadParser; + +import org.slf4j.Logger; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@SuppressLint("NewApi") // Validated in the Factory +public class HistoricalAnrDetector implements AnrDetector { + private final Logger logger; + private final Context context; + private final AnrListener anrListener; + + public HistoricalAnrDetector( + Context context, + AnrListener anrListener, + Logger logger + ) { + this.context = context; + this.anrListener = anrListener; + this.logger = logger; + } + + @Override + public void init() { + if (anrListener == null) { + logger.error("AnrListener is null"); + return; + } + Thread thread = new Thread("HistoricalAnrDetectorThread") { + @Override + public void run() { + super.run(); + evaluateLastExitReasons(); + } + }; + thread.setDaemon(true); + thread.start(); + } + + + private void evaluateLastExitReasons() { + List applicationExitInfoList = getApplicationExitInformation(); + + if (applicationExitInfoList.isEmpty()) { + logger.debug("Empty ApplicationExitInfo List"); + return; + } + + for (ApplicationExitInfo applicationExitInfo : applicationExitInfoList) { + if (isNotAnr(applicationExitInfo)) { + continue; + } + + try { + List threads = getThreads(applicationExitInfo); + + if (threads.isEmpty()) { + logger.error("Error parsing ANR"); + continue; + } + + AnrException anrException = createAnrException(threads); + if (anrException == null) { + logger.error("Main thread not found, skipping ANR"); + } else { + anrListener.onAppNotResponding(anrException); + } + } catch (Throwable e) { + logger.error("Can't parse ANR", e); + } + } + } + + private boolean isNotAnr(ApplicationExitInfo applicationExitInfo) { + return applicationExitInfo.getReason() != ApplicationExitInfo.REASON_ANR; + } + + private AnrException createAnrException(List threads) { + List rollbarThreads = new ArrayList<>(); + RollbarThread mainThread = null; + for (RollbarThread thread: threads) { + if (thread.isMain()) { + mainThread = thread; + } else { + rollbarThreads.add(thread); + } + } + + if (mainThread == null) { + return null; + } + return new AnrException(mainThread.toStackTraceElement(), rollbarThreads); + } + + private List getApplicationExitInformation() { + ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + return activityManager.getHistoricalProcessExitReasons(null, 0, 0); + } + + private List getThreads(ApplicationExitInfo applicationExitInfo) throws IOException { + Lines lines = getLines(applicationExitInfo); + ThreadParser threadParser = new ThreadParser(isBackground(applicationExitInfo)); + return threadParser.parse(lines); + } + + private boolean isBackground(ApplicationExitInfo applicationExitInfo) { + return applicationExitInfo.getImportance() != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; + } + + private Lines getLines(ApplicationExitInfo applicationExitInfo) throws IOException { + byte[] dump = getDumpBytes(Objects.requireNonNull(applicationExitInfo.getTraceInputStream())); + return getLines(dump); + } + + private Lines getLines(byte[] dump) throws IOException { + return Lines.readLines(toBufferReader(dump)); + } + + private BufferedReader toBufferReader(byte[] dump) { + return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(dump))); + } + + private byte[] getDumpBytes(final InputStream trace) throws IOException { + try (final ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + + int nRead; + byte[] data = new byte[1024]; + + while ((nRead = trace.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + + return buffer.toByteArray(); + } + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/Line.java b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/Line.java new file mode 100644 index 00000000..9764d032 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/Line.java @@ -0,0 +1,13 @@ +package com.rollbar.android.anr.historical.stacktrace; + +public final class Line { + private String text; + + public Line(final String text) { + this.text = text; + } + + public String getText() { + return text; + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/Lines.java b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/Lines.java new file mode 100644 index 00000000..15f0a02c --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/Lines.java @@ -0,0 +1,55 @@ +package com.rollbar.android.anr.historical.stacktrace; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.ArrayList; + +public final class Lines { + private final ArrayList mList; + private final int mMin; + private final int mMax; + + /** The read position inside the list. */ + public int pos; + + /** Read the whole file into a Lines object. */ + public static Lines readLines(final BufferedReader in) throws IOException { + final ArrayList list = new ArrayList<>(); + + String text; + while ((text = in.readLine()) != null) { + list.add(new Line(text)); + } + + return new Lines(list); + } + + /** Construct with a list of lines. */ + public Lines(final ArrayList list) { + this.mList = list; + mMin = 0; + mMax = mList.size(); + } + + /** If there are more lines to read within the current range. */ + public boolean hasNext() { + return pos < mMax; + } + + /** + * Return the next line, or null if there are no more lines to read. Also returns null in the + * error condition where pos is before the beginning. + */ + public Line next() { + if (pos >= mMin && pos < mMax) { + return this.mList.get(pos++); + } else { + return null; + } + } + + /** Move the read position back by one line. */ + public void rewind() { + pos--; + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/LockReason.java b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/LockReason.java new file mode 100644 index 00000000..db6929ae --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/LockReason.java @@ -0,0 +1,112 @@ +package com.rollbar.android.anr.historical.stacktrace; + +import com.rollbar.api.json.JsonSerializable; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +public class LockReason implements JsonSerializable { + + public static final int LOCKED = 1; + public static final int WAITING = 2; + public static final int SLEEPING = 4; + public static final int BLOCKED = 8; + + private int type; + private String address; + private String packageName; + private String className; + private Long threadId; + private Map unknown; + + public LockReason() {} + + public LockReason(final LockReason other) { + this.type = other.type; + this.address = other.address; + this.packageName = other.packageName; + this.className = other.className; + this.threadId = other.threadId; + if (other.unknown != null) { + this.unknown = new ConcurrentHashMap<>(other.unknown); + } + } + + @SuppressWarnings("unused") + public int getType() { + return type; + } + + public void setType(final int type) { + this.type = type; + } + + + public String getAddress() { + return address; + } + + public void setAddress(final String address) { + this.address = address; + } + + public void setPackageName(final String packageName) { + this.packageName = packageName; + } + + public void setClassName(final String className) { + this.className = className; + } + + public void setThreadId(final Long threadId) { + this.threadId = threadId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LockReason that = (LockReason) o; + return Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(address); + } + + @Override + public Object asJson() { + Map values = new HashMap<>(); + values.put(JsonKeys.TYPE, type); + if (address != null) { + values.put(JsonKeys.ADDRESS, address); + } + if (packageName != null) { + values.put(JsonKeys.PACKAGE_NAME, packageName); + } + if (className != null) { + values.put(JsonKeys.CLASS_NAME, className); + } + if (threadId != null) { + values.put(JsonKeys.THREAD_ID, threadId); + } + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + values.put(key, value); + } + } + return values; + } + + public static final class JsonKeys { + public static final String TYPE = "type"; + public static final String ADDRESS = "address"; + public static final String PACKAGE_NAME = "package_name"; + public static final String CLASS_NAME = "class_name"; + public static final String THREAD_ID = "thread_id"; + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/RollbarThread.java b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/RollbarThread.java new file mode 100644 index 00000000..bbc3fabc --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/RollbarThread.java @@ -0,0 +1,123 @@ +package com.rollbar.android.anr.historical.stacktrace; + +import com.rollbar.api.json.JsonSerializable; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +public class RollbarThread implements JsonSerializable { + private Long id; + private String name; + private String state; + private Boolean crashed; + private Boolean current; + private Boolean main; + private StackTrace stacktrace; + + private Map heldLocks; + + public StackTraceElement[] toStackTraceElement() { + return stacktrace.getStackTraceElements(); + } + + public void setId(final Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public void setCrashed(final Boolean crashed) { + this.crashed = crashed; + } + + public void setCurrent(final Boolean current) { + this.current = current; + } + + public void setStacktrace(final StackTrace stacktrace) { + this.stacktrace = stacktrace; + } + + public Boolean isMain() { + return main; + } + + public void setMain(final Boolean main) { + this.main = main; + } + + public void setState(final String state) { + this.state = state; + } + + public Map getHeldLocks() { + return heldLocks; + } + + public void setHeldLocks(final Map heldLocks) { + this.heldLocks = heldLocks; + } + + @Override + public Object asJson() { + Map values = new HashMap<>(); + + if (id != null) { + values.put(JsonKeys.ID, id); + } + if (name != null) { + values.put(JsonKeys.NAME, name); + } + if (state != null) { + values.put(JsonKeys.STATE, state); + } + if (crashed != null) { + values.put(JsonKeys.CRASHED, crashed); + } + if (current != null) { + values.put(JsonKeys.CURRENT, current); + } + if (main != null) { + values.put(JsonKeys.MAIN, main); + } + if (stacktrace != null) { + values.put(JsonKeys.STACKTRACE, stacktrace); + } + if (heldLocks != null) { + values.put(JsonKeys.HELD_LOCKS, heldLocks); + } + return values; + } + + @Override + public String toString() { + return "RollbarThread{" + + "id=" + id + + ", name='" + name + '\'' + + ", state='" + state + '\'' + + ", crashed=" + crashed + + ", current=" + current + + ", main=" + main + + ", stacktrace=" + Arrays.toString(toStackTraceElement()) + + ", heldLocks=" + heldLocks + + '}'; + } + + public static final class JsonKeys { + public static final String ID = "id"; + public static final String NAME = "name"; + public static final String STATE = "state"; + public static final String CRASHED = "crashed"; + public static final String CURRENT = "current"; + public static final String MAIN = "main"; + public static final String STACKTRACE = "stacktrace"; + public static final String HELD_LOCKS = "held_locks"; + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/StackFrame.java b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/StackFrame.java new file mode 100644 index 00000000..66108dc9 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/StackFrame.java @@ -0,0 +1,82 @@ +package com.rollbar.android.anr.historical.stacktrace; + +import com.rollbar.api.json.JsonSerializable; + +import java.util.HashMap; +import java.util.Map; + +public class StackFrame implements JsonSerializable { + + private String filename = ""; + private String function = ""; + private String module = ""; + private Integer lineno = 0; + private String _package; + + public StackTraceElement toStackTraceElement() { + return new StackTraceElement(module, function, filename, lineno); + } + + private LockReason lock; + + public void setFilename(final String filename) { + this.filename = filename; + } + + public void setFunction(final String function) { + this.function = function; + } + + public void setModule(final String module) { + this.module = module; + } + + public void setLineno(final Integer lineno) { + this.lineno = lineno; + } + + public String getPackage() { + return _package; + } + + public void setPackage(final String _package) { + this._package = _package; + } + + public void setLock(final LockReason lock) { + this.lock = lock; + } + + @Override + public Object asJson() { + Map values = new HashMap<>(); + if (filename != null) { + values.put(JsonKeys.FILENAME, filename); + } + if (function != null) { + values.put(JsonKeys.FUNCTION, function); + } + if (module != null) { + values.put(JsonKeys.MODULE, module); + } + if (lineno != null) { + values.put(JsonKeys.LINENO, lineno); + } + if (_package != null) { + values.put(JsonKeys.PACKAGE, _package); + } + if (lock != null) { + values.put(JsonKeys.LOCK, lock); + } + return values; + } + + public static final class JsonKeys { + public static final String FILENAME = "filename"; + public static final String FUNCTION = "function"; + public static final String MODULE = "module"; + public static final String LINENO = "lineno"; + public static final String PACKAGE = "package"; + public static final String LOCK = "lock"; + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/StackTrace.java b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/StackTrace.java new file mode 100644 index 00000000..3e8c101d --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/StackTrace.java @@ -0,0 +1,49 @@ +package com.rollbar.android.anr.historical.stacktrace; + +import com.rollbar.api.json.JsonSerializable; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class StackTrace implements JsonSerializable { + + private final List frames; + private Boolean snapshot; + + public StackTrace(final List frames) { + this.frames = frames; + } + + public StackTraceElement[] getStackTraceElements() { + StackTraceElement[] stackTraceElements = new StackTraceElement[frames.size()]; + int element = 0; + for (StackFrame frame : frames) { + stackTraceElements[element] = frame.toStackTraceElement(); + element++; + } + return stackTraceElements; + } + + public void setSnapshot(final Boolean snapshot) { + this.snapshot = snapshot; + } + + @Override + public Object asJson() { + Map values = new HashMap<>(); + + if (frames != null) { + values.put(JsonKeys.FRAMES, frames); + } + if (snapshot != null) { + values.put(JsonKeys.SNAPSHOT, snapshot); + } + return values; + } + + public static final class JsonKeys { + public static final String FRAMES = "frames"; + public static final String SNAPSHOT = "snapshot"; + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/ThreadParser.java b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/ThreadParser.java new file mode 100644 index 00000000..c97687ec --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/historical/stacktrace/ThreadParser.java @@ -0,0 +1,315 @@ +package com.rollbar.android.anr.historical.stacktrace; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class ThreadParser { + private final Logger LOGGER = LoggerFactory.getLogger(this.getClass()); + + private static final Pattern BEGIN_MANAGED_THREAD_RE = + Pattern.compile("\"(.*)\" (.*) ?prio=(\\d+)\\s+tid=(\\d+)\\s*(.*)"); + + private static final Pattern BEGIN_UNMANAGED_NATIVE_THREAD_RE = + Pattern.compile("\"(.*)\" (.*) ?sysTid=(\\d+)"); + + private static final Pattern NATIVE_RE = + Pattern.compile( + " *(?:native: )?#\\d+ \\S+ [0-9a-fA-F]+\\s+(.*?)\\s+\\((.*)\\+(\\d+)\\)(?: \\(.*\\))?"); + private static final Pattern NATIVE_NO_LOC_RE = + Pattern.compile( + " *(?:native: )?#\\d+ \\S+ [0-9a-fA-F]+\\s+(.*)\\s*\\(?(.*)\\)?(?: \\(.*\\))?"); + private static final Pattern JAVA_RE = + Pattern.compile(" *at (?:(.+)\\.)?([^.]+)\\.([^.]+)\\((.*):([\\d-]+)\\)"); + private static final Pattern JNI_RE = + Pattern.compile(" *at (?:(.+)\\.)?([^.]+)\\.([^.]+)\\(Native method\\)"); + private static final Pattern LOCKED_RE = + Pattern.compile(" *- locked \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)"); + private static final Pattern SLEEPING_ON_RE = + Pattern.compile(" *- sleeping on \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)"); + private static final Pattern WAITING_ON_RE = + Pattern.compile(" *- waiting on \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)"); + private static final Pattern WAITING_TO_LOCK_RE = + Pattern.compile( + " *- waiting to lock \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)"); + private static final Pattern WAITING_TO_LOCK_HELD_RE = + Pattern.compile( + " *- waiting to lock \\<([0x0-9a-fA-F]{1,16})\\> \\(a (?:(.+)\\.)?([^.]+)\\)" + + "(?: held by thread (\\d+))"); + private static final Pattern WAITING_TO_LOCK_UNKNOWN_RE = + Pattern.compile(" *- waiting to lock an unknown object"); + private static final Pattern BLANK_RE = Pattern.compile("\\s+"); + + + private final boolean isBackground; + + public ThreadParser(final boolean isBackground) { + this.isBackground = isBackground; + } + + + public List parse(final Lines lines) { + final List rollbarThreads = new ArrayList<>(); + + final Matcher beginManagedThreadRe = BEGIN_MANAGED_THREAD_RE.matcher(""); + final Matcher beginUnmanagedNativeThreadRe = BEGIN_UNMANAGED_NATIVE_THREAD_RE.matcher(""); + + while (lines.hasNext()) { + Line line = lines.next(); + if (line == null) { + LOGGER.warn("No line: Internal error while parsing thread dump"); + return rollbarThreads; + } + final String text = line.getText(); + + if (matches(beginManagedThreadRe, text) || matches(beginUnmanagedNativeThreadRe, text)) { + lines.rewind(); + + final RollbarThread rollbarThread = parseThread(lines); + if (rollbarThread != null) { + rollbarThreads.add(rollbarThread); + } + } + } + return rollbarThreads; + } + + private RollbarThread parseThread(final Lines lines) { + final RollbarThread RollbarThread = new RollbarThread(); + + final Matcher beginManagedThreadRe = BEGIN_MANAGED_THREAD_RE.matcher(""); + final Matcher beginUnmanagedNativeThreadRe = BEGIN_UNMANAGED_NATIVE_THREAD_RE.matcher(""); + + if (!lines.hasNext()) { + return null; + } + final Line line = lines.next(); + if (line == null) { + LOGGER.warn("Internal error while parsing thread dump"); + return null; + } + if (matches(beginManagedThreadRe, line.getText())) { + Long threadId = getLong(beginManagedThreadRe, 4, null); + if (threadId == null) { + LOGGER.debug("No thread id in the dump, skipping thread"); + return null; + } + RollbarThread.setId(threadId); + RollbarThread.setName(beginManagedThreadRe.group(1)); + final String state = beginManagedThreadRe.group(5); + + if (state != null) { + if (state.contains(" ")) { + RollbarThread.setState(state.substring(0, state.indexOf(' '))); + } else { + RollbarThread.setState(state); + } + } + } else if (matches(beginUnmanagedNativeThreadRe, line.getText())) { + Long systemThreadId = getLong(beginUnmanagedNativeThreadRe, 3, null); + if (systemThreadId == null) { + LOGGER.debug("No thread id in the dump, skipping thread"); + return null; + } + RollbarThread.setId(systemThreadId); + RollbarThread.setName(beginUnmanagedNativeThreadRe.group(1)); + } + + final String threadName = RollbarThread.getName(); + if (threadName != null) { + boolean isMain = threadName.equals("main"); + RollbarThread.setMain(isMain); + RollbarThread.setCrashed(isMain); + RollbarThread.setCurrent(isMain && !isBackground); + } + + final StackTrace stackTrace = parseStacktrace(lines, RollbarThread); + RollbarThread.setStacktrace(stackTrace); + return RollbarThread; + } + + + private StackTrace parseStacktrace( + final Lines lines, final RollbarThread rollbarThread) { + final List frames = new ArrayList<>(); + StackFrame lastJavaFrame = null; + + final Matcher nativeRe = NATIVE_RE.matcher(""); + final Matcher nativeNoLocRe = NATIVE_NO_LOC_RE.matcher(""); + final Matcher javaRe = JAVA_RE.matcher(""); + final Matcher jniRe = JNI_RE.matcher(""); + final Matcher lockedRe = LOCKED_RE.matcher(""); + final Matcher waitingOnRe = WAITING_ON_RE.matcher(""); + final Matcher sleepingOnRe = SLEEPING_ON_RE.matcher(""); + final Matcher waitingToLockHeldRe = WAITING_TO_LOCK_HELD_RE.matcher(""); + final Matcher waitingToLockRe = WAITING_TO_LOCK_RE.matcher(""); + final Matcher waitingToLockUnknownRe = WAITING_TO_LOCK_UNKNOWN_RE.matcher(""); + final Matcher blankRe = BLANK_RE.matcher(""); + + while (lines.hasNext()) { + final Line line = lines.next(); + if (line == null) { + LOGGER.warn("Internal error while parsing thread dump"); + break; + } + final String text = line.getText(); + if (matches(nativeRe, text)) { + final StackFrame frame = new StackFrame(); + frame.setPackage(nativeRe.group(1)); + frame.setFunction(nativeRe.group(2)); + frame.setLineno(getInteger(nativeRe, 3, null)); + frames.add(frame); + lastJavaFrame = null; + } else if (matches(nativeNoLocRe, text)) { + final StackFrame frame = new StackFrame(); + frame.setPackage(nativeNoLocRe.group(1)); + frame.setFunction(nativeNoLocRe.group(2)); + frames.add(frame); + lastJavaFrame = null; + } else if (matches(javaRe, text)) { + final StackFrame frame = new StackFrame(); + final String packageName = javaRe.group(1); + final String className = javaRe.group(2); + final String module = String.format("%s.%s", packageName, className); + frame.setModule(module); + frame.setFunction(javaRe.group(3)); + frame.setFilename(javaRe.group(4)); + frame.setLineno(getUInteger(javaRe, 5, null)); + frames.add(frame); + lastJavaFrame = frame; + } else if (matches(jniRe, text)) { + final StackFrame frame = new StackFrame(); + final String packageName = jniRe.group(1); + final String className = jniRe.group(2); + final String module = String.format("%s.%s", packageName, className); + frame.setModule(module); + frame.setFunction(jniRe.group(3)); + frames.add(frame); + lastJavaFrame = frame; + } else if (matches(lockedRe, text)) { + if (lastJavaFrame != null) { + final LockReason lock = new LockReason(); + lock.setType(LockReason.LOCKED); + lock.setAddress(lockedRe.group(1)); + lock.setPackageName(lockedRe.group(2)); + lock.setClassName(lockedRe.group(3)); + lastJavaFrame.setLock(lock); + combineThreadLocks(rollbarThread, lock); + } + } else if (matches(waitingOnRe, text)) { + if (lastJavaFrame != null) { + final LockReason lock = new LockReason(); + lock.setType(LockReason.WAITING); + lock.setAddress(waitingOnRe.group(1)); + lock.setPackageName(waitingOnRe.group(2)); + lock.setClassName(waitingOnRe.group(3)); + lastJavaFrame.setLock(lock); + combineThreadLocks(rollbarThread, lock); + } + } else if (matches(sleepingOnRe, text)) { + if (lastJavaFrame != null) { + final LockReason lock = new LockReason(); + lock.setType(LockReason.SLEEPING); + lock.setAddress(sleepingOnRe.group(1)); + lock.setPackageName(sleepingOnRe.group(2)); + lock.setClassName(sleepingOnRe.group(3)); + lastJavaFrame.setLock(lock); + combineThreadLocks(rollbarThread, lock); + } + } else if (matches(waitingToLockHeldRe, text)) { + if (lastJavaFrame != null) { + final LockReason lock = new LockReason(); + lock.setType(LockReason.BLOCKED); + lock.setAddress(waitingToLockHeldRe.group(1)); + lock.setPackageName(waitingToLockHeldRe.group(2)); + lock.setClassName(waitingToLockHeldRe.group(3)); + lock.setThreadId(getLong(waitingToLockHeldRe, 4, null)); + lastJavaFrame.setLock(lock); + combineThreadLocks(rollbarThread, lock); + } + } else if (matches(waitingToLockRe, text)) { + if (lastJavaFrame != null) { + final LockReason lock = new LockReason(); + lock.setType(LockReason.BLOCKED); + lock.setAddress(waitingToLockRe.group(1)); + lock.setPackageName(waitingToLockRe.group(2)); + lock.setClassName(waitingToLockRe.group(3)); + lastJavaFrame.setLock(lock); + combineThreadLocks(rollbarThread, lock); + } + } else if (matches(waitingToLockUnknownRe, text)) { + if (lastJavaFrame != null) { + final LockReason lock = new LockReason(); + lock.setType(LockReason.BLOCKED); + lastJavaFrame.setLock(lock); + combineThreadLocks(rollbarThread, lock); + } + } else if (text.length() == 0 || matches(blankRe, text)) { + break; + } + } + + Collections.reverse(frames);//Todo review later + final StackTrace stackTrace = new StackTrace(frames); + stackTrace.setSnapshot(true); + return stackTrace; + } + + private boolean matches(final Matcher matcher, final String text) { + matcher.reset(text); + return matcher.matches(); + } + + private void combineThreadLocks( + final RollbarThread rollbarThread, final LockReason lockReason) { + Map heldLocks = rollbarThread.getHeldLocks(); + if (heldLocks == null) { + heldLocks = new HashMap<>(); + } + final LockReason prev = heldLocks.get(lockReason.getAddress()); + if (prev != null) { + prev.setType(Math.max(prev.getType(), lockReason.getType())); + } else { + heldLocks.put(lockReason.getAddress(), new LockReason(lockReason)); + } + rollbarThread.setHeldLocks(heldLocks); + } + + private Long getLong( + final Matcher matcher, final int group, final Long defaultValue) { + final String str = matcher.group(group); + if (str == null || str.length() == 0) { + return defaultValue; + } else { + return Long.parseLong(str); + } + } + + private Integer getInteger( + final Matcher matcher, final int group, final Integer defaultValue) { + final String str = matcher.group(group); + if (str == null || str.isEmpty()) { + return defaultValue; + } else { + return Integer.parseInt(str); + } + } + + private Integer getUInteger( + final Matcher matcher, final int group, final Integer defaultValue) { + final String str = matcher.group(group); + if (str == null || str.isEmpty()) { + return defaultValue; + } else { + final Integer parsed = Integer.parseInt(str); + return parsed >= 0 ? parsed : defaultValue; + } + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/LooperHandler.java b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/LooperHandler.java new file mode 100644 index 00000000..c5782201 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/LooperHandler.java @@ -0,0 +1,19 @@ +package com.rollbar.android.anr.watchdog; + +import android.os.Handler; +import android.os.Looper; + +public class LooperHandler { + private final Handler handler; + LooperHandler() { + this.handler = new Handler(Looper.getMainLooper()); + } + + public void post(Runnable runnable) { + handler.post(runnable); + } + + public Thread getThread() { + return handler.getLooper().getThread(); + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchDog.java b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchDog.java new file mode 100644 index 00000000..3847eb91 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchDog.java @@ -0,0 +1,114 @@ +package com.rollbar.android.anr.watchdog; + +import static android.app.ActivityManager.ProcessErrorStateInfo.NOT_RESPONDING; + +import android.app.ActivityManager; +import android.content.Context; + +import com.rollbar.android.anr.AnrException; +import com.rollbar.android.anr.AnrListener; +import com.rollbar.notifier.provider.Provider; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class WatchDog extends Thread { + private final Logger LOGGER = LoggerFactory.getLogger(this.getClass()); + private static final String MESSAGE = "Application Not Responding for at least %s ms."; + + private final LooperHandler uiHandler; + private final Provider timeProvider; + private volatile long lastKnownActiveUiTimestampMs = 0; + private final AtomicBoolean reported = new AtomicBoolean(false); + private final Runnable ticker; + private final Context context; + private final AnrListener anrListener; + private final WatchdogConfiguration watchdogConfiguration; + + public WatchDog( + Context context, + AnrListener anrListener, + LooperHandler looperHandler, + WatchdogConfiguration watchdogConfiguration, + Provider timeProvider + ) { + uiHandler = looperHandler; + this.anrListener = anrListener; + this.context = context; + this.timeProvider = timeProvider; + this.watchdogConfiguration = watchdogConfiguration; + this.ticker = + () -> { + lastKnownActiveUiTimestampMs = timeProvider.provide(); + reported.set(false); + }; + } + + @Override + public void run() { + ticker.run(); + + while (!isInterrupted()) { + uiHandler.post(ticker); + + try { + Thread.sleep(watchdogConfiguration.getPollingIntervalMillis()); + } catch (InterruptedException e) { + try { + Thread.currentThread().interrupt(); + } catch (SecurityException ignored) { + LOGGER.warn("Failed to interrupt due to SecurityException: {}", e.getMessage()); + return; + } + LOGGER.warn("Interrupted: {}", e.getMessage()); + return; + } + + if (isMainThreadNotHandlerTicker()) { + if (isProcessNotResponding() && reported.compareAndSet(false, true)) { + anrListener.onAppNotResponding(makeException()); + } + } + } + } + + private AnrException makeException() { + return new AnrException(createAnrMessage(), uiHandler.getThread()); + } + + private String createAnrMessage() { + return String.format(MESSAGE, watchdogConfiguration.getTimeOutMillis()); + } + + private boolean isMainThreadNotHandlerTicker() { + long unresponsiveDurationMs = timeProvider.provide() - lastKnownActiveUiTimestampMs; + return unresponsiveDurationMs > watchdogConfiguration.getTimeOutMillis(); + } + + private boolean isProcessNotResponding() { + final ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) return true; + + List processesInErrorState = null; + try { + processesInErrorState = activityManager.getProcessesInErrorState(); + } catch (Exception e) { + LOGGER.error("Error getting ActivityManager#getProcessesInErrorState: {}", e.getMessage()); + } + + if (processesInErrorState == null) { + return false; + } + + for (ActivityManager.ProcessErrorStateInfo item : processesInErrorState) { + if (item.condition == NOT_RESPONDING) { + return true; + } + } + + return false; + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchdogAnrDetector.java b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchdogAnrDetector.java new file mode 100644 index 00000000..9d8593fd --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchdogAnrDetector.java @@ -0,0 +1,83 @@ +package com.rollbar.android.anr.watchdog; + +import android.annotation.SuppressLint; +import android.content.Context; + +import com.rollbar.android.anr.AnrDetector; +import com.rollbar.android.anr.AnrListener; +import com.rollbar.notifier.provider.timestamp.TimestampProvider; + +import java.io.Closeable; +import java.io.IOException; + +public class WatchdogAnrDetector implements AnrDetector, Closeable { + private boolean isClosed = false; + private final Object startLock = new Object(); + + @SuppressLint("StaticFieldLeak") + private static WatchDog watchDog; + private static final Object watchDogLock = new Object(); + + public WatchdogAnrDetector( + Context context, + WatchdogConfiguration watchdogConfiguration, + AnrListener anrListener + ) { + interruptWatchdog(); + createWatchdog(context, watchdogConfiguration, anrListener); + } + + @Override + public void init() { + if (watchDog == null) return; + + Thread thread = new Thread("WatchdogAnrDetectorThread") { + @Override + public void run() { + super.run(); + synchronized (startLock) { + if (!isClosed) { + watchDog.start(); + } + } + } + }; + thread.setDaemon(true); + thread.start(); + } + + @Override + public void close() throws IOException { + synchronized (startLock) { + isClosed = true; + } + interruptWatchdog(); + } + + private void createWatchdog( + Context context, + WatchdogConfiguration watchdogConfiguration, + AnrListener anrListener + ) { + if (context == null) return; + if (anrListener == null) return; + + watchDog = new WatchDog( + context, + anrListener, + new LooperHandler(), + watchdogConfiguration, + new TimestampProvider() + ); + } + + private void interruptWatchdog() { + synchronized (watchDogLock) { + if (watchDog != null) { + watchDog.interrupt(); + watchDog = null; + } + } + } + +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchdogConfiguration.java b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchdogConfiguration.java new file mode 100644 index 00000000..b16d894f --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/anr/watchdog/WatchdogConfiguration.java @@ -0,0 +1,64 @@ +package com.rollbar.android.anr.watchdog; + +public class WatchdogConfiguration { + private long pollingIntervalMillis; + private long timeOutMillis; + + WatchdogConfiguration(Builder builder) { + pollingIntervalMillis = builder.pollingIntervalMillis; + timeOutMillis = builder.timeOutMillis; + } + + /** + * Returns the Watchdog pooling interval in millis. + * Default is 500 + * + * @return the pooling interval in millis + */ + public long getPollingIntervalMillis() { + return pollingIntervalMillis; + } + + /** + * Returns the ANR timeout in millis. + * Default is 5000, 5 seconds + * + * @return the timeout in millis + */ + public long getTimeOutMillis() { + return timeOutMillis; + } + + public static final class Builder { + private long pollingIntervalMillis = 500; + private long timeOutMillis = 5000; + + /** + * Set the Watchdog pooling interval in millis. + * Default is 500 + * + * @param pollingIntervalMillis timeout in millis + * @return the builder instance + */ + public Builder setPollingIntervalMillis(long pollingIntervalMillis) { + this.pollingIntervalMillis = pollingIntervalMillis; + return this; + } + + /** + * Set the ANR timeout in millis. + * Default is 5000, 5 seconds + * + * @param timeOutMillis timeout in millis + * @return the builder instance + */ + public Builder setTimeOutMillis(long timeOutMillis) { + this.timeOutMillis = timeOutMillis; + return this; + } + + public WatchdogConfiguration build() { + return new WatchdogConfiguration(this); + } + } +} diff --git a/rollbar-android/src/test/java/com/rollbar/android/RollbarTest.java b/rollbar-android/src/test/java/com/rollbar/android/RollbarTest.java index 0ec880ab..98c65068 100644 --- a/rollbar-android/src/test/java/com/rollbar/android/RollbarTest.java +++ b/rollbar-android/src/test/java/com/rollbar/android/RollbarTest.java @@ -3,21 +3,17 @@ import android.content.Context; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; -import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.PackageInfo; import android.os.Bundle; -import android.test.mock.MockPackageManager; import com.rollbar.android.provider.NotifierProvider; import com.rollbar.api.payload.Payload; import com.rollbar.api.payload.data.Data; import com.rollbar.api.payload.data.Level; -import com.rollbar.api.payload.data.Notifier; import com.rollbar.notifier.config.Config; import com.rollbar.notifier.config.ConfigBuilder; import com.rollbar.notifier.config.ConfigProvider; import com.rollbar.notifier.filter.Filter; -import com.rollbar.notifier.provider.Provider; import com.rollbar.notifier.sender.BufferedSender; import com.rollbar.notifier.sender.Sender; import com.rollbar.notifier.sender.SyncSender; @@ -32,21 +28,13 @@ import static org.junit.Assert.*; import org.junit.Before; -import org.junit.Rule; import org.junit.Test; import org.mockito.Mock; -import org.mockito.invocation.InvocationOnMock; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; -import org.mockito.stubbing.Answer; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyObject; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.nullable; -import static org.mockito.hamcrest.MockitoHamcrest.argThat; -import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; diff --git a/rollbar-android/src/test/java/com/rollbar/android/anr/AnrDetectorFactoryTest.java b/rollbar-android/src/test/java/com/rollbar/android/anr/AnrDetectorFactoryTest.java new file mode 100644 index 00000000..151100dc --- /dev/null +++ b/rollbar-android/src/test/java/com/rollbar/android/anr/AnrDetectorFactoryTest.java @@ -0,0 +1,49 @@ +package com.rollbar.android.anr; + +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.content.Context; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.slf4j.Logger; + +public class AnrDetectorFactoryTest { + @Mock + private Context context; + + @Mock + private AnrListener anrListener; + + @Mock + private Logger logger; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void createShouldReturnNullWhenNoAnrConfigurationIsProvided() { + AnrDetector anrDetector = AnrDetectorFactory.create(context, logger, null, anrListener); + + assertNull(anrDetector); + thenWarningLogMustSay("No ANR configuration"); + } + + @Test + public void createShouldReturnNullWhenNoContextIsProvided() { + AnrDetector anrDetector = AnrDetectorFactory.create(null, logger, new AnrConfiguration.Builder().build(), anrListener); + + assertNull(anrDetector); + thenWarningLogMustSay("No context"); + } + + private void thenWarningLogMustSay(String logMessage) { + verify(logger, times(1)).warn(logMessage); + } +} diff --git a/rollbar-android/src/test/java/com/rollbar/android/anr/historical/HistoricalAnrDetectorTest.java b/rollbar-android/src/test/java/com/rollbar/android/anr/historical/HistoricalAnrDetectorTest.java new file mode 100644 index 00000000..f35c73fc --- /dev/null +++ b/rollbar-android/src/test/java/com/rollbar/android/anr/historical/HistoricalAnrDetectorTest.java @@ -0,0 +1,206 @@ +package com.rollbar.android.anr.historical; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.ActivityManager; +import android.app.ApplicationExitInfo; +import android.content.Context; + +import com.rollbar.android.anr.AnrListener; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.slf4j.Logger; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class HistoricalAnrDetectorTest { + + @Mock + private ApplicationExitInfo applicationExitInfo; + + @Mock + private Context context; + + @Mock + private AnrListener anrListener; + + @Mock + private Logger logger; + + @Mock + private ActivityManager activityManager; + + private HistoricalAnrDetector historicalAnrDetector; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + historicalAnrDetector = new HistoricalAnrDetector(context, anrListener, logger); + } + + @Test + public void shouldNotDetectAnrWhenAnrListenerIsNull() throws InterruptedException { + givenAnActivityManagerWithoutExitInfo(); + historicalAnrDetector = new HistoricalAnrDetector(context, null, logger); + + whenDetectorIsExecuted(); + + thenTheListenerIsNeverCalled(); + thenErrorLogMustSay("AnrListener is null"); + } + + @Test + public void shouldNotDetectAnrWhenApplicationExitInfoIsEmpty() throws InterruptedException { + givenAnActivityManagerWithoutExitInfo(); + + whenDetectorIsExecuted(); + + thenTheListenerIsNeverCalled(); + thenDebugLogMustSay("Empty ApplicationExitInfo List"); + } + + @Test + public void shouldNotDetectAnrWhenMainThreadIsNotParsed() throws InterruptedException, IOException { + givenAnActivityManagerWithAnAnr(anrWithoutMainThread()); + + whenDetectorIsExecuted(); + + thenTheListenerIsNeverCalled(); + thenErrorLogMustSay("Main thread not found, skipping ANR"); + } + + @Test + public void shouldDetectAnr() throws InterruptedException, IOException { + givenAnActivityManagerWithAnAnr(anr()); + + whenDetectorIsExecuted(); + + thenTheListenerIsCalled(); + } + + private void whenDetectorIsExecuted() throws InterruptedException { + historicalAnrDetector.init(); + waitForDetectorToRun(); + } + + private void givenAnActivityManagerWithAnAnr(ByteArrayInputStream anr) throws IOException { + setAnr(anr); + setActivityManagerService(); + + List list = new ArrayList<>(); + list.add(applicationExitInfo); + setExitReason(list); + } + + private void givenAnActivityManagerWithoutExitInfo() { + setActivityManagerService(); + setExitReason(new ArrayList<>()); + } + + private void setActivityManagerService() { + when(context.getSystemService(eq(Context.ACTIVITY_SERVICE))).thenReturn(activityManager); + } + + private void setAnr(ByteArrayInputStream anr) throws IOException { + when(applicationExitInfo.getReason()).thenReturn(ApplicationExitInfo.REASON_ANR); + when(applicationExitInfo.getTraceInputStream()).thenReturn(anr); + } + + private void setExitReason(List applicationExitInfos) { + when(activityManager.getHistoricalProcessExitReasons(eq(null), eq(0), eq(0))).thenReturn(applicationExitInfos); + } + + private ByteArrayInputStream anr() { + String string = "\"main\" prio=5 tid=1 Sleeping\n" + + "| group=\"main\" sCount=1 ucsCount=0 flags=1 obj=0x72273478 self=0xb4000077e811ff50\n" + + "| sysTid=20408 nice=-10 cgrp=top-app sched=0/0 handle=0x79e97864f8\n" + + "| state=S schedstat=( 856373236 2319887008 1428 ) utm=74 stm=10 core=0 HZ=100\n" + + "| stack=0x7fd2fc2000-0x7fd2fc4000 stackSize=8188KB\n" + + "| held mutexes=" + + "at java.lang.Thread.sleep(Native method)" + + "- sleeping on <0x0c0f663b> (a java.lang.Object)\n" + + "at java.lang.Thread.sleep(Thread.java:450)\n" + + "- locked <0x0c0f663b> (a java.lang.Object)\n" + + "at java.lang.Thread.sleep(Thread.java:355)\n" + + "at com.rollbar.example.android.MainActivity.clickAction(MainActivity.java:77)\n" + + "at com.rollbar.example.android.MainActivity.access$000(MainActivity.java:14)\n" + + "at com.rollbar.example.android.MainActivity$1$1.onClick(MainActivity.java:34)\n" + + "at android.support.design.widget.Snackbar$1.onClick(Snackbar.java:255)\n" + + "at android.view.View.performClick(View.java:7659)\n" + + "at android.view.View.performClickInternal(View.java:7636)\n" + + "at android.view.View.-$$Nest$mperformClickInternal(unavailable:0)\n" + + "at android.view.View$PerformClick.run(View.java:30156)\n" + + "at android.os.Handler.handleCallback(Handler.java:958)\n" + + "at android.os.Handler.dispatchMessage(Handler.java:99)\n" + + "at android.os.Looper.loopOnce(Looper.java:205)\n" + + "at android.os.Looper.loop(Looper.java:294)\n" + + "at android.app.ActivityThread.main(ActivityThread.java:8177)\n" + + "at java.lang.reflect.Method.invoke(Native method)\n" + + "at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:552)\n" + + "at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:971)\n" + + "\"OkHttp ConnectionPool\" daemon prio=5 tid=3 TimedWaiting\n" + + "| group=\"main\" sCount=1 ucsCount=0 flags=1 obj=0x12c4ddc0 self=0xb4000077e8175220\n" + + "| sysTid=20482 nice=0 cgrp=top-app sched=0/0 handle=0x76fd901cb0\n" + + "| state=S schedstat=( 598626 7237000 4 ) utm=0 stm=0 core=0 HZ=100\n" + + "| stack=0x76fd7fe000-0x76fd800000 stackSize=1039KB\n" + + "| held mutexes=\n" + + "at java.lang.Object.wait(Native method)\n" + + "- waiting on <0x06842b17> (a com.android.okhttp.ConnectionPool)\n" + + "at com.android.okhttp.ConnectionPool$1.run(ConnectionPool.java:106)\n" + + "- locked <0x06842b17> (a com.android.okhttp.ConnectionPool)\n" + + "at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)\n" + + "at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644)\n" + + "at java.lang.Thread.run(Thread.java:1012)\n"; + return new ByteArrayInputStream(string.getBytes()); + } + + private ByteArrayInputStream anrWithoutMainThread() { + String string = "\"OkHttp ConnectionPool\" daemon prio=5 tid=3 TimedWaiting\n" + + "| group=\"main\" sCount=1 ucsCount=0 flags=1 obj=0x12c4ddc0 self=0xb4000077e8175220\n" + + "| sysTid=20482 nice=0 cgrp=top-app sched=0/0 handle=0x76fd901cb0\n" + + "| state=S schedstat=( 598626 7237000 4 ) utm=0 stm=0 core=0 HZ=100\n" + + "| stack=0x76fd7fe000-0x76fd800000 stackSize=1039KB\n" + + "| held mutexes=\n" + + "at java.lang.Object.wait(Native method)\n" + + "- waiting on <0x06842b17> (a com.android.okhttp.ConnectionPool)\n" + + "at com.android.okhttp.ConnectionPool$1.run(ConnectionPool.java:106)\n" + + "- locked <0x06842b17> (a com.android.okhttp.ConnectionPool)\n" + + "at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)\n" + + "at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644)\n" + + "at java.lang.Thread.run(Thread.java:1012)\n"; + return new ByteArrayInputStream(string.getBytes()); + } + + private void waitForDetectorToRun() throws InterruptedException { + for(int i = 0; i<3 ; i++) { + Thread.sleep(50); + } + } + + private void thenTheListenerIsCalled() { + verify(anrListener).onAppNotResponding(any()); + } + + private void thenTheListenerIsNeverCalled() { + verify(anrListener, never()).onAppNotResponding(any()); + } + + private void thenDebugLogMustSay(String logMessage) { + verify(logger, times(1)).debug(logMessage); + } + + private void thenErrorLogMustSay(String logMessage) { + verify(logger, times(1)).error(logMessage); + } +} diff --git a/rollbar-android/src/test/java/com/rollbar/android/anr/watchdog/WatchDogTest.java b/rollbar-android/src/test/java/com/rollbar/android/anr/watchdog/WatchDogTest.java new file mode 100644 index 00000000..667b3d55 --- /dev/null +++ b/rollbar-android/src/test/java/com/rollbar/android/anr/watchdog/WatchDogTest.java @@ -0,0 +1,171 @@ +package com.rollbar.android.anr.watchdog; + +import static android.app.ActivityManager.ProcessErrorStateInfo.NOT_RESPONDING; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import android.app.ActivityManager; +import android.content.Context; + +import com.rollbar.android.anr.AnrException; +import com.rollbar.android.anr.AnrListener; +import com.rollbar.notifier.provider.Provider; +import com.rollbar.notifier.provider.timestamp.TimestampProvider; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.ArrayList; +import java.util.List; + +public class WatchDogTest { + private static final long ANR_TIMEOUT_MILLIS = 5000; + + private long currentTimeMs = 0L; + private AnrException anrException; + private final AnrListener anrListener = new AnrListenerFake(); + private final Thread.State blockedState = Thread.State.BLOCKED; + private final StackTraceElement stacktrace = new StackTraceElement("declaringClass", + "methodName", "fileName", 7); + + @Mock + private Thread thread; + + @Mock + private ActivityManager activityManager; + + @Mock + private Context context; + + @Mock + private LooperHandler looperHandler; + + private final Provider timeProvider = new TimestampProviderFake(); + + private WatchDog watchDog; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + currentTimeMs = 0; + watchDog = new WatchDog( + context, + anrListener, + looperHandler, + new WatchdogConfiguration.Builder().build(), + timeProvider + ); + } + + @After + public void tearDown() { + watchDog.interrupt(); + } + + @Test + public void shouldNotDetectAnrWhenTimeOutIsNotSurpassed() throws InterruptedException { + whenWatchdogStart(); + whenAnrTimeOutIsNotSurpassed(); + + thenAnrIsNotDetected(); + } + + @Test + public void shouldDetectAnrWhenMainThreadIsBlockedAndActivityManagerNotAvailable() throws InterruptedException { + givenABlockedThread(); + + whenWatchdogStart(); + whenAnrTimeOutIsSurpassed(); + + thenAnrExceptionIsTheExpected(); + } + + @Test + public void shouldDetectAnrWhenMainThreadIsBlockedAndActivityManagerHasAnr() throws InterruptedException { + givenAnActivityManagerWithAnr(); + givenABlockedThread(); + + whenWatchdogStart(); + whenAnrTimeOutIsSurpassed(); + + thenAnrExceptionIsTheExpected(); + } + + private void thenAnrIsNotDetected() { + assertNull(anrException); + } + + private void thenAnrExceptionIsTheExpected() { + assertNotNull(anrException); + assertEquals(anrException.getMessage(), "Application Not Responding for at least 5000 ms."); + assertEquals(stacktrace.getClassName(), anrException.getStackTrace()[0].getClassName()); + } + + private void whenWatchdogStart() { + watchDog.start(); + } + + private void whenAnrTimeOutIsNotSurpassed() throws InterruptedException { + int iterations = 0; + int maxIterations = 10; //just to prevent infinite execution + + while (iterations < maxIterations) { + iterations++; + currentTimeMs += 1; + defaultSleep(); + } + } + + private void whenAnrTimeOutIsSurpassed() throws InterruptedException { + int iterations = 0; + int maxIterations = 30; //just to prevent infinite execution + + while (anrException == null && iterations < maxIterations) { + iterations++; + currentTimeMs += ANR_TIMEOUT_MILLIS + 1; + defaultSleep(); + } + } + + private void givenAnActivityManagerWithAnr() { + ActivityManager.ProcessErrorStateInfo stateInfo = new ActivityManager.ProcessErrorStateInfo(); + stateInfo.condition = NOT_RESPONDING; + List anrs = new ArrayList<>(); + anrs.add(stateInfo); + + when(context.getSystemService(eq(Context.ACTIVITY_SERVICE))).thenReturn(activityManager); + when(activityManager.getProcessesInErrorState()).thenReturn(anrs); + } + + private void givenABlockedThread() { + StackTraceElement[] stackTraceElements = {stacktrace}; + + when(thread.getState()).thenReturn(blockedState); + when(thread.getStackTrace()).thenReturn(stackTraceElements); + when(looperHandler.getThread()).thenReturn(thread); + } + + private void defaultSleep() throws InterruptedException { + Thread.sleep(20); + } + + private class TimestampProviderFake extends TimestampProvider { + @Override + public Long provide() { + return currentTimeMs; + } + } + + private class AnrListenerFake implements AnrListener { + @Override + public void onAppNotResponding(AnrException error) { + anrException = error; + } + } +}