diff --git a/app/src/androidTest/java/com/duckduckgo/app/global/AlertingUncaughtExceptionHandlerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/global/AlertingUncaughtExceptionHandlerTest.kt new file mode 100644 index 000000000000..866dc0b73164 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/global/AlertingUncaughtExceptionHandlerTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2020 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.global + +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.global.exception.UncaughtExceptionRepository +import com.duckduckgo.app.global.exception.UncaughtExceptionSource +import com.duckduckgo.app.statistics.store.OfflinePixelCountDataStore +import com.nhaarman.mockitokotlin2.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.io.InterruptedIOException + +@ExperimentalCoroutinesApi +class AlertingUncaughtExceptionHandlerTest { + + private lateinit var testee: AlertingUncaughtExceptionHandler + private val mockDefaultExceptionHandler: Thread.UncaughtExceptionHandler = mock() + private val mockPixelCountDataStore: OfflinePixelCountDataStore = mock() + private val mockUncaughtExceptionRepository: UncaughtExceptionRepository = mock() + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + @Before + fun setup() { + testee = AlertingUncaughtExceptionHandler( + mockDefaultExceptionHandler, + mockPixelCountDataStore, + mockUncaughtExceptionRepository, + coroutineTestRule.testDispatcherProvider + ) + } + + @Test + fun whenExceptionIsNotInIgnoreListThenCrashRecordedInDatabase() = coroutineTestRule.testDispatcher.runBlockingTest { + testee.uncaughtException(Thread.currentThread(), NullPointerException("Deliberate")) + advanceUntilIdle() + + verify(mockUncaughtExceptionRepository).recordUncaughtException(any(), eq(UncaughtExceptionSource.GLOBAL)) + } + + @Test + fun whenExceptionIsNotInIgnoreListThenDefaultExceptionHandlerCalled() = coroutineTestRule.testDispatcher.runBlockingTest { + val exception = NullPointerException("Deliberate") + testee.uncaughtException(Thread.currentThread(), exception) + advanceUntilIdle() + + verify(mockDefaultExceptionHandler).uncaughtException(any(), eq(exception)) + } + + @Test + fun whenExceptionIsInterruptedIoExceptionThenCrashNotRecorded() = coroutineTestRule.testDispatcher.runBlockingTest { + testee.uncaughtException(Thread.currentThread(), InterruptedIOException("Deliberate")) + advanceUntilIdle() + + verify(mockUncaughtExceptionRepository, never()).recordUncaughtException(any(), any()) + } + + @Test + fun whenExceptionIsInterruptedExceptionThenCrashNotRecorded() = coroutineTestRule.testDispatcher.runBlockingTest { + testee.uncaughtException(Thread.currentThread(), InterruptedException("Deliberate")) + advanceUntilIdle() + + verify(mockUncaughtExceptionRepository, never()).recordUncaughtException(any(), any()) + } + + @Test + fun whenExceptionIsNotRecordedButInDebugModeThenDefaultExceptionHandlerCalled() = coroutineTestRule.testDispatcher.runBlockingTest { + val exception = InterruptedIOException("Deliberate") + testee.uncaughtException(Thread.currentThread(), exception) + advanceUntilIdle() + + verify(mockDefaultExceptionHandler).uncaughtException(any(), eq(exception)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/global/AlertingUncaughtExceptionHandler.kt b/app/src/main/java/com/duckduckgo/app/global/AlertingUncaughtExceptionHandler.kt index d8c3217ce6f1..d2366571e4cd 100644 --- a/app/src/main/java/com/duckduckgo/app/global/AlertingUncaughtExceptionHandler.kt +++ b/app/src/main/java/com/duckduckgo/app/global/AlertingUncaughtExceptionHandler.kt @@ -20,16 +20,17 @@ import com.duckduckgo.app.browser.BuildConfig import com.duckduckgo.app.global.exception.UncaughtExceptionRepository import com.duckduckgo.app.global.exception.UncaughtExceptionSource import com.duckduckgo.app.statistics.store.OfflinePixelCountDataStore -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch +import timber.log.Timber import java.io.InterruptedIOException class AlertingUncaughtExceptionHandler( private val originalHandler: Thread.UncaughtExceptionHandler, private val offlinePixelCountDataStore: OfflinePixelCountDataStore, - private val uncaughtExceptionRepository: UncaughtExceptionRepository + private val uncaughtExceptionRepository: UncaughtExceptionRepository, + private val dispatcherProvider: DispatcherProvider ) : Thread.UncaughtExceptionHandler { override fun uncaughtException(thread: Thread?, originalException: Throwable?) { @@ -64,12 +65,15 @@ class AlertingUncaughtExceptionHandler( private fun shouldCrashApp(): Boolean = BuildConfig.DEBUG private fun recordExceptionAndAllowCrash(thread: Thread?, originalException: Throwable?) { - GlobalScope.launch(Dispatchers.IO + NonCancellable) { - uncaughtExceptionRepository.recordUncaughtException(originalException, UncaughtExceptionSource.GLOBAL) - offlinePixelCountDataStore.applicationCrashCount += 1 - - // wait until the exception has been fully processed before propagating exception - originalHandler.uncaughtException(thread, originalException) + GlobalScope.launch(dispatcherProvider.io() + NonCancellable) { + try { + uncaughtExceptionRepository.recordUncaughtException(originalException, UncaughtExceptionSource.GLOBAL) + offlinePixelCountDataStore.applicationCrashCount += 1 + } catch (e: Throwable) { + Timber.e(e, "Failed to record exception") + } finally { + originalHandler.uncaughtException(thread, originalException) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionModule.kt b/app/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionModule.kt index c24d53dc181a..c07c05ac07bd 100644 --- a/app/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionModule.kt +++ b/app/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionModule.kt @@ -17,6 +17,7 @@ package com.duckduckgo.app.global.exception import com.duckduckgo.app.global.AlertingUncaughtExceptionHandler +import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.statistics.store.OfflinePixelCountDataStore import dagger.Module import dagger.Provides @@ -39,10 +40,11 @@ class UncaughtExceptionModule { @Singleton fun alertingUncaughtExceptionHandler( offlinePixelCountDataStore: OfflinePixelCountDataStore, - uncaughtExceptionRepository: UncaughtExceptionRepository + uncaughtExceptionRepository: UncaughtExceptionRepository, + dispatcherProvider: DispatcherProvider ): AlertingUncaughtExceptionHandler { val originalHandler = Thread.getDefaultUncaughtExceptionHandler() - return AlertingUncaughtExceptionHandler(originalHandler, offlinePixelCountDataStore, uncaughtExceptionRepository) + return AlertingUncaughtExceptionHandler(originalHandler, offlinePixelCountDataStore, uncaughtExceptionRepository, dispatcherProvider) } @Provides