diff --git a/CHANGELOG.md b/CHANGELOG.md index 42790c8930..5efc07bca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog -## 4.X.X (TBD) +## 4.12.0 (2019-02-27) + +### Enhancements + +* Add stopSession() and resumeSession() to Client +[#429](https://github.com/bugsnag/bugsnag-android/pull/429) ### Bug fixes diff --git a/Gemfile.lock b/Gemfile.lock index cb9b0d9c4f..833070354e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,12 @@ GIT remote: https://github.com/bugsnag/maze-runner - revision: 24f53756011b1247a166b70fe65ddf1c40b4fc89 + revision: 6f5af2c98c6fa1cfb07d6466a5cf3eaca5dcbc90 specs: bugsnag-maze-runner (1.0.0) cucumber (~> 3.1.0) cucumber-expressions (= 5.0.15) minitest (~> 5.0) + os (~> 1.0.0) rack (~> 2.0.0) rake (~> 12.3.0) test-unit (~> 3.2.0) @@ -34,17 +35,18 @@ GEM cucumber-wire (0.0.1) diff-lcs (1.3) gherkin (5.1.0) - method_source (0.9.0) + method_source (0.9.2) minitest (5.11.3) multi_json (1.13.1) multi_test (0.1.2) + os (1.0.0) power_assert (1.1.3) - pry (0.11.3) + pry (0.12.2) coderay (~> 1.1.0) method_source (~> 0.9.0) - rack (2.0.5) - rake (12.3.1) - test-unit (3.2.8) + rack (2.0.6) + rake (12.3.2) + test-unit (3.2.9) power_assert PLATFORMS @@ -55,4 +57,4 @@ DEPENDENCIES pry BUNDLED WITH - 1.16.2 + 1.16.1 diff --git a/README.md b/README.md index 626e3b7b92..eac2d4ff32 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build status](https://travis-ci.org/bugsnag/bugsnag-android.svg?branch=master)](https://travis-ci.org/bugsnag/bugsnag-android) [![Coverage Status](https://coveralls.io/repos/github/bugsnag/bugsnag-android/badge.svg?branch=master)](https://coveralls.io/github/bugsnag/bugsnag-android?branch=master) -![Method count and size](https://img.shields.io/badge/Methods%20and%20size-86%20classes%20|%20688%20methods%20|%20351%20fields%20|%20128%20KB-e91e63.svg) +![Method count and size](https://img.shields.io/badge/Methods%20and%20size-86%20classes%20|%20698%20methods%20|%20353%20fields%20|%20132%20KB-e91e63.svg) Get comprehensive [Android crash reports](https://www.bugsnag.com/platforms/android/) to quickly debug errors. diff --git a/features/fixtures/mazerunner/src/main/cpp/bugsnags.cpp b/features/fixtures/mazerunner/src/main/cpp/bugsnags.cpp index 9e5e7e4d8e..47c7df9081 100644 --- a/features/fixtures/mazerunner/src/main/cpp/bugsnags.cpp +++ b/features/fixtures/mazerunner/src/main/cpp/bugsnags.cpp @@ -17,6 +17,26 @@ Java_com_bugsnag_android_mazerunner_scenarios_CXXAutoContextScenario_activate(JN (char *)"This is a new world", BSG_SEVERITY_INFO); } +JNIEXPORT int JNICALL +Java_com_bugsnag_android_mazerunner_scenarios_CXXStartSessionScenario_crash(JNIEnv *env, + jobject instance, + jint value) { + int x = 22; + if (x > 0) + __builtin_trap(); + return 338; +} + +JNIEXPORT int JNICALL +Java_com_bugsnag_android_mazerunner_scenarios_CXXStopSessionScenario_crash(JNIEnv *env, + jobject instance, + jint value) { + int x = 22552; + if (x > 0) + __builtin_trap(); + return 555; +} + JNIEXPORT int JNICALL Java_com_bugsnag_android_mazerunner_scenarios_CXXUpdateContextCrashScenario_crash(JNIEnv *env, jobject instance, diff --git a/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXStartSessionScenario.java b/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXStartSessionScenario.java new file mode 100644 index 0000000000..b53e885303 --- /dev/null +++ b/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXStartSessionScenario.java @@ -0,0 +1,43 @@ +package com.bugsnag.android.mazerunner.scenarios; + +import android.content.Context; +import android.os.Handler; + +import com.bugsnag.android.Bugsnag; +import com.bugsnag.android.Configuration; + +import android.support.annotation.NonNull; + +public class CXXStartSessionScenario extends Scenario { + static { + System.loadLibrary("bugsnag-ndk"); + System.loadLibrary("monochrome"); + System.loadLibrary("entrypoint"); + } + + private Handler handler = new Handler(); + + public native int crash(int counter); + + public CXXStartSessionScenario(@NonNull Configuration config, @NonNull Context context) { + super(config, context); + config.setAutoCaptureSessions(false); + } + + @Override + public void run() { + super.run(); + String metadata = getEventMetaData(); + + if (metadata == null || !metadata.equals("non-crashy")) { + Bugsnag.getClient().startSession(); + + handler.postDelayed(new Runnable() { + @Override + public void run() { + crash(0); + } + }, 8000); + } + } +} diff --git a/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXStopSessionScenario.java b/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXStopSessionScenario.java new file mode 100644 index 0000000000..f1089c74b3 --- /dev/null +++ b/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXStopSessionScenario.java @@ -0,0 +1,44 @@ +package com.bugsnag.android.mazerunner.scenarios; + +import android.content.Context; +import android.os.Handler; + +import com.bugsnag.android.Bugsnag; +import com.bugsnag.android.Configuration; + +import android.support.annotation.NonNull; + +public class CXXStopSessionScenario extends Scenario { + static { + System.loadLibrary("bugsnag-ndk"); + System.loadLibrary("monochrome"); + System.loadLibrary("entrypoint"); + } + + private Handler handler = new Handler(); + + public native int crash(int counter); + + public CXXStopSessionScenario(@NonNull Configuration config, @NonNull Context context) { + super(config, context); + config.setAutoCaptureSessions(false); + } + + @Override + public void run() { + super.run(); + String metadata = getEventMetaData(); + + if (metadata == null || !metadata.equals("non-crashy")) { + Bugsnag.getClient().startSession(); + Bugsnag.getClient().stopSession(); + + handler.postDelayed(new Runnable() { + @Override + public void run() { + crash(0); + } + }, 8000); + } + } +} diff --git a/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/NewSessionScenario.kt b/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/NewSessionScenario.kt new file mode 100644 index 0000000000..4f519bd3d2 --- /dev/null +++ b/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/NewSessionScenario.kt @@ -0,0 +1,37 @@ +package com.bugsnag.android.mazerunner.scenarios + +import android.content.Context +import com.bugsnag.android.Bugsnag +import com.bugsnag.android.Configuration +import android.os.Handler +import android.os.HandlerThread + +/** + * Sends an exception after stopping the session + */ +internal class NewSessionScenario(config: Configuration, + context: Context) : Scenario(config, context) { + init { + config.setAutoCaptureSessions(false) + } + + override fun run() { + super.run() + val client = Bugsnag.getClient() + val thread = HandlerThread("HandlerThread") + thread.start() + + Handler(thread.looper).post { + // send 1st exception which should include session info + client.startSession() + client.notifyBlocking(generateException()) + + // stop tracking the existing session + client.stopSession() + + // send 2nd exception which should contain new session info + client.startSession() + client.notifyBlocking(generateException()) + } + } +} diff --git a/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/ResumedSessionScenario.kt b/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/ResumedSessionScenario.kt new file mode 100644 index 0000000000..8ce6a97bc6 --- /dev/null +++ b/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/ResumedSessionScenario.kt @@ -0,0 +1,35 @@ +package com.bugsnag.android.mazerunner.scenarios + +import android.content.Context +import com.bugsnag.android.Bugsnag +import com.bugsnag.android.Configuration +import android.os.Handler +import android.os.HandlerThread + +/** + * Sends 2 exceptions, 1 before resuming a session, and 1 after resuming a session. + */ +internal class ResumedSessionScenario(config: Configuration, + context: Context) : Scenario(config, context) { + init { + config.setAutoCaptureSessions(false) + } + + override fun run() { + super.run() + val client = Bugsnag.getClient() + val thread = HandlerThread("HandlerThread") + thread.start() + + Handler(thread.looper).post { + // send 1st exception + client.startSession() + client.notifyBlocking(generateException()) + + // send 2nd exception after resuming a session + client.stopSession() + client.resumeSession() + client.notifyBlocking(generateException()) + } + } +} diff --git a/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/StoppedSessionScenario.kt b/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/StoppedSessionScenario.kt new file mode 100644 index 0000000000..c3450a0ba9 --- /dev/null +++ b/features/fixtures/mazerunner/src/main/java/com/bugsnag/android/mazerunner/scenarios/StoppedSessionScenario.kt @@ -0,0 +1,34 @@ +package com.bugsnag.android.mazerunner.scenarios + +import android.content.Context +import com.bugsnag.android.Bugsnag +import com.bugsnag.android.Configuration +import android.os.Handler +import android.os.HandlerThread + +/** + * Sends an exception after stopping the session + */ +internal class StoppedSessionScenario(config: Configuration, + context: Context) : Scenario(config, context) { + init { + config.setAutoCaptureSessions(false) + } + + override fun run() { + super.run() + val client = Bugsnag.getClient() + val thread = HandlerThread("HandlerThread") + thread.start() + + Handler(thread.looper).post { + // send 1st exception which should include session info + client.startSession() + client.notifyBlocking(generateException()) + + // send 2nd exception which should not include session info + client.stopSession() + client.notifyBlocking(generateException()) + } + } +} diff --git a/features/native_session_tracking.feature b/features/native_session_tracking.feature new file mode 100644 index 0000000000..a3b0872f3b --- /dev/null +++ b/features/native_session_tracking.feature @@ -0,0 +1,23 @@ +Feature: NDK Session Tracking + +Scenario: Stopped session is not in payload of unhandled NDK error + When I run "CXXStopSessionScenario" + And I wait a bit + And I wait a bit + And I configure the app to run in the "non-crashy" state + And I relaunch the app + Then I should receive 2 requests + And the request 0 is a valid for the session tracking API + And the request 1 is a valid for the error reporting API + And the payload field "events.0.session" is null for request 1 + +Scenario: Started session is in payload of unhandled NDK error + When I run "CXXStartSessionScenario" + And I wait a bit + And I wait a bit + And I configure the app to run in the "non-crashy" state + And I relaunch the app + Then I should receive 2 requests + And the request 0 is a valid for the session tracking API + And the request 1 is a valid for the error reporting API + And the payload field "events.0.session.events.unhandled" equals 1 for request 1 diff --git a/features/session_stopping.feature b/features/session_stopping.feature new file mode 100644 index 0000000000..35778329c7 --- /dev/null +++ b/features/session_stopping.feature @@ -0,0 +1,32 @@ +Feature: Stopping and resuming sessions + +Scenario: When a session is stopped the error has no session information + When I run "StoppedSessionScenario" + Then I should receive 3 requests + And the request 0 is valid for the session tracking API + And the request 1 is valid for the error reporting API + And the request 2 is valid for the error reporting API + And the payload field "events.0.session" is not null for request 1 + And the payload field "events.0.session" is null for request 2 + +Scenario: When a session is resumed the error uses the previous session information + When I run "ResumedSessionScenario" + Then I should receive 3 requests + And the request 0 is valid for the session tracking API + And the request 1 is valid for the error reporting API + And the request 2 is valid for the error reporting API + And the payload field "events.0.session.events.handled" equals 1 for request 1 + And the payload field "events.0.session.events.handled" equals 2 for request 2 + And the payload field "events.0.session.id" of request 1 equals the payload field "events.0.session.id" of request 2 + And the payload field "events.0.session.startedAt" of request 1 equals the payload field "events.0.session.startedAt" of request 2 + +Scenario: When a new session is started the error uses different session information + When I run "NewSessionScenario" + Then I should receive 4 requests + And the request 0 is valid for the session tracking API + And the request 1 is valid for the error reporting API + And the request 2 is valid for the session tracking API + And the request 3 is valid for the error reporting API + And the payload field "events.0.session.events.handled" equals 1 for request 1 + And the payload field "events.0.session.events.handled" equals 1 for request 3 + And the payload field "events.0.session.id" of request 1 does not equal the payload field "events.0.session.id" of request 3 diff --git a/gradle.properties b/gradle.properties index 876778a209..44c9b7d83b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ org.gradle.jvmargs=-Xmx1536m # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -VERSION_NAME=4.11.0 +VERSION_NAME=4.12.0 GROUP=com.bugsnag POM_SCM_URL=https://github.com/bugsnag/bugsnag-android POM_SCM_CONNECTION=scm:git@github.com:bugsnag/bugsnag-android.git diff --git a/ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.java b/ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.java index a073fea2d4..73d467c492 100644 --- a/ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.java +++ b/ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.java @@ -54,7 +54,9 @@ public static native void addBreadcrumb(String name, String type, String timesta public static native void removeMetadata(String tab, String key); - public static native void startedSession(String sessionID, String key); + public static native void startedSession(String sessionID, String key, int handledCount); + + public static native void stoppedSession(); public static native void updateAppVersion(String appVersion); @@ -131,6 +133,9 @@ public void update(Observable observable, Object rawMessage) { case START_SESSION: handleStartSession(arg); break; + case STOP_SESSION: + stoppedSession(); + break; case UPDATE_APP_VERSION: handleAppVersionChange(arg); break; @@ -313,11 +318,14 @@ private void handleStartSession(Object arg) { if (arg instanceof List) { @SuppressWarnings("unchecked") List metadata = (List)arg; - if (metadata.size() == 2) { + if (metadata.size() == 3) { Object id = metadata.get(0); Object startTime = metadata.get(1); - if (id instanceof String && startTime instanceof String) { - startedSession((String)id, (String)startTime); + Object handledCount = metadata.get(2); + + if (id instanceof String && startTime instanceof String + && handledCount instanceof Integer) { + startedSession((String)id, (String)startTime, (Integer) handledCount); return; } } @@ -326,6 +334,10 @@ private void handleStartSession(Object arg) { warn("START_SESSION object is invalid: " + arg); } + private void handleStopSession() { + stoppedSession(); + } + private void handleReleaseStageChange(Object arg) { if (arg instanceof String) { updateReleaseStage((String)arg); diff --git a/ndk/src/main/jni/bugsnag_ndk.c b/ndk/src/main/jni/bugsnag_ndk.c index bf7d28145e..ddd414aba4 100644 --- a/ndk/src/main/jni/bugsnag_ndk.c +++ b/ndk/src/main/jni/bugsnag_ndk.c @@ -121,24 +121,42 @@ Java_com_bugsnag_android_ndk_NativeBridge_addHandledEvent(JNIEnv *env, if (bsg_global_env == NULL) return; bsg_request_env_write_lock(); - bsg_global_env->next_report.handled_events++; + bugsnag_report *report = &bsg_global_env->next_report; + + if (bugsnag_report_has_session(report)) { + report->handled_events++; + } bsg_release_env_write_lock(); } JNIEXPORT void JNICALL Java_com_bugsnag_android_ndk_NativeBridge_startedSession( - JNIEnv *env, jobject _this, jstring session_id_, jstring start_date_) { + JNIEnv *env, jobject _this, jstring session_id_, jstring start_date_, jint handled_count) { if (bsg_global_env == NULL || session_id_ == NULL) return; char *session_id = (char *)(*env)->GetStringUTFChars(env, session_id_, 0); char *started_at = (char *)(*env)->GetStringUTFChars(env, start_date_, 0); bsg_request_env_write_lock(); bugsnag_report_start_session(&bsg_global_env->next_report, session_id, - started_at); + started_at, handled_count); + bsg_release_env_write_lock(); (*env)->ReleaseStringUTFChars(env, session_id_, session_id); (*env)->ReleaseStringUTFChars(env, start_date_, started_at); } +JNIEXPORT void JNICALL Java_com_bugsnag_android_ndk_NativeBridge_stoppedSession( + JNIEnv *env, jobject _this) { + if (bsg_global_env == NULL) { + return; + } + bsg_request_env_write_lock(); + bugsnag_report *report = &bsg_global_env->next_report; + memset(report->session_id, 0, strlen(report->session_id)); + memset(report->session_start, 0, strlen(report->session_start)); + report->handled_events = 0; + bsg_release_env_write_lock(); +} + JNIEXPORT void JNICALL Java_com_bugsnag_android_ndk_NativeBridge_clearBreadcrumbs(JNIEnv *env, jobject _this) { diff --git a/ndk/src/main/jni/report.c b/ndk/src/main/jni/report.c index 4962e25697..97e49e37e9 100644 --- a/ndk/src/main/jni/report.c +++ b/ndk/src/main/jni/report.c @@ -83,11 +83,11 @@ void bugsnag_report_remove_metadata_tab(bugsnag_report *report, char *section) { } void bugsnag_report_start_session(bugsnag_report *report, char *session_id, - char *started_at) { + char *started_at, int handled_count) { bsg_strncpy_safe(report->session_id, session_id, sizeof(report->session_id)); bsg_strncpy_safe(report->session_start, started_at, sizeof(report->session_start)); - report->handled_events = 0; + report->handled_events = handled_count; } void bugsnag_report_set_context(bugsnag_report *report, char *value) { @@ -158,3 +158,7 @@ void bugsnag_report_clear_breadcrumbs(bugsnag_report *report) { report->crumb_count = 0; report->crumb_first_index = 0; } + +bool bugsnag_report_has_session(bugsnag_report *report) { + return strlen(report->session_id) > 0; +} diff --git a/ndk/src/main/jni/report.h b/ndk/src/main/jni/report.h index 879e9f3b0a..1c126e7ef6 100644 --- a/ndk/src/main/jni/report.h +++ b/ndk/src/main/jni/report.h @@ -315,7 +315,9 @@ void bugsnag_report_set_user_email(bugsnag_report *report, char *value); void bugsnag_report_set_user_id(bugsnag_report *report, char *value); void bugsnag_report_set_user_name(bugsnag_report *report, char *value); void bugsnag_report_start_session(bugsnag_report *report, char *session_id, - char *started_at); + char *started_at, int handled_count); +bool bugsnag_report_has_session(bugsnag_report *report); + #ifdef __cplusplus } #endif diff --git a/ndk/src/main/jni/utils/serializer.c b/ndk/src/main/jni/utils/serializer.c index f2a7bb48e7..3361b3671c 100644 --- a/ndk/src/main/jni/utils/serializer.c +++ b/ndk/src/main/jni/utils/serializer.c @@ -255,13 +255,14 @@ char *bsg_serialize_report_to_json_string(bugsnag_report *report) { json_object_dotset_string(event, "user.email", report->user.email); if (strlen(report->user.id) > 0) json_object_dotset_string(event, "user.id", report->user.id); - if (strlen(report->session_id) > 0) { - json_object_dotset_string(event, "session.startedAt", - report->session_start); - json_object_dotset_string(event, "session.id", report->session_id); - json_object_dotset_number(event, "session.events.handled", - report->handled_events); - json_object_dotset_number(event, "session.events.unhandled", 1); + + if (bugsnag_report_has_session(report)) { + json_object_dotset_string(event, "session.startedAt", + report->session_start); + json_object_dotset_string(event, "session.id", report->session_id); + json_object_dotset_number(event, "session.events.handled", + report->handled_events); + json_object_dotset_number(event, "session.events.unhandled", 1); } json_object_set_string(exception, "errorClass", report->exception.name); diff --git a/sdk/src/androidTest/java/com/bugsnag/android/ObserverInterfaceTest.java b/sdk/src/androidTest/java/com/bugsnag/android/ObserverInterfaceTest.java index b1539f4423..ec4e52d33c 100644 --- a/sdk/src/androidTest/java/com/bugsnag/android/ObserverInterfaceTest.java +++ b/sdk/src/androidTest/java/com/bugsnag/android/ObserverInterfaceTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import android.support.test.InstrumentationRegistry; @@ -152,9 +153,18 @@ public void testStartSessionSendsMessage() throws InterruptedException { client.startSession(); List sessionInfo = (List)findMessageInQueue( NativeInterface.MessageType.START_SESSION, List.class); - assertEquals(2, sessionInfo.size()); + assertEquals(3, sessionInfo.size()); assertTrue(sessionInfo.get(0) instanceof String); assertTrue(sessionInfo.get(1) instanceof String); + assertTrue(sessionInfo.get(2) instanceof Integer); + } + + @Test + public void testStopSessionSendsmessage() { + client.startSession(); + client.stopSession(); + Object msg = findMessageInQueue(NativeInterface.MessageType.STOP_SESSION, null); + assertNull(msg); } @Test diff --git a/sdk/src/androidTest/java/com/bugsnag/android/SessionTrackerStopResumeTest.kt b/sdk/src/androidTest/java/com/bugsnag/android/SessionTrackerStopResumeTest.kt new file mode 100644 index 0000000000..190e0fe03f --- /dev/null +++ b/sdk/src/androidTest/java/com/bugsnag/android/SessionTrackerStopResumeTest.kt @@ -0,0 +1,123 @@ +package com.bugsnag.android + +import com.bugsnag.android.BugsnagTestUtils.generateClient +import com.bugsnag.android.BugsnagTestUtils.generateConfiguration +import com.bugsnag.android.BugsnagTestUtils.generateSessionStore +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class SessionTrackerStopResumeTest { + + private val configuration = generateConfiguration().also { + it.autoCaptureSessions = false + } + private val sessionStore = generateSessionStore() + private lateinit var tracker: SessionTracker + + @Before + fun setUp() { + tracker = SessionTracker(configuration, generateClient(), sessionStore) + } + + /** + * Verifies that a session can be resumed after it is stopped + */ + @Test + fun resumeFromStoppedSession() { + tracker.startSession(false) + val originalSession = tracker.currentSession + assertNotNull(originalSession) + + tracker.stopSession() + assertNull(tracker.currentSession) + + assertTrue(tracker.resumeSession()) + assertEquals(originalSession, tracker.currentSession) + } + + /** + * Verifies that a new session is started when calling [SessionTracker.resumeSession], + * if there is no stopped session + */ + @Test + fun resumeWithNoStoppedSession() { + assertNull(tracker.currentSession) + assertFalse(tracker.resumeSession()) + assertNotNull(tracker.currentSession) + } + + /** + * Verifies that a new session can be created after the previous one is stopped + */ + @Test + fun startNewAfterStoppedSession() { + tracker.startSession(false) + val originalSession = tracker.currentSession + + tracker.stopSession() + tracker.startSession(false) + assertNotEquals(originalSession, tracker.currentSession) + } + + /** + * Verifies that calling [SessionTracker.resumeSession] multiple times only starts one session + */ + @Test + fun multipleResumesHaveNoEffect() { + tracker.startSession(false) + val original = tracker.currentSession + tracker.stopSession() + + assertTrue(tracker.resumeSession()) + assertEquals(original, tracker.currentSession) + + assertFalse(tracker.resumeSession()) + assertEquals(original, tracker.currentSession) + } + + /** + * Verifies that calling [SessionTracker.stopSession] multiple times only stops one session + */ + @Test + fun multipleStopsHaveNoEffect() { + tracker.startSession(false) + assertNotNull(tracker.currentSession) + + tracker.stopSession() + assertNull(tracker.currentSession) + + tracker.stopSession() + assertNull(tracker.currentSession) + } + + /** + * Verifies that if a handled or unhandled error occurs when a session is stopped, the + * error count is not updated + */ + @Test + fun stoppedSessionDoesNotIncrement() { + tracker.startSession(false) + tracker.incrementHandledAndCopy() + tracker.incrementUnhandledAndCopy() + assertEquals(1, tracker.currentSession?.handledCount) + assertEquals(1, tracker.currentSession?.unhandledCount) + + tracker.stopSession() + tracker.incrementHandledAndCopy() + tracker.incrementUnhandledAndCopy() + tracker.resumeSession() + assertEquals(1, tracker.currentSession?.handledCount) + assertEquals(1, tracker.currentSession?.unhandledCount) + + tracker.incrementHandledAndCopy() + tracker.incrementUnhandledAndCopy() + assertEquals(2, tracker.currentSession?.handledCount) + assertEquals(2, tracker.currentSession?.unhandledCount) + } +} diff --git a/sdk/src/main/java/com/bugsnag/android/Bugsnag.java b/sdk/src/main/java/com/bugsnag/android/Bugsnag.java index 97aa905ee1..e6baa08a22 100644 --- a/sdk/src/main/java/com/bugsnag/android/Bugsnag.java +++ b/sdk/src/main/java/com/bugsnag/android/Bugsnag.java @@ -668,16 +668,70 @@ public static void setLoggingEnabled(boolean enabled) { } /** - * Manually starts tracking a new session. - * - * Automatic session tracking can be enabled via - * {@link Configuration#setAutoCaptureSessions(boolean)}, which will automatically create a new - * session everytime the app enters the foreground. + * Starts tracking a new session. You should disable automatic session tracking via + * {@link #setAutoCaptureSessions(boolean)} if you call this method. + *

+ * You should call this at the appropriate time in your application when you wish to start a + * session. Any subsequent errors which occur in your application will still be reported to + * Bugsnag but will not count towards your application's + * + * stability score. This will start a new session even if there is already an existing + * session; you should call {@link #resumeSession()} if you only want to start a session + * when one doesn't already exist. + * + * @see #resumeSession() + * @see #stopSession() + * @see Configuration#setAutoCaptureSessions(boolean) */ public static void startSession() { getClient().startSession(); } + /** + * Resumes a session which has previously been stopped, or starts a new session if none exists. + * If a session has already been resumed or started and has not been stopped, calling this + * method will have no effect. You should disable automatic session tracking via + * {@link #setAutoCaptureSessions(boolean)} if you call this method. + *

+ * It's important to note that sessions are stored in memory for the lifetime of the + * application process and are not persisted on disk. Therefore calling this method on app + * startup would start a new session, rather than continuing any previous session. + *

+ * You should call this at the appropriate time in your application when you wish to resume + * a previously started session. Any subsequent errors which occur in your application will + * still be reported to Bugsnag but will not count towards your application's + * + * stability score. + * + * @see #startSession() + * @see #stopSession() + * @see Configuration#setAutoCaptureSessions(boolean) + * + * @return true if a previous session was resumed, false if a new session was started. + */ + public static boolean resumeSession() { + return getClient().resumeSession(); + } + + /** + * Stops tracking a session. You should disable automatic session tracking via + * {@link #setAutoCaptureSessions(boolean)} if you call this method. + *

+ * You should call this at the appropriate time in your application when you wish to stop a + * session. Any subsequent errors which occur in your application will still be reported to + * Bugsnag but will not count towards your application's + * + * stability score. This can be advantageous if, for example, you do not wish the + * stability score to include crashes in a background service. + * + * @see #startSession() + * @see #resumeSession() + * @see Configuration#setAutoCaptureSessions(boolean) + */ + public static void stopSession() { + getClient().stopSession(); + } + /** * Get the current Bugsnag Client instance. */ diff --git a/sdk/src/main/java/com/bugsnag/android/Client.java b/sdk/src/main/java/com/bugsnag/android/Client.java index 5d0dc9fd2b..3cb95a79f1 100644 --- a/sdk/src/main/java/com/bugsnag/android/Client.java +++ b/sdk/src/main/java/com/bugsnag/android/Client.java @@ -284,14 +284,68 @@ public void update(@NonNull Observable observable, @NonNull Object arg) { } /** - * Manually starts tracking a new session. + * Starts tracking a new session. You should disable automatic session tracking via + * {@link #setAutoCaptureSessions(boolean)} if you call this method. + *

+ * You should call this at the appropriate time in your application when you wish to start a + * session. Any subsequent errors which occur in your application will still be reported to + * Bugsnag but will not count towards your application's + * + * stability score. This will start a new session even if there is already an existing + * session; you should call {@link #resumeSession()} if you only want to start a session + * when one doesn't already exist. * - * Automatic session tracking can be enabled via - * {@link Configuration#setAutoCaptureSessions(boolean)}, which will automatically create a new - * session everytime the app enters the foreground. + * @see #resumeSession() + * @see #stopSession() + * @see Configuration#setAutoCaptureSessions(boolean) */ public void startSession() { - sessionTracker.startNewSession(new Date(), user, false); + sessionTracker.startSession(false); + } + + /** + * Stops tracking a session. You should disable automatic session tracking via + * {@link #setAutoCaptureSessions(boolean)} if you call this method. + *

+ * You should call this at the appropriate time in your application when you wish to stop a + * session. Any subsequent errors which occur in your application will still be reported to + * Bugsnag but will not count towards your application's + * + * stability score. This can be advantageous if, for example, you do not wish the + * stability score to include crashes in a background service. + * + * @see #startSession() + * @see #resumeSession() + * @see Configuration#setAutoCaptureSessions(boolean) + */ + public final void stopSession() { + sessionTracker.stopSession(); + } + + /** + * Resumes a session which has previously been stopped, or starts a new session if none exists. + * If a session has already been resumed or started and has not been stopped, calling this + * method will have no effect. You should disable automatic session tracking via + * {@link #setAutoCaptureSessions(boolean)} if you call this method. + *

+ * It's important to note that sessions are stored in memory for the lifetime of the + * application process and are not persisted on disk. Therefore calling this method on app + * startup would start a new session, rather than continuing any previous session. + *

+ * You should call this at the appropriate time in your application when you wish to resume + * a previously started session. Any subsequent errors which occur in your application will + * still be reported to Bugsnag but will not count towards your application's + * + * stability score. + * + * @see #startSession() + * @see #stopSession() + * @see Configuration#setAutoCaptureSessions(boolean) + * + * @return true if a previous session was resumed, false if a new session was started. + */ + public final boolean resumeSession() { + return sessionTracker.resumeSession(); } /** diff --git a/sdk/src/main/java/com/bugsnag/android/NativeInterface.java b/sdk/src/main/java/com/bugsnag/android/NativeInterface.java index fd4eed4bf2..3b6026caf3 100644 --- a/sdk/src/main/java/com/bugsnag/android/NativeInterface.java +++ b/sdk/src/main/java/com/bugsnag/android/NativeInterface.java @@ -59,6 +59,12 @@ public enum MessageType { * containing [id, startDateIsoString] */ START_SESSION, + + /** + * A session was stopped. + */ + STOP_SESSION, + /** * Set a new app version. The Message object should be the new app * version diff --git a/sdk/src/main/java/com/bugsnag/android/Notifier.java b/sdk/src/main/java/com/bugsnag/android/Notifier.java index 2dddd6b0b5..a2a0d98fb0 100644 --- a/sdk/src/main/java/com/bugsnag/android/Notifier.java +++ b/sdk/src/main/java/com/bugsnag/android/Notifier.java @@ -10,7 +10,7 @@ public class Notifier implements JsonStream.Streamable { private static final String NOTIFIER_NAME = "Android Bugsnag Notifier"; - private static final String NOTIFIER_VERSION = "4.11.0"; + private static final String NOTIFIER_VERSION = "4.12.0"; private static final String NOTIFIER_URL = "https://bugsnag.com"; @NonNull diff --git a/sdk/src/main/java/com/bugsnag/android/Session.java b/sdk/src/main/java/com/bugsnag/android/Session.java index 7f66a2ae50..882500cf6c 100644 --- a/sdk/src/main/java/com/bugsnag/android/Session.java +++ b/sdk/src/main/java/com/bugsnag/android/Session.java @@ -42,6 +42,7 @@ public Session(String id, Date startedAt, User user, boolean autoCaptured) { private AtomicInteger unhandledCount = new AtomicInteger(); private AtomicInteger handledCount = new AtomicInteger(); private AtomicBoolean tracked = new AtomicBoolean(false); + final AtomicBoolean isStopped = new AtomicBoolean(false); String getId() { return id; diff --git a/sdk/src/main/java/com/bugsnag/android/SessionTracker.java b/sdk/src/main/java/com/bugsnag/android/SessionTracker.java index dba11434df..ac53d52232 100644 --- a/sdk/src/main/java/com/bugsnag/android/SessionTracker.java +++ b/sdk/src/main/java/com/bugsnag/android/SessionTracker.java @@ -7,6 +7,7 @@ import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; import java.io.File; import java.util.Arrays; @@ -65,7 +66,9 @@ class SessionTracker extends Observable implements Application.ActivityLifecycle * @param date the session start date * @param user the session user (if any) */ - @Nullable Session startNewSession(@NonNull Date date, @Nullable User user, + @Nullable + @VisibleForTesting + Session startNewSession(@NonNull Date date, @Nullable User user, boolean autoCaptured) { if (configuration.getSessionEndpoint() == null) { Logger.warn("The session tracking endpoint has not been set. " @@ -78,6 +81,44 @@ class SessionTracker extends Observable implements Application.ActivityLifecycle return session; } + Session startSession(boolean autoCaptured) { + return startNewSession(new Date(), client.getUser(), autoCaptured); + } + + void stopSession() { + Session session = currentSession.get(); + + if (session != null) { + session.isStopped.set(true); + setChanged(); + notifyObservers(new NativeInterface.Message( + NativeInterface.MessageType.STOP_SESSION, null)); + } + } + + boolean resumeSession() { + Session session = currentSession.get(); + boolean resumed; + + if (session == null) { + session = startSession(false); + resumed = false; + } else { + resumed = session.isStopped.compareAndSet(true, false); + } + + notifySessionStartObserver(session); + return resumed; + } + + private void notifySessionStartObserver(Session session) { + setChanged(); + String startedAt = DateUtils.toIso8601(session.getStartedAt()); + notifyObservers(new NativeInterface.Message( + NativeInterface.MessageType.START_SESSION, + Arrays.asList(session.getId(), startedAt, session.getHandledCount()))); + } + /** * Determines whether or not a session should be tracked. If this is true, the session will be * stored and sent to the Bugsnag API, otherwise no action will occur in this method. @@ -90,6 +131,8 @@ private void trackSessionIfNeeded(final Session session) { if (notifyForRelease && (configuration.getAutoCaptureSessions() || !session.isAutoCaptured()) && session.isTracked().compareAndSet(false, true)) { + notifySessionStartObserver(session); + try { final String endpoint = configuration.getSessionEndpoint(); Async.run(new Runnable() { @@ -116,11 +159,6 @@ public void run() { // This is on the current thread but there isn't much else we can do sessionStore.write(session); } - setChanged(); - String startedAt = DateUtils.toIso8601(session.getStartedAt()); - notifyObservers(new NativeInterface.Message( - NativeInterface.MessageType.START_SESSION, - Arrays.asList(session.getId(), startedAt))); } } @@ -141,7 +179,12 @@ private String getReleaseStage() { @Nullable Session getCurrentSession() { - return currentSession.get(); + Session session = currentSession.get(); + + if (session != null && !session.isStopped.get()) { + return session; + } + return null; } /** @@ -151,7 +194,7 @@ Session getCurrentSession() { * @return a copy of the current session, or null if no session has been started. */ Session incrementUnhandledAndCopy() { - Session session = currentSession.get(); + Session session = getCurrentSession(); if (session != null) { return session.incrementUnhandledAndCopy(); } @@ -165,7 +208,7 @@ Session incrementUnhandledAndCopy() { * @return a copy of the current session, or null if no session has been started. */ Session incrementHandledAndCopy() { - Session session = currentSession.get(); + Session session = getCurrentSession(); if (session != null) { return session.incrementHandledAndCopy(); }