diff --git a/CHANGELOG.md b/CHANGELOG.md index d38a251b873..cb3a1154a05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Improvements - Session Replay: Use main thread looper to schedule replay capture ([#4542](https://github.com/getsentry/sentry-java/pull/4542)) +- Use single `LifecycleObserver` and multi-cast it to the integrations interested in lifecycle states ([#4567](https://github.com/getsentry/sentry-java/pull/4567)) ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 7e0cf42507b..044a1fded2b 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -166,11 +166,17 @@ public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/In public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/AppState { +public final class io/sentry/android/core/AppState : java/io/Closeable { + public fun close ()V public static fun getInstance ()Lio/sentry/android/core/AppState; public fun isInBackground ()Ljava/lang/Boolean; } +public abstract interface class io/sentry/android/core/AppState$AppStateListener { + public abstract fun onBackground ()V + public abstract fun onForeground ()V +} + public final class io/sentry/android/core/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z @@ -422,11 +428,13 @@ public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerfo public fun onSpanStarted (Lio/sentry/ISpan;)V } -public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable { +public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : io/sentry/Integration, io/sentry/android/core/AppState$AppStateListener, java/io/Closeable { public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;Ljava/util/List;)V public fun close ()V public static fun getDefaultActions ()Ljava/util/List; + public fun onBackground ()V + public fun onForeground ()V public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 33b003e9081..21dde74d3ee 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -128,6 +128,7 @@ static void loadDefaultAndMetadataOptions( options.setCacheDirPath(getCacheDir(context).getAbsolutePath()); readDefaultOptionValues(options, context, buildInfoProvider); + AppState.getInstance().registerLifecycleObserver(options); } @TestOnly diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java index 92bf2203481..9fd90b23099 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java @@ -2,12 +2,12 @@ import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; -import androidx.lifecycle.ProcessLifecycleOwner; import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; -import io.sentry.android.core.internal.util.AndroidThreadChecker; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; @@ -17,20 +17,11 @@ public final class AppLifecycleIntegration implements Integration, Closeable { + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); @TestOnly @Nullable volatile LifecycleWatcher watcher; private @Nullable SentryAndroidOptions options; - private final @NotNull MainLooperHandler handler; - - public AppLifecycleIntegration() { - this(new MainLooperHandler()); - } - - AppLifecycleIntegration(final @NotNull MainLooperHandler handler) { - this.handler = handler; - } - @Override public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { Objects.requireNonNull(scopes, "Scopes are required"); @@ -55,85 +46,47 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions if (this.options.isEnableAutoSessionTracking() || this.options.isEnableAppLifecycleBreadcrumbs()) { - try { - Class.forName("androidx.lifecycle.DefaultLifecycleObserver"); - Class.forName("androidx.lifecycle.ProcessLifecycleOwner"); - if (AndroidThreadChecker.getInstance().isMainThread()) { - addObserver(scopes); - } else { - // some versions of the androidx lifecycle-process require this to be executed on the main - // thread. - handler.post(() -> addObserver(scopes)); + try (final ISentryLifecycleToken ignored = lock.acquire()) { + if (watcher != null) { + return; } - } catch (ClassNotFoundException e) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "androidx.lifecycle is not available, AppLifecycleIntegration won't be installed"); - } catch (IllegalStateException e) { - options - .getLogger() - .log(SentryLevel.ERROR, "AppLifecycleIntegration could not be installed", e); - } - } - } - private void addObserver(final @NotNull IScopes scopes) { - // this should never happen, check added to avoid warnings from NullAway - if (this.options == null) { - return; - } + watcher = + new LifecycleWatcher( + scopes, + this.options.getSessionTrackingIntervalMillis(), + this.options.isEnableAutoSessionTracking(), + this.options.isEnableAppLifecycleBreadcrumbs()); - watcher = - new LifecycleWatcher( - scopes, - this.options.getSessionTrackingIntervalMillis(), - this.options.isEnableAutoSessionTracking(), - this.options.isEnableAppLifecycleBreadcrumbs()); + AppState.getInstance().addAppStateListener(watcher); + } - try { - ProcessLifecycleOwner.get().getLifecycle().addObserver(watcher); options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration installed."); addIntegrationToSdkVersion("AppLifecycle"); - } catch (Throwable e) { - // This is to handle a potential 'AbstractMethodError' gracefully. The error is triggered in - // connection with conflicting dependencies of the androidx.lifecycle. - // //See the issue here: https://github.com/getsentry/sentry-java/pull/2228 - watcher = null; - options - .getLogger() - .log( - SentryLevel.ERROR, - "AppLifecycleIntegration failed to get Lifecycle and could not be installed.", - e); } } private void removeObserver() { - final @Nullable LifecycleWatcher watcherRef = watcher; + final @Nullable LifecycleWatcher watcherRef; + try (final ISentryLifecycleToken ignored = lock.acquire()) { + watcherRef = watcher; + watcher = null; + } + if (watcherRef != null) { - ProcessLifecycleOwner.get().getLifecycle().removeObserver(watcherRef); + AppState.getInstance().removeAppStateListener(watcherRef); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration removed."); } } - watcher = null; } @Override public void close() throws IOException { - if (watcher == null) { - return; - } - if (AndroidThreadChecker.getInstance().isMainThread()) { - removeObserver(); - } else { - // some versions of the androidx lifecycle-process require this to be executed on the main - // thread. - // avoid method refs on Android due to some issues with older AGP setups - // noinspection Convert2MethodRef - handler.post(() -> removeObserver()); - } + removeObserver(); + // TODO: probably should move it to Scopes.close(), but that'd require a new interface and + // different implementations for Java and Android. This is probably fine like this too, because + // integrations are closed in the same place + AppState.getInstance().unregisterLifecycleObserver(); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java index d9633aed540..8fc1c8ab0b8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java @@ -1,7 +1,20 @@ package io.sentry.android.core; +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.ProcessLifecycleOwner; +import io.sentry.ILogger; import io.sentry.ISentryLifecycleToken; +import io.sentry.NoOpLogger; +import io.sentry.SentryLevel; +import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.util.AutoClosableReentrantLock; +import java.io.Closeable; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -9,9 +22,11 @@ /** AppState holds the state of the App, e.g. whether the app is in background/foreground, etc. */ @ApiStatus.Internal -public final class AppState { +public final class AppState implements Closeable { private static @NotNull AppState instance = new AppState(); private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private volatile LifecycleObserver lifecycleObserver; + private MainLooperHandler handler = new MainLooperHandler(); private AppState() {} @@ -19,7 +34,17 @@ private AppState() {} return instance; } - private @Nullable Boolean inBackground = null; + private volatile @Nullable Boolean inBackground = null; + + @TestOnly + LifecycleObserver getLifecycleObserver() { + return lifecycleObserver; + } + + @TestOnly + void setHandler(final @NotNull MainLooperHandler handler) { + this.handler = handler; + } @TestOnly void resetInstance() { @@ -31,8 +56,155 @@ void resetInstance() { } void setInBackground(final boolean inBackground) { + this.inBackground = inBackground; + } + + void addAppStateListener(final @NotNull AppStateListener listener) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + ensureLifecycleObserver(NoOpLogger.getInstance()); + + if (lifecycleObserver != null) { + lifecycleObserver.listeners.add(listener); + } + } + } + + void removeAppStateListener(final @NotNull AppStateListener listener) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - this.inBackground = inBackground; + if (lifecycleObserver != null) { + lifecycleObserver.listeners.remove(listener); + } } } + + void registerLifecycleObserver(final @Nullable SentryAndroidOptions options) { + if (lifecycleObserver != null) { + return; + } + + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + ensureLifecycleObserver(options != null ? options.getLogger() : NoOpLogger.getInstance()); + } + } + + private void ensureLifecycleObserver(final @NotNull ILogger logger) { + if (lifecycleObserver != null) { + return; + } + try { + Class.forName("androidx.lifecycle.ProcessLifecycleOwner"); + // create it right away, so it's available in addAppStateListener in case it's posted to main + // thread + lifecycleObserver = new LifecycleObserver(); + + if (AndroidThreadChecker.getInstance().isMainThread()) { + addObserverInternal(logger); + } else { + // some versions of the androidx lifecycle-process require this to be executed on the main + // thread. + handler.post(() -> addObserverInternal(logger)); + } + } catch (ClassNotFoundException e) { + logger.log( + SentryLevel.WARNING, + "androidx.lifecycle is not available, some features might not be properly working," + + "e.g. Session Tracking, Network and System Events breadcrumbs, etc."); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "AppState could not register lifecycle observer", e); + } + } + + private void addObserverInternal(final @NotNull ILogger logger) { + final @Nullable LifecycleObserver observerRef = lifecycleObserver; + try { + // might already be unregistered/removed so we have to check for nullability + if (observerRef != null) { + ProcessLifecycleOwner.get().getLifecycle().addObserver(observerRef); + } + } catch (Throwable e) { + // This is to handle a potential 'AbstractMethodError' gracefully. The error is triggered in + // connection with conflicting dependencies of the androidx.lifecycle. + // //See the issue here: https://github.com/getsentry/sentry-java/pull/2228 + lifecycleObserver = null; + logger.log( + SentryLevel.ERROR, + "AppState failed to get Lifecycle and could not install lifecycle observer.", + e); + } + } + + void unregisterLifecycleObserver() { + if (lifecycleObserver == null) { + return; + } + + final @Nullable LifecycleObserver ref; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + ref = lifecycleObserver; + lifecycleObserver.listeners.clear(); + lifecycleObserver = null; + } + + if (AndroidThreadChecker.getInstance().isMainThread()) { + removeObserverInternal(ref); + } else { + // some versions of the androidx lifecycle-process require this to be executed on the main + // thread. + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + handler.post(() -> removeObserverInternal(ref)); + } + } + + private void removeObserverInternal(final @Nullable LifecycleObserver ref) { + if (ref != null) { + ProcessLifecycleOwner.get().getLifecycle().removeObserver(ref); + } + } + + @Override + public void close() throws IOException { + unregisterLifecycleObserver(); + } + + final class LifecycleObserver implements DefaultLifecycleObserver { + final List listeners = + new CopyOnWriteArrayList() { + @Override + public boolean add(AppStateListener appStateListener) { + final boolean addResult = super.add(appStateListener); + // notify the listeners immediately to let them "catch up" with the current state + // (mimics the behavior of androidx.lifecycle) + if (Boolean.FALSE.equals(inBackground)) { + appStateListener.onForeground(); + } else if (Boolean.TRUE.equals(inBackground)) { + appStateListener.onBackground(); + } + return addResult; + } + }; + + @Override + public void onStart(@NonNull LifecycleOwner owner) { + setInBackground(false); + for (AppStateListener listener : listeners) { + listener.onForeground(); + } + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + setInBackground(true); + for (AppStateListener listener : listeners) { + listener.onBackground(); + } + } + } + + // If necessary, we can adjust this and add other callbacks in the future + public interface AppStateListener { + void onForeground(); + + void onBackground(); + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 89d78193207..a5e4d398e74 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -1,7 +1,5 @@ package io.sentry.android.core; -import androidx.lifecycle.DefaultLifecycleObserver; -import androidx.lifecycle.LifecycleOwner; import io.sentry.Breadcrumb; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; @@ -17,7 +15,7 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; -final class LifecycleWatcher implements DefaultLifecycleObserver { +final class LifecycleWatcher implements AppState.AppStateListener { private final AtomicLong lastUpdatedSession = new AtomicLong(0L); @@ -58,15 +56,10 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { this.currentDateProvider = currentDateProvider; } - // App goes to foreground @Override - public void onStart(final @NotNull LifecycleOwner owner) { + public void onForeground() { startSession(); addAppBreadcrumb("foreground"); - - // Consider using owner.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED); - // in the future. - AppState.getInstance().setInBackground(false); } private void startSession() { @@ -99,14 +92,13 @@ private void startSession() { // App went to background and triggered this callback after 700ms // as no new screen was shown @Override - public void onStop(final @NotNull LifecycleOwner owner) { + public void onBackground() { final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); this.lastUpdatedSession.set(currentTimeMillis); scopes.getOptions().getReplayController().pause(); scheduleEndSession(); - AppState.getInstance().setInBackground(true); addAppBreadcrumb("background"); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index f4dd4fbc94f..f8950ee7626 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -25,10 +25,6 @@ import android.content.Intent; import android.content.IntentFilter; import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.lifecycle.DefaultLifecycleObserver; -import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.ProcessLifecycleOwner; import io.sentry.Breadcrumb; import io.sentry.Hint; import io.sentry.IScopes; @@ -37,7 +33,6 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; -import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.internal.util.Debouncer; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -52,16 +47,13 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; -public final class SystemEventsBreadcrumbsIntegration implements Integration, Closeable { +public final class SystemEventsBreadcrumbsIntegration + implements Integration, Closeable, AppState.AppStateListener { private final @NotNull Context context; @TestOnly @Nullable volatile SystemEventsBroadcastReceiver receiver; - @TestOnly @Nullable volatile ReceiverLifecycleHandler lifecycleHandler; - - private final @NotNull MainLooperHandler handler; - private @Nullable SentryAndroidOptions options; private @Nullable IScopes scopes; @@ -78,18 +70,10 @@ public SystemEventsBreadcrumbsIntegration(final @NotNull Context context) { this(context, getDefaultActionsInternal()); } - private SystemEventsBreadcrumbsIntegration( - final @NotNull Context context, final @NotNull String[] actions) { - this(context, actions, new MainLooperHandler()); - } - SystemEventsBreadcrumbsIntegration( - final @NotNull Context context, - final @NotNull String[] actions, - final @NotNull MainLooperHandler handler) { + final @NotNull Context context, final @NotNull String[] actions) { this.context = ContextUtils.getApplicationContext(context); this.actions = actions; - this.handler = handler; } public SystemEventsBreadcrumbsIntegration( @@ -97,7 +81,6 @@ public SystemEventsBreadcrumbsIntegration( this.context = ContextUtils.getApplicationContext(context); this.actions = new String[actions.size()]; actions.toArray(this.actions); - this.handler = new MainLooperHandler(); } @Override @@ -117,7 +100,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions this.options.isEnableSystemEventBreadcrumbs()); if (this.options.isEnableSystemEventBreadcrumbs()) { - addLifecycleObserver(this.options); + AppState.getInstance().addAppStateListener(this); registerReceiver(this.scopes, this.options, /* reportAsNewIntegration= */ true); } } @@ -131,10 +114,8 @@ private void registerReceiver( return; } - try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { - if (isClosed || isStopped || receiver != null) { - return; - } + if (isClosed || isStopped || receiver != null) { + return; } try { @@ -185,88 +166,25 @@ private void registerReceiver( } private void unregisterReceiver() { - final @Nullable SystemEventsBroadcastReceiver receiverRef; - try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { - isStopped = true; - receiverRef = receiver; - receiver = null; - } - - if (receiverRef != null) { - context.unregisterReceiver(receiverRef); - } - } - - // TODO: this duplicates a lot of AppLifecycleIntegration. We should register once on init - // and multiplex to different listeners rather. - private void addLifecycleObserver(final @NotNull SentryAndroidOptions options) { - try { - Class.forName("androidx.lifecycle.DefaultLifecycleObserver"); - Class.forName("androidx.lifecycle.ProcessLifecycleOwner"); - if (AndroidThreadChecker.getInstance().isMainThread()) { - addObserverInternal(options); - } else { - // some versions of the androidx lifecycle-process require this to be executed on the main - // thread. - handler.post(() -> addObserverInternal(options)); - } - } catch (ClassNotFoundException e) { - options - .getLogger() - .log( - SentryLevel.WARNING, - "androidx.lifecycle is not available, SystemEventsBreadcrumbsIntegration won't be able" - + " to register/unregister an internal BroadcastReceiver. This may result in an" - + " increased ANR rate on Android 14 and above."); - } catch (Throwable e) { - options - .getLogger() - .log( - SentryLevel.ERROR, - "SystemEventsBreadcrumbsIntegration could not register lifecycle observer", - e); - } - } - - private void addObserverInternal(final @NotNull SentryAndroidOptions options) { - lifecycleHandler = new ReceiverLifecycleHandler(); - - try { - ProcessLifecycleOwner.get().getLifecycle().addObserver(lifecycleHandler); - } catch (Throwable e) { - // This is to handle a potential 'AbstractMethodError' gracefully. The error is triggered in - // connection with conflicting dependencies of the androidx.lifecycle. - // //See the issue here: https://github.com/getsentry/sentry-java/pull/2228 - lifecycleHandler = null; - options - .getLogger() - .log( - SentryLevel.ERROR, - "SystemEventsBreadcrumbsIntegration failed to get Lifecycle and could not install lifecycle observer.", - e); + if (options == null) { + return; } - } - private void removeLifecycleObserver() { - if (lifecycleHandler != null) { - if (AndroidThreadChecker.getInstance().isMainThread()) { - removeObserverInternal(); - } else { - // some versions of the androidx lifecycle-process require this to be executed on the main - // thread. - // avoid method refs on Android due to some issues with older AGP setups - // noinspection Convert2MethodRef - handler.post(() -> removeObserverInternal()); - } - } - } + options + .getExecutorService() + .submit( + () -> { + final @Nullable SystemEventsBroadcastReceiver receiverRef; + try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { + isStopped = true; + receiverRef = receiver; + receiver = null; + } - private void removeObserverInternal() { - final @Nullable ReceiverLifecycleHandler watcherRef = lifecycleHandler; - if (watcherRef != null) { - ProcessLifecycleOwner.get().getLifecycle().removeObserver(watcherRef); - } - lifecycleHandler = null; + if (receiverRef != null) { + context.unregisterReceiver(receiverRef); + } + }); } @Override @@ -276,11 +194,11 @@ public void close() throws IOException { filter = null; } - removeLifecycleObserver(); + AppState.getInstance().removeAppStateListener(this); unregisterReceiver(); if (options != null) { - options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration remove."); + options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration removed."); } } @@ -313,24 +231,20 @@ public void close() throws IOException { return actions; } - final class ReceiverLifecycleHandler implements DefaultLifecycleObserver { - @Override - public void onStart(@NonNull LifecycleOwner owner) { - if (scopes == null || options == null) { - return; - } + @Override + public void onForeground() { + if (scopes == null || options == null) { + return; + } - try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) { - isStopped = false; - } + isStopped = false; - registerReceiver(scopes, options, /* reportAsNewIntegration= */ false); - } + registerReceiver(scopes, options, /* reportAsNewIntegration= */ false); + } - @Override - public void onStop(@NonNull LifecycleOwner owner) { - unregisterReceiver(); - } + @Override + public void onBackground() { + unregisterReceiver(); } final class SystemEventsBroadcastReceiver extends BroadcastReceiver { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt index 6b2cafabe7f..896673085c2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt @@ -8,21 +8,17 @@ import kotlin.test.Test import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith -import org.mockito.kotlin.any import org.mockito.kotlin.mock -import org.mockito.kotlin.verify import org.robolectric.Shadows.shadowOf @RunWith(AndroidJUnit4::class) class AppLifecycleIntegrationTest { private class Fixture { val scopes = mock() - lateinit var handler: MainLooperHandler val options = SentryAndroidOptions() - fun getSut(mockHandler: Boolean = true): AppLifecycleIntegration { - handler = if (mockHandler) mock() else MainLooperHandler() - return AppLifecycleIntegration(handler) + fun getSut(): AppLifecycleIntegration { + return AppLifecycleIntegration() } } @@ -64,23 +60,7 @@ class AppLifecycleIntegrationTest { } @Test - fun `When AppLifecycleIntegration is registered from a background thread, post on the main thread`() { - val sut = fixture.getSut() - val latch = CountDownLatch(1) - - Thread { - sut.register(fixture.scopes, fixture.options) - latch.countDown() - } - .start() - - latch.await() - - verify(fixture.handler).post(any()) - } - - @Test - fun `When AppLifecycleIntegration is closed from a background thread, post on the main thread`() { + fun `When AppLifecycleIntegration is closed from a background thread, watcher is set to null`() { val sut = fixture.getSut() val latch = CountDownLatch(1) @@ -96,29 +76,25 @@ class AppLifecycleIntegrationTest { latch.await() - verify(fixture.handler).post(any()) + // ensure all messages on main looper got processed + shadowOf(Looper.getMainLooper()).idle() + + assertNull(sut.watcher) } @Test - fun `When AppLifecycleIntegration is closed from a background thread, watcher is set to null`() { - val sut = fixture.getSut(mockHandler = false) - val latch = CountDownLatch(1) + fun `When AppLifecycleIntegration is closed, AppState unregisterLifecycleObserver is called`() { + val sut = fixture.getSut() + val appState = AppState.getInstance() sut.register(fixture.scopes, fixture.options) - assertNotNull(sut.watcher) + // Verify that lifecycleObserver is not null after registration + assertNotNull(appState.lifecycleObserver) - Thread { - sut.close() - latch.countDown() - } - .start() - - latch.await() - - // ensure all messages on main looper got processed - shadowOf(Looper.getMainLooper()).idle() + sut.close() - assertNull(sut.watcher) + // Verify that lifecycleObserver is null after unregistering + assertNull(appState.lifecycleObserver) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppStateTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppStateTest.kt new file mode 100644 index 00000000000..4fe39b20f2e --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppStateTest.kt @@ -0,0 +1,344 @@ +package io.sentry.android.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.AppState.AppStateListener +import io.sentry.android.core.internal.util.AndroidThreadChecker +import java.util.concurrent.CountDownLatch +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class AppStateTest { + + private class Fixture { + val mockThreadChecker: AndroidThreadChecker = mock() + val mockHandler: MainLooperHandler = mock() + val options = SentryAndroidOptions() + val listener: AppStateListener = mock() + lateinit var androidThreadCheckerMock: MockedStatic + + fun getSut(isMainThread: Boolean = true): AppState { + val appState = AppState.getInstance() + whenever(mockThreadChecker.isMainThread).thenReturn(isMainThread) + appState.setHandler(mockHandler) + return appState + } + + fun createListener(): AppStateListener = mock() + } + + private val fixture = Fixture() + + @BeforeTest + fun `set up`() { + AppState.getInstance().resetInstance() + + // Mock AndroidThreadChecker + fixture.androidThreadCheckerMock = mockStatic(AndroidThreadChecker::class.java) + fixture.androidThreadCheckerMock + .`when` { AndroidThreadChecker.getInstance() } + .thenReturn(fixture.mockThreadChecker) + } + + @AfterTest + fun `tear down`() { + fixture.androidThreadCheckerMock.close() + } + + @Test + fun `getInstance returns singleton instance`() { + val instance1 = fixture.getSut() + val instance2 = fixture.getSut() + + assertSame(instance1, instance2) + } + + @Test + fun `resetInstance creates new instance`() { + val sut = fixture.getSut() + sut.setInBackground(true) + + sut.resetInstance() + + val newInstance = fixture.getSut() + assertNull(newInstance.isInBackground()) + } + + @Test + fun `isInBackground returns null initially`() { + val sut = fixture.getSut() + + assertNull(sut.isInBackground()) + } + + @Test + fun `setInBackground updates state`() { + val sut = fixture.getSut() + + sut.setInBackground(true) + assertTrue(sut.isInBackground()!!) + + sut.setInBackground(false) + assertFalse(sut.isInBackground()!!) + } + + @Test + fun `addAppStateListener creates lifecycle observer if needed`() { + val sut = fixture.getSut() + + sut.addAppStateListener(fixture.listener) + + assertNotNull(sut.lifecycleObserver) + } + + @Test + fun `addAppStateListener from background thread posts to main thread`() { + val sut = fixture.getSut(isMainThread = false) + + sut.addAppStateListener(fixture.listener) + + verify(fixture.mockHandler).post(any()) + } + + @Test + fun `addAppStateListener notifies listener with onForeground when in foreground state`() { + val sut = fixture.getSut() + + sut.setInBackground(false) + sut.addAppStateListener(fixture.listener) + + verify(fixture.listener).onForeground() + verify(fixture.listener, never()).onBackground() + } + + @Test + fun `addAppStateListener notifies listener with onBackground when in background state`() { + val sut = fixture.getSut() + + sut.setInBackground(true) + sut.addAppStateListener(fixture.listener) + + verify(fixture.listener).onBackground() + verify(fixture.listener, never()).onForeground() + } + + @Test + fun `addAppStateListener does not notify listener when state is unknown`() { + val sut = fixture.getSut() + + // State is null (unknown) by default + sut.addAppStateListener(fixture.listener) + + verify(fixture.listener, never()).onForeground() + verify(fixture.listener, never()).onBackground() + } + + @Test + fun `removeAppStateListener removes listener`() { + val sut = fixture.getSut() + + sut.addAppStateListener(fixture.listener) + val observer = sut.lifecycleObserver + // Check that listener was added + assertNotNull(observer) + + sut.removeAppStateListener(fixture.listener) + // Listener should be removed but observer still exists + assertNotNull(sut.lifecycleObserver) + } + + @Test + fun `removeAppStateListener handles null lifecycle observer`() { + val sut = fixture.getSut() + + // Should not throw when lifecycleObserver is null + sut.removeAppStateListener(fixture.listener) + } + + @Test + fun `registerLifecycleObserver does nothing if already registered`() { + val sut = fixture.getSut() + + sut.registerLifecycleObserver(fixture.options) + val firstObserver = sut.lifecycleObserver + + sut.registerLifecycleObserver(fixture.options) + val secondObserver = sut.lifecycleObserver + + assertSame(firstObserver, secondObserver) + } + + @Test + fun `unregisterLifecycleObserver clears listeners and nulls observer`() { + val sut = fixture.getSut() + + sut.addAppStateListener(fixture.listener) + assertNotNull(sut.lifecycleObserver) + + sut.unregisterLifecycleObserver() + + assertNull(sut.lifecycleObserver) + } + + @Test + fun `unregisterLifecycleObserver handles null observer`() { + val sut = fixture.getSut() + + // Should not throw when lifecycleObserver is already null + sut.unregisterLifecycleObserver() + } + + @Test + fun `unregisterLifecycleObserver from background thread posts to main thread`() { + val sut = fixture.getSut(isMainThread = false) + + sut.registerLifecycleObserver(fixture.options) + + sut.unregisterLifecycleObserver() + + // 2 times - register and unregister + verify(fixture.mockHandler, times(2)).post(any()) + } + + @Test + fun `close calls unregisterLifecycleObserver`() { + val sut = fixture.getSut() + sut.addAppStateListener(fixture.listener) + + sut.close() + + assertNull(sut.lifecycleObserver) + } + + @Test + fun `LifecycleObserver onStart notifies all listeners and sets foreground`() { + val listener1 = fixture.createListener() + val listener2 = fixture.createListener() + val sut = fixture.getSut() + + // Add listeners to create observer + sut.addAppStateListener(listener1) + sut.addAppStateListener(listener2) + + val observer = sut.lifecycleObserver!! + observer.onStart(mock()) + + verify(listener1).onForeground() + verify(listener2).onForeground() + assertFalse(sut.isInBackground()!!) + } + + @Test + fun `LifecycleObserver onStop notifies all listeners and sets background`() { + val listener1 = fixture.createListener() + val listener2 = fixture.createListener() + val sut = fixture.getSut() + + // Add listeners to create observer + sut.addAppStateListener(listener1) + sut.addAppStateListener(listener2) + + val observer = sut.lifecycleObserver!! + observer.onStop(mock()) + + verify(listener1).onBackground() + verify(listener2).onBackground() + assertTrue(sut.isInBackground()!!) + } + + @Test + fun `a listener can be unregistered within a callback`() { + val sut = fixture.getSut() + + var onForegroundCalled = false + val listener = + object : AppStateListener { + override fun onForeground() { + sut.removeAppStateListener(this) + onForegroundCalled = true + } + + override fun onBackground() { + // ignored + } + } + + sut.registerLifecycleObserver(fixture.options) + val observer = sut.lifecycleObserver!! + observer.onStart(mock()) + + // if an observer is added + sut.addAppStateListener(listener) + + // it should be notified + assertTrue(onForegroundCalled) + + // and removed from the list of listeners if it unregisters itself within the callback + assertEquals(sut.lifecycleObserver?.listeners?.size, 0) + } + + @Test + fun `state is correct within onStart and onStop callbacks`() { + val sut = fixture.getSut() + + var onForegroundCalled = false + var onBackgroundCalled = false + val listener = + object : AppStateListener { + override fun onForeground() { + assertFalse(sut.isInBackground!!) + onForegroundCalled = true + } + + override fun onBackground() { + assertTrue(sut.isInBackground!!) + onBackgroundCalled = true + } + } + + sut.addAppStateListener(listener) + + val observer = sut.lifecycleObserver!! + observer.onStart(mock()) + observer.onStop(mock()) + + assertTrue(onForegroundCalled) + assertTrue(onBackgroundCalled) + } + + @Test + fun `thread safety - concurrent access is handled`() { + val listeners = (1..5).map { fixture.createListener() } + val sut = fixture.getSut() + val latch = CountDownLatch(5) + + // Add listeners concurrently + listeners.forEach { listener -> + Thread { + sut.addAppStateListener(listener) + latch.countDown() + } + .start() + } + latch.await() + + val observer = sut.lifecycleObserver + assertNotNull(observer) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 446dfa3330a..5149f167129 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -1,6 +1,5 @@ package io.sentry.android.core -import androidx.lifecycle.LifecycleOwner import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.IContinuousProfiler @@ -16,10 +15,8 @@ import io.sentry.transport.ICurrentDateProvider import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull -import kotlin.test.assertTrue import org.mockito.ArgumentCaptor import org.mockito.kotlin.any import org.mockito.kotlin.check @@ -33,7 +30,6 @@ import org.mockito.kotlin.whenever class LifecycleWatcherTest { private class Fixture { - val ownerMock = mock() val scopes = mock() val dateProvider = mock() val options = SentryOptions() @@ -77,7 +73,7 @@ class LifecycleWatcherTest { @Test fun `if last started session is 0, start new session`() { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.scopes).startSession() verify(fixture.replayController).start() } @@ -86,8 +82,8 @@ class LifecycleWatcherTest { fun `if last started session is after interval, start new session`() { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) whenever(fixture.dateProvider.currentTimeMillis).thenReturn(1L, 2L) - watcher.onStart(fixture.ownerMock) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() + watcher.onForeground() verify(fixture.scopes, times(2)).startSession() verify(fixture.replayController, times(2)).start() } @@ -96,8 +92,8 @@ class LifecycleWatcherTest { fun `if last started session is before interval, it should not start a new session`() { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) whenever(fixture.dateProvider.currentTimeMillis).thenReturn(2L, 1L) - watcher.onStart(fixture.ownerMock) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() + watcher.onForeground() verify(fixture.scopes).startSession() verify(fixture.replayController).start() } @@ -105,8 +101,8 @@ class LifecycleWatcherTest { @Test fun `if app goes to background, end session after interval`() { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) - watcher.onStop(fixture.ownerMock) + watcher.onForeground() + watcher.onBackground() verify(fixture.scopes, timeout(10000)).endSession() verify(fixture.replayController, timeout(10000)).stop() verify(fixture.continuousProfiler, timeout(10000)).close(eq(false)) @@ -116,12 +112,12 @@ class LifecycleWatcherTest { fun `if app goes to background and foreground again, dont end the session`() { val watcher = fixture.getSUT(sessionIntervalMillis = 30000L, enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() - watcher.onStop(fixture.ownerMock) + watcher.onBackground() assertNotNull(watcher.timerTask) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() assertNull(watcher.timerTask) verify(fixture.scopes, never()).endSession() @@ -132,7 +128,7 @@ class LifecycleWatcherTest { fun `When session tracking is disabled, do not start session`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.scopes, never()).startSession() } @@ -140,14 +136,14 @@ class LifecycleWatcherTest { fun `When session tracking is disabled, do not end session`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStop(fixture.ownerMock) + watcher.onBackground() verify(fixture.scopes, never()).endSession() } @Test fun `When app lifecycle breadcrumbs is enabled, add breadcrumb on start`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.scopes) .addBreadcrumb( check { @@ -163,14 +159,14 @@ class LifecycleWatcherTest { fun `When app lifecycle breadcrumbs is disabled, do not add breadcrumb on start`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test fun `When app lifecycle breadcrumbs is enabled, add breadcrumb on stop`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false) - watcher.onStop(fixture.ownerMock) + watcher.onBackground() verify(fixture.scopes) .addBreadcrumb( check { @@ -186,7 +182,7 @@ class LifecycleWatcherTest { fun `When app lifecycle breadcrumbs is disabled, do not add breadcrumb on stop`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStop(fixture.ownerMock) + watcher.onBackground() verify(fixture.scopes, never()).addBreadcrumb(any()) } @@ -221,7 +217,7 @@ class LifecycleWatcherTest { ), ) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.scopes, never()).startSession() verify(fixture.replayController, never()).start() } @@ -250,25 +246,11 @@ class LifecycleWatcherTest { ), ) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.scopes).startSession() verify(fixture.replayController).start() } - @Test - fun `When app goes into foreground, sets isBackground to false for AppState`() { - val watcher = fixture.getSUT() - watcher.onStart(fixture.ownerMock) - assertFalse(AppState.getInstance().isInBackground!!) - } - - @Test - fun `When app goes into background, sets isBackground to true for AppState`() { - val watcher = fixture.getSUT() - watcher.onStop(fixture.ownerMock) - assertTrue(AppState.getInstance().isInBackground!!) - } - @Test fun `if the hub has already a fresh session running, resumes replay to invalidate isManualPause flag`() { val watcher = @@ -293,7 +275,7 @@ class LifecycleWatcherTest { ), ) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.replayController).resume() } @@ -301,16 +283,16 @@ class LifecycleWatcherTest { fun `background-foreground replay`() { whenever(fixture.dateProvider.currentTimeMillis).thenReturn(1L) val watcher = fixture.getSUT(sessionIntervalMillis = 2L, enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.replayController).start() - watcher.onStop(fixture.ownerMock) + watcher.onBackground() verify(fixture.replayController).pause() - watcher.onStart(fixture.ownerMock) + watcher.onForeground() verify(fixture.replayController, times(2)).resume() - watcher.onStop(fixture.ownerMock) + watcher.onBackground() verify(fixture.replayController, timeout(10000)).stop() } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt index d4264d9831b..bdb328e2421 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt @@ -18,7 +18,6 @@ import io.sentry.SentryEnvelope import io.sentry.SentryEvent import io.sentry.SentryLogEvent import io.sentry.SentryLogEvents -import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.Session import io.sentry.TraceContext @@ -41,6 +40,7 @@ class SessionTrackingIntegrationTest { @BeforeTest fun `set up`() { + AppState.getInstance().resetInstance() context = ApplicationProvider.getApplicationContext() } @@ -56,7 +56,7 @@ class SessionTrackingIntegrationTest { } val client = CapturingSentryClient() Sentry.bindClient(client) - val lifecycle = setupLifecycle(options) + val lifecycle = setupLifecycle() val initSid = lastSessionId() lifecycle.handleLifecycleEvent(ON_START) @@ -115,12 +115,9 @@ class SessionTrackingIntegrationTest { return sid } - private fun setupLifecycle(options: SentryOptions): LifecycleRegistry { + private fun setupLifecycle(): LifecycleRegistry { val lifecycle = LifecycleRegistry(ProcessLifecycleOwner.get()) - val lifecycleWatcher = - (options.integrations.find { it is AppLifecycleIntegration } as AppLifecycleIntegration) - .watcher - lifecycle.addObserver(lifecycleWatcher!!) + lifecycle.addObserver(AppState.getInstance().lifecycleObserver) return lifecycle } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt index 650c36868ba..c156eafd1ef 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt @@ -13,11 +13,14 @@ import io.sentry.SentryLevel import io.sentry.test.DeferredExecutorService import io.sentry.test.ImmediateExecutorService import java.util.concurrent.CountDownLatch +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -38,14 +41,11 @@ class SystemEventsBreadcrumbsIntegrationTest { val context = mock() var options = SentryAndroidOptions() val scopes = mock() - lateinit var handler: MainLooperHandler fun getSut( enableSystemEventBreadcrumbs: Boolean = true, executorService: ISentryExecutorService = ImmediateExecutorService(), - mockHandler: Boolean = true, ): SystemEventsBreadcrumbsIntegration { - handler = if (mockHandler) mock() else MainLooperHandler() options = SentryAndroidOptions().apply { isEnableSystemEventBreadcrumbs = enableSystemEventBreadcrumbs @@ -54,13 +54,23 @@ class SystemEventsBreadcrumbsIntegrationTest { return SystemEventsBreadcrumbsIntegration( context, SystemEventsBreadcrumbsIntegration.getDefaultActions().toTypedArray(), - handler, ) } } private val fixture = Fixture() + @BeforeTest + fun `set up`() { + AppState.getInstance().resetInstance() + AppState.getInstance().registerLifecycleObserver(fixture.options) + } + + @AfterTest + fun `tear down`() { + AppState.getInstance().unregisterLifecycleObserver() + } + @Test fun `When system events breadcrumb is enabled, it registers callback`() { val sut = fixture.getSut() @@ -337,82 +347,60 @@ class SystemEventsBreadcrumbsIntegrationTest { } @Test - fun `When integration is added, lifecycle handler should be started`() { + fun `When integration is added, should subscribe for app state events`() { val sut = fixture.getSut() sut.register(fixture.scopes, fixture.options) - assertNotNull(sut.lifecycleHandler) + assertTrue( + AppState.getInstance().lifecycleObserver.listeners.any { + it is SystemEventsBreadcrumbsIntegration + } + ) } @Test - fun `When system events breadcrumbs are disabled, lifecycle handler should not be started`() { + fun `When system events breadcrumbs are disabled, should not subscribe for app state events`() { val sut = fixture.getSut() fixture.options.apply { isEnableSystemEventBreadcrumbs = false } sut.register(fixture.scopes, fixture.options) - assertNull(sut.lifecycleHandler) + assertFalse( + AppState.getInstance().lifecycleObserver.listeners.any { + it is SystemEventsBreadcrumbsIntegration + } + ) } @Test - fun `When integration is closed, lifecycle handler should be closed`() { + fun `When integration is closed, should unsubscribe from app state events`() { val sut = fixture.getSut() sut.register(fixture.scopes, fixture.options) - assertNotNull(sut.lifecycleHandler) + assertTrue( + AppState.getInstance().lifecycleObserver.listeners.any { + it is SystemEventsBreadcrumbsIntegration + } + ) sut.close() - assertNull(sut.lifecycleHandler) - } - - @Test - fun `When integration is registered from a background thread, post on the main thread`() { - val sut = fixture.getSut() - val latch = CountDownLatch(1) - - Thread { - sut.register(fixture.scopes, fixture.options) - latch.countDown() + assertFalse( + AppState.getInstance().lifecycleObserver.listeners.any { + it is SystemEventsBreadcrumbsIntegration } - .start() - - latch.await() - - verify(fixture.handler).post(any()) + ) } @Test - fun `When integration is closed from a background thread, post on the main thread`() { + fun `When integration is closed from a background thread, unsubscribes from app events`() { val sut = fixture.getSut() val latch = CountDownLatch(1) sut.register(fixture.scopes, fixture.options) - assertNotNull(sut.lifecycleHandler) - - Thread { - sut.close() - latch.countDown() - } - .start() - - latch.await() - - verify(fixture.handler).post(any()) - } - - @Test - fun `When integration is closed from a background thread, watcher is set to null`() { - val sut = fixture.getSut(mockHandler = false) - val latch = CountDownLatch(1) - - sut.register(fixture.scopes, fixture.options) - - assertNotNull(sut.lifecycleHandler) - Thread { sut.close() latch.countDown() @@ -424,7 +412,11 @@ class SystemEventsBreadcrumbsIntegrationTest { // ensure all messages on main looper got processed shadowOf(Looper.getMainLooper()).idle() - assertNull(sut.lifecycleHandler) + assertFalse( + AppState.getInstance().lifecycleObserver.listeners.any { + it is SystemEventsBreadcrumbsIntegration + } + ) } @Test @@ -433,7 +425,7 @@ class SystemEventsBreadcrumbsIntegrationTest { sut.register(fixture.scopes, fixture.options) - sut.lifecycleHandler!!.onStop(mock()) + sut.onBackground() verify(fixture.context).unregisterReceiver(any()) assertNull(sut.receiver) @@ -446,8 +438,8 @@ class SystemEventsBreadcrumbsIntegrationTest { sut.register(fixture.scopes, fixture.options) verify(fixture.context).registerReceiver(any(), any(), any()) - sut.lifecycleHandler!!.onStop(mock()) - sut.lifecycleHandler!!.onStart(mock()) + sut.onBackground() + sut.onForeground() verify(fixture.context, times(2)).registerReceiver(any(), any(), any()) assertNotNull(sut.receiver) @@ -461,7 +453,7 @@ class SystemEventsBreadcrumbsIntegrationTest { verify(fixture.context).registerReceiver(any(), any(), any()) val receiver = sut.receiver - sut.lifecycleHandler!!.onStart(mock()) + sut.onForeground() assertEquals(receiver, sut.receiver) } @@ -473,10 +465,11 @@ class SystemEventsBreadcrumbsIntegrationTest { deferredExecutorService.runAll() assertNotNull(sut.receiver) - sut.lifecycleHandler!!.onStop(mock()) - sut.lifecycleHandler!!.onStart(mock()) + sut.onBackground() + sut.onForeground() + deferredExecutorService.runAll() assertNull(sut.receiver) - sut.lifecycleHandler!!.onStop(mock()) + sut.onBackground() deferredExecutorService.runAll() assertNull(sut.receiver) } @@ -486,7 +479,7 @@ class SystemEventsBreadcrumbsIntegrationTest { val deferredExecutorService = DeferredExecutorService() val latch = CountDownLatch(1) - val sut = fixture.getSut(executorService = deferredExecutorService, mockHandler = false) + val sut = fixture.getSut(executorService = deferredExecutorService) sut.register(fixture.scopes, fixture.options) deferredExecutorService.runAll() assertNotNull(sut.receiver) @@ -498,14 +491,14 @@ class SystemEventsBreadcrumbsIntegrationTest { .start() latch.await() + deferredExecutorService.runAll() - sut.lifecycleHandler!!.onStart(mock()) + sut.onForeground() assertNull(sut.receiver) deferredExecutorService.runAll() shadowOf(Looper.getMainLooper()).idle() assertNull(sut.receiver) - assertNull(sut.lifecycleHandler) } }