diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc8b48760..fe4891c80d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## TBD + +### Enhancements + +* Added `Session.apiKey` so that it can be changed in an `OnSessionCallback` + [#1855](https://github.com/bugsnag/bugsnag-android/pull/1855) + +### Bug fixes + +* Prevent rare app crash while migrating old `SharedPreferences` data from older versions of `bugsnag-android` + [#1860](https://github.com/bugsnag/bugsnag-android/pull/1860) + +* Prevent free memory calculation from potentially crashing the app when `ActivityManager` cannot be reached. + [#1861](https://github.com/bugsnag/bugsnag-android/pull/1861) + ## 5.30.0 (2023-05-11) ### Enhancements diff --git a/Gemfile.lock b/Gemfile.lock index 408eb3158c..cd6739c256 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -88,6 +88,8 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2023.0218.1) multi_test (0.1.2) + nokogiri (1.15.1-arm64-darwin) + racc (~> 1.4) nokogiri (1.15.1-x86_64-darwin) racc (~> 1.4) optimist (3.0.1) @@ -121,6 +123,7 @@ GEM rexml PLATFORMS + arm64-darwin-22 x86_64-darwin-20 DEPENDENCIES diff --git a/bugsnag-android-core/api/bugsnag-android-core.api b/bugsnag-android-core/api/bugsnag-android-core.api index 2fb22c0674..0dd39b2bee 100644 --- a/bugsnag-android-core/api/bugsnag-android-core.api +++ b/bugsnag-android-core/api/bugsnag-android-core.api @@ -573,11 +573,13 @@ public abstract interface class com/bugsnag/android/Plugin { } public final class com/bugsnag/android/Session : com/bugsnag/android/JsonStream$Streamable, com/bugsnag/android/UserAware { + public fun getApiKey ()Ljava/lang/String; public fun getApp ()Lcom/bugsnag/android/App; public fun getDevice ()Lcom/bugsnag/android/Device; public fun getId ()Ljava/lang/String; public fun getStartedAt ()Ljava/util/Date; public fun getUser ()Lcom/bugsnag/android/User; + public fun setApiKey (Ljava/lang/String;)V public fun setId (Ljava/lang/String;)V public fun setStartedAt (Ljava/util/Date;)V public fun setUser (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V diff --git a/bugsnag-android-core/detekt-baseline.xml b/bugsnag-android-core/detekt-baseline.xml index 53c0a3c858..fb7824ddb0 100644 --- a/bugsnag-android-core/detekt-baseline.xml +++ b/bugsnag-android-core/detekt-baseline.xml @@ -45,12 +45,15 @@ SwallowedException:BugsnagEventMapper.kt$BugsnagEventMapper$catch (pe: IllegalArgumentException) { ndkDateFormatHolder.get()!!.parse(this) ?: throw IllegalArgumentException("cannot parse date $this") } SwallowedException:ConnectivityCompat.kt$ConnectivityLegacy$catch (e: NullPointerException) { // in some rare cases we get a remote NullPointerException via Parcel.readException null } SwallowedException:ContextExtensions.kt$catch (exc: RuntimeException) { null } + SwallowedException:DeviceDataCollector.kt$DeviceDataCollector$catch (e: Throwable) { null } + SwallowedException:DeviceDataCollector.kt$DeviceDataCollector$catch (e: Throwable) { return null } SwallowedException:DeviceDataCollector.kt$DeviceDataCollector$catch (exc: Exception) { false } SwallowedException:DeviceDataCollector.kt$DeviceDataCollector$catch (exception: Exception) { logger.w("Could not get battery status") } SwallowedException:DeviceDataCollector.kt$DeviceDataCollector$catch (exception: Exception) { logger.w("Could not get locationStatus") } SwallowedException:DeviceIdFilePersistence.kt$DeviceIdFilePersistence$catch (exc: OverlappingFileLockException) { Thread.sleep(FILE_LOCK_WAIT_MS) } SwallowedException:JsonHelperTest.kt$JsonHelperTest$catch (e: IllegalArgumentException) { didThrow = true } SwallowedException:PluginClient.kt$PluginClient$catch (exc: ClassNotFoundException) { if (isWarningEnabled) { logger.d("Plugin '$clz' is not on the classpath - functionality will not be enabled.") } null } + SwallowedException:SharedPrefMigrator.kt$SharedPrefMigrator$catch (e: RuntimeException) { null } ThrowsCount:JsonHelper.kt$JsonHelper$ fun jsonToLong(value: Any?): Long? TooManyFunctions:ConfigInternal.kt$ConfigInternal : CallbackAwareMetadataAwareUserAwareFeatureFlagAware TooManyFunctions:DeviceDataCollector.kt$DeviceDataCollector diff --git a/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/BugsnagTestUtils.java b/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/BugsnagTestUtils.java index 534218f2fb..077bdce16e 100644 --- a/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/BugsnagTestUtils.java +++ b/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/BugsnagTestUtils.java @@ -58,7 +58,7 @@ static Client generateClient() { static Session generateSession() { return new Session("test", new Date(), new User(), false, - new Notifier(), NoopLogger.INSTANCE); + new Notifier(), NoopLogger.INSTANCE, "TEST APIKEY"); } static Event generateEvent() { diff --git a/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/SessionV1PayloadTest.java b/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/SessionV1PayloadTest.java index e5e5b77861..3ad76fd6d1 100644 --- a/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/SessionV1PayloadTest.java +++ b/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/SessionV1PayloadTest.java @@ -47,7 +47,7 @@ void writeLegacyFile(Session session) throws IOException { */ @Test public void testSessionFromFile() throws Exception { - Session payload = new Session(file, new Notifier(), NoopLogger.INSTANCE); + Session payload = new Session(file, new Notifier(), NoopLogger.INSTANCE, "TEST APIKEY"); payload.setApp(generateApp()); payload.setDevice(generateDevice()); diff --git a/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/SessionV2PayloadTest.java b/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/SessionV2PayloadTest.java index ac49f1ffd9..679296b927 100644 --- a/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/SessionV2PayloadTest.java +++ b/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/SessionV2PayloadTest.java @@ -52,7 +52,7 @@ public void testSessionFromFile() throws Exception { session.toStream(stream); out.flush(); - Session payload = new Session(file, new Notifier(), NoopLogger.INSTANCE); + Session payload = new Session(file, new Notifier(), NoopLogger.INSTANCE, "TEST APIKEY"); JSONObject obj = BugsnagTestUtils.streamableToJson(payload); JSONObject rootNode = obj.getJSONArray("sessions").getJSONObject(0); assertNotNull(rootNode); @@ -71,7 +71,15 @@ public void testSessionFromFile() throws Exception { @Test public void testAutoCapturedOverride() throws Exception { - session = new Session("id", new Date(), null, false, new Notifier(), NoopLogger.INSTANCE); + session = new Session( + "id", + new Date(), + null, + false, + new Notifier(), + NoopLogger.INSTANCE, + "TEST APIKEY" + ); assertFalse(session.isAutoCaptured()); session.setAutoCaptured(true); assertTrue(session.isAutoCaptured()); diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/BugsnagEventMapper.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/BugsnagEventMapper.kt index 1cfdfd90f0..61f74229c5 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/BugsnagEventMapper.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/BugsnagEventMapper.kt @@ -66,7 +66,7 @@ internal class BugsnagEventMapper( // populate session val sessionMap = map["session"] as? Map sessionMap?.let { - event.session = Session(it, logger) + event.session = Session(it, logger, apiKey) } // populate threads diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/DeviceDataCollector.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/DeviceDataCollector.kt index a7755a2edc..493763a493 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/DeviceDataCollector.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/DeviceDataCollector.kt @@ -242,21 +242,26 @@ internal class DeviceDataCollector( /** * Get the amount of memory remaining on the device */ - private fun calculateFreeMemory(): Long? { + fun calculateFreeMemory(): Long? { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - val freeMemory = appContext.getActivityManager() - ?.let { am -> ActivityManager.MemoryInfo().also { am.getMemoryInfo(it) } } - ?.availMem - - if (freeMemory != null) { - return freeMemory + try { + val freeMemory = appContext.getActivityManager() + ?.let { am -> ActivityManager.MemoryInfo().also { am.getMemoryInfo(it) } } + ?.availMem + if (freeMemory != null) { + return freeMemory + } + } catch (e: Throwable) { + return null } } - return runCatching { + return try { @Suppress("PrivateApi") AndroidProcess::class.java.getDeclaredMethod("getFreeMemory").invoke(null) as Long? - }.getOrNull() + } catch (e: Throwable) { + null + } } /** diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Session.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Session.java index 23dc782e2c..8e0d46e5a5 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Session.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Session.java @@ -34,17 +34,19 @@ public final class Session implements JsonStream.Streamable, UserAware { private final AtomicBoolean tracked = new AtomicBoolean(false); final AtomicBoolean isPaused = new AtomicBoolean(false); + private String apiKey; + static Session copySession(Session session) { Session copy = new Session(session.id, session.startedAt, session.user, session.unhandledCount.get(), session.handledCount.get(), session.notifier, - session.logger); + session.logger, session.getApiKey()); copy.tracked.set(session.tracked.get()); copy.autoCaptured.set(session.isAutoCaptured()); return copy; } - Session(Map map, Logger logger) { - this(null, null, logger); + Session(Map map, Logger logger, String apiKey) { + this(null, null, logger, apiKey); setId((String) map.get("id")); String timestamp = (String) map.get("startedAt"); @@ -61,25 +63,28 @@ static Session copySession(Session session) { } Session(String id, Date startedAt, User user, boolean autoCaptured, - Notifier notifier, Logger logger) { - this(null, notifier, logger); + Notifier notifier, Logger logger, String apiKey) { + this(null, notifier, logger, apiKey); this.id = id; this.startedAt = new Date(startedAt.getTime()); this.user = user; this.autoCaptured.set(autoCaptured); + this.apiKey = apiKey; } Session(String id, Date startedAt, User user, int unhandledCount, int handledCount, - Notifier notifier, Logger logger) { - this(id, startedAt, user, false, notifier, logger); + Notifier notifier, Logger logger, String apiKey) { + this(id, startedAt, user, false, notifier, logger, apiKey); this.unhandledCount.set(unhandledCount); this.handledCount.set(handledCount); this.tracked.set(true); + this.apiKey = apiKey; } - Session(File file, Notifier notifier, Logger logger) { + Session(File file, Notifier notifier, Logger logger, String apiKey) { this.file = file; this.logger = logger; + this.apiKey = SessionFilenameInfo.findApiKeyInFilename(file, apiKey); if (notifier != null) { Notifier copy = new Notifier(notifier.getName(), notifier.getVersion(), notifier.getUrl()); @@ -261,4 +266,25 @@ void serializeSessionInfo(@NonNull JsonStream writer) throws IOException { writer.name("user").value(user); writer.endObject(); } + + /** + * The API key used for session sent to Bugsnag. Even though the API key is set when Bugsnag + * is initialized, you may choose to send certain sessions to a different Bugsnag project. + */ + public void setApiKey(@NonNull String apiKey) { + if (apiKey != null) { + this.apiKey = apiKey; + } else { + logNull("apiKey"); + } + } + + /** + * The API key used for session sent to Bugsnag. Even though the API key is set when Bugsnag + * is initialized, you may choose to send certain sessions to a different Bugsnag project. + */ + @NonNull + public String getApiKey() { + return apiKey; + } } diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/SessionFilenameInfo.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/SessionFilenameInfo.kt index 04efa4081b..554b1ee0d7 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/SessionFilenameInfo.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/SessionFilenameInfo.kt @@ -1,5 +1,6 @@ package com.bugsnag.android +import com.bugsnag.android.internal.ImmutableConfig import java.io.File import java.util.UUID @@ -11,12 +12,13 @@ import java.util.UUID * timestamp - to sort error reports by time of capture */ internal data class SessionFilenameInfo( + var apiKey: String, val timestamp: Long, - val uuid: String, + val uuid: String ) { fun encode(): String { - return toFilename(timestamp, uuid) + return toFilename(apiKey, timestamp, uuid) } internal companion object { @@ -27,29 +29,63 @@ internal data class SessionFilenameInfo( * Generates a filename for the session in the format * "[UUID][timestamp]_v2.json" */ - fun toFilename(timestamp: Long, uuid: String): String { - return "${uuid}${timestamp}_v2.json" + fun toFilename(apiKey: String, timestamp: Long, uuid: String): String { + return "${apiKey}_${uuid}${timestamp}_v3.json" } @JvmStatic - fun defaultFilename(): String { - return toFilename(System.currentTimeMillis(), UUID.randomUUID().toString()) + fun defaultFilename( + obj: Any, + config: ImmutableConfig + ): SessionFilenameInfo { + val sanitizedApiKey = when (obj) { + is Session -> obj.apiKey + else -> config.apiKey + } + + return SessionFilenameInfo( + sanitizedApiKey, + System.currentTimeMillis(), + UUID.randomUUID().toString() + ) } - fun fromFile(file: File): SessionFilenameInfo { + fun fromFile(file: File, defaultApiKey: String): SessionFilenameInfo { return SessionFilenameInfo( + findApiKeyInFilename(file, defaultApiKey), findTimestampInFilename(file), findUuidInFilename(file) ) } - private fun findUuidInFilename(file: File): String { - return file.name.substring(0, uuidLength - 1) + @JvmStatic + fun findUuidInFilename(file: File): String { + var fileName = file.name + if (isFileV3(file)) { + fileName = file.name.substringAfter('_') + } + return fileName.takeIf { it.length >= uuidLength }?.take(uuidLength) ?: "" } @JvmStatic fun findTimestampInFilename(file: File): Long { - return file.name.substring(uuidLength, file.name.indexOf("_")).toLongOrNull() ?: -1 + var fileName = file.name + if (isFileV3(file)) { + fileName = file.name.substringAfter('_') + } + return fileName.drop(findUuidInFilename(file).length) + .substringBefore('_') + .toLongOrNull() ?: -1 } + + @JvmStatic + fun findApiKeyInFilename(file: File?, defaultApiKey: String): String { + if (file == null || !isFileV3(file)) { + return defaultApiKey + } + return file.name.substringBefore('_').takeUnless { it.isEmpty() } ?: defaultApiKey + } + + internal fun isFileV3(file: File): Boolean = file.name.endsWith("_v3.json") } } diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/SessionStore.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/SessionStore.java index a0238a5feb..87e3524e28 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/SessionStore.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/SessionStore.java @@ -17,6 +17,7 @@ */ class SessionStore extends FileStore { + private final ImmutableConfig config; static final Comparator SESSION_COMPARATOR = new Comparator() { @Override public int compare(File lhs, File rhs) { @@ -43,12 +44,15 @@ public int compare(File lhs, File rhs) { SESSION_COMPARATOR, logger, delegate); + this.config = config; } @NonNull @Override String getFilename(Object object) { - return SessionFilenameInfo.defaultFilename(); + SessionFilenameInfo sessionInfo + = SessionFilenameInfo.defaultFilename(object, config); + return sessionInfo.encode(); } public boolean isTooOld(File file) { diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/SessionTracker.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/SessionTracker.java index f620b32416..a2c1d90408 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/SessionTracker.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/SessionTracker.java @@ -89,7 +89,10 @@ Session startNewSession(@NonNull Date date, @Nullable User user, return null; } String id = UUID.randomUUID().toString(); - Session session = new Session(id, date, user, autoCaptured, client.getNotifier(), logger); + Session session = new Session( + id, date, user, autoCaptured, + client.getNotifier(), logger, configuration.getApiKey() + ); if (trackSessionIfNeeded(session)) { return session; } else { @@ -157,7 +160,7 @@ Session registerExistingSession(@Nullable Date date, @Nullable String sessionId, Session session = null; if (date != null && sessionId != null) { session = new Session(sessionId, date, user, unhandledCount, handledCount, - client.getNotifier(), logger); + client.getNotifier(), logger, configuration.getApiKey()); notifySessionStartObserver(session); } else { updateState(StateEvent.PauseSession.INSTANCE); @@ -256,7 +259,9 @@ void flushStoredSessions() { void flushStoredSession(File storedFile) { logger.d("SessionTracker#flushStoredSession() - attempting delivery"); - Session payload = new Session(storedFile, client.getNotifier(), logger); + Session payload = new Session( + storedFile, client.getNotifier(), logger, configuration.getApiKey() + ); if (!payload.isV2Payload()) { // collect data here payload.setApp(client.getAppDataCollector().generateApp()); @@ -330,7 +335,7 @@ void deliverInMemorySession(Session session) { } DeliveryStatus deliverSessionPayload(Session payload) { - DeliveryParams params = configuration.getSessionApiDeliveryParams(); + DeliveryParams params = configuration.getSessionApiDeliveryParams(payload); Delivery delivery = configuration.getDelivery(); return delivery.deliver(payload, params); } diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/SharedPrefMigrator.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/SharedPrefMigrator.kt index d664576791..2f7ccc238f 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/SharedPrefMigrator.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/SharedPrefMigrator.kt @@ -2,32 +2,38 @@ package com.bugsnag.android import android.annotation.SuppressLint import android.content.Context +import android.content.SharedPreferences /** * Reads legacy information left in SharedPreferences and migrates it to the new location. */ internal class SharedPrefMigrator(context: Context) : DeviceIdPersistence { - private val prefs = context - .getSharedPreferences("com.bugsnag.android", Context.MODE_PRIVATE) + private val prefs: SharedPreferences? = + try { + context.getSharedPreferences("com.bugsnag.android", Context.MODE_PRIVATE) + } catch (e: RuntimeException) { + null + } /** * This implementation will never create an ID; it will only fetch one if present. */ - override fun loadDeviceId(requestCreateIfDoesNotExist: Boolean) = prefs.getString(INSTALL_ID_KEY, null) + override fun loadDeviceId(requestCreateIfDoesNotExist: Boolean) = + prefs?.getString(INSTALL_ID_KEY, null) fun loadUser(deviceId: String?) = User( - prefs.getString(USER_ID_KEY, deviceId), - prefs.getString(USER_EMAIL_KEY, null), - prefs.getString(USER_NAME_KEY, null) + prefs?.getString(USER_ID_KEY, deviceId), + prefs?.getString(USER_EMAIL_KEY, null), + prefs?.getString(USER_NAME_KEY, null) ) - fun hasPrefs() = prefs.contains(INSTALL_ID_KEY) + fun hasPrefs() = prefs?.contains(INSTALL_ID_KEY) == true @SuppressLint("ApplySharedPref") fun deleteLegacyPrefs() { if (hasPrefs()) { - prefs.edit().clear().commit() + prefs?.edit()?.clear()?.commit() } } diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt index 07f2dcb835..8ff97eab96 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt @@ -18,6 +18,7 @@ import com.bugsnag.android.EventPayload import com.bugsnag.android.Logger import com.bugsnag.android.ManifestConfigLoader.Companion.BUILD_UUID import com.bugsnag.android.NoopLogger +import com.bugsnag.android.Session import com.bugsnag.android.Telemetry import com.bugsnag.android.ThreadSendPolicy import com.bugsnag.android.errorApiHeaders @@ -65,8 +66,8 @@ data class ImmutableConfig( DeliveryParams(endpoints.notify, errorApiHeaders(payload)) @JvmName("getSessionApiDeliveryParams") - internal fun getSessionApiDeliveryParams() = - DeliveryParams(endpoints.sessions, sessionApiHeaders(apiKey)) + internal fun getSessionApiDeliveryParams(session: Session) = + DeliveryParams(endpoints.sessions, sessionApiHeaders(session.apiKey)) /** * Returns whether the given throwable should be discarded diff --git a/bugsnag-android-core/src/sharedTest/java/com/bugsnag/android/BugsnagTestUtils.java b/bugsnag-android-core/src/sharedTest/java/com/bugsnag/android/BugsnagTestUtils.java index d08c6ed206..24a82a3422 100644 --- a/bugsnag-android-core/src/sharedTest/java/com/bugsnag/android/BugsnagTestUtils.java +++ b/bugsnag-android-core/src/sharedTest/java/com/bugsnag/android/BugsnagTestUtils.java @@ -62,7 +62,7 @@ static EventPayload generateEventPayload(ImmutableConfig config) { static Session generateSession() { return new Session("test", new Date(), new User(), false, - new Notifier(), NoopLogger.INSTANCE); + new Notifier(), NoopLogger.INSTANCE, "BUGSNAG_API_KEY"); } static Event generateEvent() { diff --git a/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/DataCollectorTest.kt b/bugsnag-android-core/src/test/java/com/bugsnag/android/DataCollectorTest.kt similarity index 64% rename from bugsnag-android-core/src/androidTest/java/com/bugsnag/android/DataCollectorTest.kt rename to bugsnag-android-core/src/test/java/com/bugsnag/android/DataCollectorTest.kt index 443bf353c6..307151bc20 100644 --- a/bugsnag-android-core/src/androidTest/java/com/bugsnag/android/DataCollectorTest.kt +++ b/bugsnag-android-core/src/test/java/com/bugsnag/android/DataCollectorTest.kt @@ -4,24 +4,34 @@ import android.content.Context import android.content.res.Configuration import android.content.res.Resources import com.bugsnag.android.internal.BackgroundTaskService -import org.junit.Ignore +import org.junit.Assert +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mock import org.mockito.Mockito +import org.mockito.Mockito.`when` import org.mockito.junit.MockitoJUnitRunner import java.io.File import kotlin.concurrent.thread -@RunWith(MockitoJUnitRunner::class) +@RunWith(MockitoJUnitRunner.Silent::class) class DataCollectorTest { - @Ignore("Disabled until we're able to mock final classes or auto-open classes") - @Test - fun testConcurretAccess() { + private lateinit var collector: DeviceDataCollector + + @Mock + lateinit var context: Context + + @Mock + lateinit var logger: Logger + + @Before + fun setUp() { val res = Mockito.mock(Resources::class.java) - Mockito.`when`(res.configuration).thenReturn(Configuration()) + `when`(res.configuration).thenReturn(Configuration()) - val collector = DeviceDataCollector( + collector = DeviceDataCollector( Mockito.mock(Connectivity::class.java), Mockito.mock(Context::class.java), res, @@ -33,7 +43,17 @@ class DataCollectorTest { Mockito.mock(BackgroundTaskService::class.java), Mockito.mock(Logger::class.java) ) + } + @Test + fun testCalculateFreeMemoryWithException() { + `when`(collector.calculateFreeMemory()).thenThrow(RuntimeException()) + collector.generateDeviceWithState(0) + Assert.assertNull(collector.calculateFreeMemory()) + } + + @Test + fun testConcurrentAccess() { repeat(10) { index -> collector.addRuntimeVersionInfo("key" + index, "value" + index) } diff --git a/bugsnag-android-core/src/test/java/com/bugsnag/android/DeliveryDelegateTest.kt b/bugsnag-android-core/src/test/java/com/bugsnag/android/DeliveryDelegateTest.kt index 9f7291a4d1..42538a416e 100644 --- a/bugsnag-android-core/src/test/java/com/bugsnag/android/DeliveryDelegateTest.kt +++ b/bugsnag-android-core/src/test/java/com/bugsnag/android/DeliveryDelegateTest.kt @@ -19,6 +19,7 @@ internal class DeliveryDelegateTest { @Mock lateinit var eventStore: EventStore + private val apiKey = "BUGSNAG_API_KEY" private val notifier = Notifier() val config = generateImmutableConfig() val callbackState = CallbackState() @@ -40,7 +41,7 @@ internal class DeliveryDelegateTest { notifier, BackgroundTaskService() ) - event.session = Session("123", Date(), User(null, null, null), false, notifier, NoopLogger) + event.session = Session("123", Date(), User(null, null, null), false, notifier, NoopLogger, apiKey) } @Test @@ -59,6 +60,8 @@ internal class DeliveryDelegateTest { // check session count incremented assertEquals(1, event.session!!.unhandledCount) assertEquals(0, event.session!!.handledCount) + + assertEquals("BUGSNAG_API_KEY", event.session!!.apiKey) } @Test @@ -67,7 +70,7 @@ internal class DeliveryDelegateTest { SeverityReason.REASON_HANDLED_EXCEPTION ) val event = Event(RuntimeException("Whoops!"), config, state, NoopLogger) - event.session = Session("123", Date(), User(null, null, null), false, notifier, NoopLogger) + event.session = Session("123", Date(), User(null, null, null), false, notifier, NoopLogger, apiKey) var msg: StateEvent.NotifyHandled? = null deliveryDelegate.addObserver( diff --git a/bugsnag-android-core/src/test/java/com/bugsnag/android/DeliveryHeadersTest.kt b/bugsnag-android-core/src/test/java/com/bugsnag/android/DeliveryHeadersTest.kt index 46259c7eaa..0ce163179a 100644 --- a/bugsnag-android-core/src/test/java/com/bugsnag/android/DeliveryHeadersTest.kt +++ b/bugsnag-android-core/src/test/java/com/bugsnag/android/DeliveryHeadersTest.kt @@ -17,6 +17,12 @@ class DeliveryHeadersTest { private val sha1Regex = "sha1 [0-9a-f]{40}".toRegex() + var file = File.createTempFile( + "150450000000053a27e4e-967c-4e5c-91be-2e86f2eb7cdc_v2", + "json" + ) + var session = Session(file, Notifier(), NoopLogger, "Test Apikey") + @Test fun computeSha1Digest() { val payload = generateEventPayload(generateImmutableConfig()) @@ -54,8 +60,8 @@ class DeliveryHeadersTest { @Test fun verifySessionApiHeaders() { val config = generateImmutableConfig() - val headers = config.getSessionApiDeliveryParams().headers - assertEquals(config.apiKey, headers["Bugsnag-Api-Key"]) + val headers = config.getSessionApiDeliveryParams(session).headers + assertEquals("Test Apikey", headers["Bugsnag-Api-Key"]) assertEquals("application/json", headers["Content-Type"]) assertNotNull(headers["Bugsnag-Sent-At"]) assertNotNull(headers["Bugsnag-Payload-Version"]) diff --git a/bugsnag-android-core/src/test/java/com/bugsnag/android/EventSerializationTest.kt b/bugsnag-android-core/src/test/java/com/bugsnag/android/EventSerializationTest.kt index b25ff529fb..61f6899509 100644 --- a/bugsnag-android-core/src/test/java/com/bugsnag/android/EventSerializationTest.kt +++ b/bugsnag-android-core/src/test/java/com/bugsnag/android/EventSerializationTest.kt @@ -17,6 +17,7 @@ internal class EventSerializationTest { @JvmStatic @Parameters fun testCases(): Collection> { + return generateSerializationTestCases( "event", createEvent(), @@ -33,7 +34,8 @@ internal class EventSerializationTest { // session included createEvent { val user = User("123", "foo@example.com", "Joe") - it.session = Session("123", Date(0), user, false, Notifier(), NoopLogger) + val apiKey = "BUGSNAG_API_KEY" + it.session = Session("123", Date(0), user, false, Notifier(), NoopLogger, apiKey) }, // threads included diff --git a/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionFacadeTest.java b/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionFacadeTest.java index d7de6e4b38..26daf94659 100644 --- a/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionFacadeTest.java +++ b/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionFacadeTest.java @@ -14,10 +14,16 @@ public class SessionFacadeTest { private Session session; private InterceptingLogger logger; + /** + * Captures session logs + */ @Before public void setUp() { logger = new InterceptingLogger(); - session = new Session("123", new Date(0), new User(), true, new Notifier(), logger); + session = new Session( + "123", new Date(0), new User(), + true, new Notifier(), logger, "BUGSNAG_API_KEY" + ); } @Test diff --git a/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionFilenameTest.kt b/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionFilenameTest.kt new file mode 100644 index 0000000000..a49fa83448 --- /dev/null +++ b/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionFilenameTest.kt @@ -0,0 +1,135 @@ +package com.bugsnag.android + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File + +class SessionFilenameTest { + private var config = BugsnagTestUtils.generateImmutableConfig() + + @Test + fun getSessionDetailsFromV3FileName() { + val apiKey = "TEST APIKEY" + val fileName = SessionFilenameInfo( + apiKey, + 1504255147933, + "my-uuid-uuuuuuuuuuuuuuuuuuuuuuuuuuuu" + ).encode() + val file = File(fileName) + val sessionInfo = SessionFilenameInfo.fromFile(file, defaultApiKey = config.apiKey) + + assertEquals( + "TEST APIKEY_my-uuid-uuuuuuuuuuuuuuuuuuuuuuuuuuuu1504255147933_v3.json", + fileName + ) + assertEquals("TEST APIKEY", sessionInfo.apiKey) + assertEquals(1504255147933, sessionInfo.timestamp) + assertEquals("my-uuid-uuuuuuuuuuuuuuuuuuuuuuuuuuuu", sessionInfo.uuid) + } + + @Test + fun getFileDetailsFromV2FileName() { + val file = File("my-uuid-uuuuuuuuuuuuuuuuuuuuuuuuuuuu1504255147933_v2.json") + val sessionInfo = SessionFilenameInfo.fromFile(file, defaultApiKey = config.apiKey) + assertEquals("my-uuid-uuuuuuuuuuuuuuuuuuuuuuuuuuuu", sessionInfo.uuid) + assertEquals(1504255147933, sessionInfo.timestamp) + assertEquals("5d1ec5bd39a74caa1267142706a7fb21", sessionInfo.apiKey) + } + + @Test + fun getSessionApiKeyFromV3FileNameWithoutApiKey() { + val fileName = SessionFilenameInfo( + "", + 1504255147933, + "my-uuid-uuuuuuuuuuuuuuuuuuuuuuuuuuuu" + ).encode() + + val file = File(fileName) + val sessionInfo = SessionFilenameInfo.fromFile(file, defaultApiKey = config.apiKey) + assertEquals(config.apiKey, sessionInfo.apiKey) + assertEquals("my-uuid-uuuuuuuuuuuuuuuuuuuuuuuuuuuu", sessionInfo.uuid) + assertEquals(1504255147933, sessionInfo.timestamp) + } + + @Test + fun getSessionTimeStampFromV3FileNameWithoutUuid() { + val apiKey = "TEST APIKEY" + val fileName = SessionFilenameInfo( + apiKey, + 1504255147933, + "" + ).encode() + + val file = File(fileName) + val sessionInfo = SessionFilenameInfo.fromFile(file, defaultApiKey = config.apiKey) + assertEquals("TEST APIKEY", sessionInfo.apiKey) + assertEquals(1504255147933, sessionInfo.timestamp) + assertEquals("", sessionInfo.uuid) + } + + @Test + fun getSessionUuidAndTimeStampFromV2FileNameWithoutUuid() { + val file = File("1504255147933_v2.json") + val sessionInfo = SessionFilenameInfo.fromFile(file, defaultApiKey = config.apiKey) + assertEquals("", sessionInfo.uuid) + assertEquals(1504255147933, sessionInfo.timestamp) + } + + @Test + fun getSessionTimeStampFromV3FileNameWithoutUuidAndApiKey() { + val fileName = SessionFilenameInfo( + "", + 1504255147933, + "" + ).encode() + + val file = File(fileName) + val sessionInfo = SessionFilenameInfo.fromFile(file, defaultApiKey = config.apiKey) + assertEquals(config.apiKey, sessionInfo.apiKey) + assertEquals(1504255147933, sessionInfo.timestamp) + assertEquals("", sessionInfo.uuid) + } + + @Test + fun getSessionUuidFromV2FileNameWithoutUuidAndApiKey() { + val file = File("1504255147933_v2.json") + val sessionInfo = SessionFilenameInfo.fromFile(file, defaultApiKey = config.apiKey) + assertEquals("", sessionInfo.uuid) + assertEquals(config.apiKey, sessionInfo.apiKey) + assertEquals(1504255147933, sessionInfo.timestamp) + } + + @Test + fun getSessionTimeStampFromV3FileNameWithoutTimeStamp() { + val file = File("_my-uuid-uuuuuuuuuuuuuuuuuuuuuuuuuuuu_v3.json") + val sessionInfo = SessionFilenameInfo.fromFile(file, defaultApiKey = config.apiKey) + assertEquals(config.apiKey, sessionInfo.apiKey) + assertEquals(-1, sessionInfo.timestamp) + assertEquals("my-uuid-uuuuuuuuuuuuuuuuuuuuuuuuuuuu", sessionInfo.uuid) + } + + @Test + fun getSessionUuidAndTimeStampFromV2FileNameWithoutTimeStamp() { + val file = File("my-uuid-uuuuuuuuuuuuuuuuuuuuuuuuuuuu_v2.json") + val sessionInfo = SessionFilenameInfo.fromFile(file, defaultApiKey = config.apiKey) + assertEquals(-1, sessionInfo.timestamp) + assertEquals("my-uuid-uuuuuuuuuuuuuuuuuuuuuuuuuuuu", sessionInfo.uuid) + } + + @Test + fun getSessionUuidAndTimeStampFromV2FileNameWithNoTimeStampAndNoUuid() { + val file = File("_v2.json") + val sessionInfo = SessionFilenameInfo.fromFile(file, defaultApiKey = config.apiKey) + assertEquals(-1, sessionInfo.timestamp) + assertEquals("", sessionInfo.uuid) + } + + @Test + fun getSessionDetailsFromV2FileNameWithNoTimeStampAndNoUuidAndApiKey() { + val file = File("_v3.json") + val sessionInfo = SessionFilenameInfo.fromFile(file, defaultApiKey = config.apiKey) + assertEquals(-1, sessionInfo.timestamp) + assertEquals("", sessionInfo.uuid) + assertEquals(config.apiKey, sessionInfo.apiKey) + } +} diff --git a/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionSerializationTest.kt b/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionSerializationTest.kt index 00a221d60a..a124f20f00 100644 --- a/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionSerializationTest.kt +++ b/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionSerializationTest.kt @@ -18,7 +18,7 @@ internal class SessionSerializationTest { @JvmStatic @Parameters fun testCases(): Collection> { - val session = Session("123", Date(0), User(null, null, null), 1, 0, notifier, NoopLogger) + val session = Session("123", Date(0), User(null, null, null), 1, 0, notifier, NoopLogger, "BUGSNAG_API_KEY") session.app = generateApp() session.device = generateDevice() return generateSerializationTestCases("session", session) diff --git a/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionTest.kt b/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionTest.kt index 5195fb63cc..779b10f8ea 100644 --- a/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionTest.kt +++ b/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionTest.kt @@ -23,7 +23,9 @@ class SessionTest { @Mock lateinit var app: AppWithState - private val session = Session("123", Date(0), User(), true, Notifier(), NoopLogger) + private val apiKey = "BUGSNAG_API_KEY" + + private var session = Session("123", Date(0), User(), true, Notifier(), NoopLogger, apiKey) /** * Verifies that all the fields in session are copied into a new object correctly @@ -56,6 +58,23 @@ class SessionTest { assertEquals("foo", session.id) } + @Test + fun overrideApiKey() { + assertEquals("BUGSNAG_API_KEY", session.apiKey) + session.apiKey = "foo" + assertEquals("foo", session.apiKey) + } + + @Test + fun defaultApiKey() { + val file = File("_my-uuid-uuuuuuuuuuuuuuuuuuuuuuuuuuuu1504255147933_v3.json") + session = Session( + "123", Date(0), User(), true, Notifier(), NoopLogger, + SessionFilenameInfo.findApiKeyInFilename(file, "Default apikey") + ) + assertEquals("Default apikey", session.apiKey) + } + @Test fun overrideStartedAt() { assertEquals(0, session.startedAt.time) @@ -90,12 +109,13 @@ class SessionTest { fun isV2() { assertFalse(session.isV2Payload) val file = File("150450000000053a27e4e-967c-4e5c-91be-2e86f2eb7cdc.json") - assertFalse(Session(file, Notifier(), NoopLogger).isV2Payload) + assertFalse(Session(file, Notifier(), NoopLogger, apiKey).isV2Payload) assertTrue( Session( File("150450000000053a27e4e-967c-4e5c-91be-2e86f2eb7cdc_v2.json"), Notifier(), - NoopLogger + NoopLogger, + apiKey ).isV2Payload ) } @@ -105,7 +125,7 @@ class SessionTest { val original = Notifier() val dep = Notifier("bugsnag-cobol") original.dependencies = listOf(dep) - val payload = Session(null, original, NoopLogger) + val payload = Session(null, original, NoopLogger, apiKey) val copy = payload.notifier assertNotSame(original, copy) assertNotSame(original.dependencies, copy.dependencies) diff --git a/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionTrackerTest.java b/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionTrackerTest.java index de1cd60c5d..4de50103c6 100644 --- a/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionTrackerTest.java +++ b/bugsnag-android-core/src/test/java/com/bugsnag/android/SessionTrackerTest.java @@ -106,6 +106,17 @@ public void startNewSession() { assertNotNull(newSession.getUser()); } + @Test + public void changeSessionApiKey() { + assertNotNull(sessionTracker); + assertNull(sessionTracker.getCurrentSession()); + Date date = new Date(); + sessionTracker.startNewSession(date, user, false); + Session newSession = sessionTracker.getCurrentSession(); + newSession.setApiKey("Test ApiKey"); + assertEquals("Test ApiKey", newSession.getApiKey()); + } + @Test public void startSessionDisabled() { assertNull(sessionTracker.getCurrentSession()); diff --git a/bugsnag-android-core/src/test/java/com/bugsnag/android/SharedPrefMigratorTest.kt b/bugsnag-android-core/src/test/java/com/bugsnag/android/SharedPrefMigratorTest.kt index 020646542b..21a889aa74 100644 --- a/bugsnag-android-core/src/test/java/com/bugsnag/android/SharedPrefMigratorTest.kt +++ b/bugsnag-android-core/src/test/java/com/bugsnag/android/SharedPrefMigratorTest.kt @@ -9,6 +9,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt import org.mockito.ArgumentMatchers.eq import org.mockito.Mock import org.mockito.Mockito.times @@ -32,10 +33,24 @@ internal class SharedPrefMigratorTest { @Before fun setUp() { - `when`(context.getSharedPreferences(eq("com.bugsnag.android"), eq(0))).thenReturn(prefs) + `when`(context.getSharedPreferences(eq("com.bugsnag.android"), anyInt())).thenReturn(prefs) prefMigrator = SharedPrefMigrator(context) } + @Test + fun nullSharedPreferences() { + `when`(context.getSharedPreferences(eq("com.bugsnag.android"), anyInt())).thenReturn(null) + prefMigrator = SharedPrefMigrator(context) + assertFalse(prefMigrator.hasPrefs()) + } + + @Test + fun gettingSharedPreferencesWithException() { + `when`(context.getSharedPreferences(eq("com.bugsnag.android"), anyInt())).thenThrow(RuntimeException()) + prefMigrator = SharedPrefMigrator(context) + assertFalse(prefMigrator.hasPrefs()) + } + @Test fun nullDeviceId() { `when`(prefs.getString("install.iud", null)).thenReturn(null) diff --git a/bugsnag-benchmarks/src/androidTest/java/com/bugsnag/android/ClientHooks.kt b/bugsnag-benchmarks/src/androidTest/java/com/bugsnag/android/ClientHooks.kt index e8ff256ede..9160f43818 100644 --- a/bugsnag-benchmarks/src/androidTest/java/com/bugsnag/android/ClientHooks.kt +++ b/bugsnag-benchmarks/src/androidTest/java/com/bugsnag/android/ClientHooks.kt @@ -38,6 +38,7 @@ internal fun generateSession(): Session { null, false, Notifier(), - object : Logger {} + object : Logger {}, + null ) } diff --git a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/SessionApiKeyResetScenario.kt b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/SessionApiKeyResetScenario.kt new file mode 100644 index 0000000000..2643e0e545 --- /dev/null +++ b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/SessionApiKeyResetScenario.kt @@ -0,0 +1,30 @@ +package com.bugsnag.android.mazerunner.scenarios + +import android.content.Context +import com.bugsnag.android.Bugsnag +import com.bugsnag.android.Configuration +import com.bugsnag.android.OnSessionCallback + +/** + * Reset session apikey. + */ +internal class SessionApiKeyResetScenario( + config: Configuration, + context: Context, + eventMetadata: String +) : Scenario(config, context, eventMetadata) { + + init { + config.addOnSession( + OnSessionCallback { session -> + session.setApiKey("TEST APIKEY") + true + } + ) + } + + override fun startScenario() { + super.startScenario() + Bugsnag.startSession() + } +} diff --git a/features/full_tests/session_tracking.feature b/features/full_tests/session_tracking.feature index 20bcfd676c..2211bdd92c 100644 --- a/features/full_tests/session_tracking.feature +++ b/features/full_tests/session_tracking.feature @@ -42,3 +42,19 @@ Feature: Session Tracking And the session "user.id" is not null And the session "user.name" is null And the session "user.email" is null + + Scenario: Session apikey can be reset + When I run "SessionApiKeyResetScenario" + And I wait to receive a session + Then the session Bugsnag-Integrity header is valid + And the session "Bugsnag-Api-Key" header equals "TEST APIKEY" + And the session "bugsnag-payload-version" header equals "1.0" + And the session "Content-Type" header equals "application/json" + And the session "Bugsnag-Sent-At" header is a timestamp + + And the session payload field "notifier.name" equals "Android Bugsnag Notifier" + And the session payload field "notifier.url" is not null + And the session payload field "notifier.version" is not null + + And the session payload field "app" is not null + And the session payload field "device" is not null diff --git a/features/full_tests/trimming.feature b/features/full_tests/trimming.feature index 938aa8f384..39b060e678 100644 --- a/features/full_tests/trimming.feature +++ b/features/full_tests/trimming.feature @@ -56,17 +56,17 @@ Feature: Excess data is trimmed when the payload is too big Then I wait to receive an error And the error is valid for the error reporting API version "4.0" for the "Android Bugsnag Notifier" notifier And the exception "message" equals "EventTooBigScenario" - And the event has 98 breadcrumbs - And the event "breadcrumbs.97.name" equals "Removed, along with 2 older breadcrumbs, to reduce payload size" - And the event "usage.system.breadcrumbsRemoved" equals 3 + And the event has less than 99 breadcrumbs + Then the event last breadcrumb has a message that matches the regex "Removed, along with [0-9]+ older breadcrumbs, to reduce payload size" + And the event "usage.system.breadcrumbsRemoved" is not null And the event "usage.system.breadcrumbBytesRemoved" is not null And I close and relaunch the app Then I wait to receive an error And the error is valid for the error reporting API version "4.0" for the "Android Bugsnag Notifier" notifier And the exception "message" equals "EventTooBigScenario" - And the event has 98 breadcrumbs - And the event "breadcrumbs.97.name" equals "Removed, along with 2 older breadcrumbs, to reduce payload size" - And the event "usage.system.breadcrumbsRemoved" equals 3 + And the event has less than 99 breadcrumbs + Then the event last breadcrumb has a message that matches the regex "Removed, along with [0-9]+ older breadcrumbs, to reduce payload size" + And the event "usage.system.breadcrumbsRemoved" is not null And the event "usage.system.breadcrumbBytesRemoved" is not null Scenario: Payload is too big by 3 breadcrumbs, handled exception @@ -75,9 +75,9 @@ Feature: Excess data is trimmed when the payload is too big Then I wait to receive an error And the error is valid for the error reporting API version "4.0" for the "Android Bugsnag Notifier" notifier And the exception "message" equals "EventTooBigScenario" - And the event has 98 breadcrumbs - And the event "breadcrumbs.97.name" equals "Removed, along with 2 older breadcrumbs, to reduce payload size" - And the event "usage.system.breadcrumbsRemoved" equals 3 + And the event has less than 99 breadcrumbs + Then the event last breadcrumb has a message that matches the regex "Removed, along with [0-9]+ older breadcrumbs, to reduce payload size" + And the event "usage.system.breadcrumbsRemoved" is not null And the event "usage.system.breadcrumbBytesRemoved" is not null Scenario: Payload is too big by 3 breadcrumbs, jvm exception diff --git a/features/steps/android_steps.rb b/features/steps/android_steps.rb index cba07ef123..ba86c3dc2e 100644 --- a/features/steps/android_steps.rb +++ b/features/steps/android_steps.rb @@ -275,3 +275,17 @@ def click_if_present(element) end end end + +Then("the event has less than {int} breadcrumb(s)") do |expected| + breadcrumbs = Maze::Server.errors.current[:body]['events'].first['breadcrumbs'] + Maze.check.operator( + breadcrumbs&.length || 0, :<, expected, + "Expected event to have less '#{expected}' breadcrumbs, but got: #{breadcrumbs}" + ) +end + +Then("the event last breadcrumb has a message that matches the regex {string}") do |pattern| + lastBreadcrumbName = Maze::Server.errors.current[:body]['events'].first['breadcrumbs'].last['name'] + regex = Regexp.new pattern + Maze.check.match regex, lastBreadcrumbName +end \ No newline at end of file