Add UI for prompting about crashes.
This commit is contained in:
parent
0a6c3baf24
commit
f959543c19
23 changed files with 1089 additions and 182 deletions
|
@ -34,7 +34,7 @@ class SignalInstrumentationApplicationContext : ApplicationContext() {
|
|||
|
||||
SignalExecutors.UNBOUNDED.execute {
|
||||
Log.blockUntilAllWritesFinished()
|
||||
LogDatabase.getInstance(this).trimToSize()
|
||||
LogDatabase.getInstance(this).logs.trimToSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,288 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.forEach
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.thoughtcrime.securesms.crash.CrashConfig
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
|
||||
class LogDatabaseTest {
|
||||
|
||||
private val db: LogDatabase = LogDatabase.getInstance(ApplicationDependencies.getApplication())
|
||||
|
||||
@Test
|
||||
fun crashTable_matchesNamePattern() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
db.crashes.saveCrash(
|
||||
createdAt = currentTime,
|
||||
name = "TestName",
|
||||
message = "Test Message",
|
||||
stackTrace = "test\nstack\ntrace"
|
||||
)
|
||||
|
||||
val foundMatch = db.crashes.anyMatch(
|
||||
listOf(
|
||||
CrashConfig.CrashPattern(namePattern = "Test")
|
||||
),
|
||||
promptThreshold = currentTime
|
||||
)
|
||||
|
||||
foundMatch assertIs true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashTable_matchesMessagePattern() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
db.crashes.saveCrash(
|
||||
createdAt = currentTime,
|
||||
name = "TestName",
|
||||
message = "Test Message",
|
||||
stackTrace = "test\nstack\ntrace"
|
||||
)
|
||||
|
||||
val foundMatch = db.crashes.anyMatch(
|
||||
listOf(
|
||||
CrashConfig.CrashPattern(messagePattern = "Message")
|
||||
),
|
||||
promptThreshold = currentTime
|
||||
)
|
||||
|
||||
foundMatch assertIs true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashTable_matchesStackTracePattern() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
db.crashes.saveCrash(
|
||||
createdAt = currentTime,
|
||||
name = "TestName",
|
||||
message = "Test Message",
|
||||
stackTrace = "test\nstack\ntrace"
|
||||
)
|
||||
|
||||
val foundMatch = db.crashes.anyMatch(
|
||||
listOf(
|
||||
CrashConfig.CrashPattern(stackTracePattern = "stack")
|
||||
),
|
||||
promptThreshold = currentTime
|
||||
)
|
||||
|
||||
foundMatch assertIs true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashTable_matchesNameAndMessagePattern() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
db.crashes.saveCrash(
|
||||
createdAt = currentTime,
|
||||
name = "TestName",
|
||||
message = "Test Message",
|
||||
stackTrace = "test\nstack\ntrace"
|
||||
)
|
||||
|
||||
val foundMatch = db.crashes.anyMatch(
|
||||
listOf(
|
||||
CrashConfig.CrashPattern(namePattern = "Test", messagePattern = "Message")
|
||||
),
|
||||
promptThreshold = currentTime
|
||||
)
|
||||
|
||||
foundMatch assertIs true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashTable_matchesNameAndStackTracePattern() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
db.crashes.saveCrash(
|
||||
createdAt = currentTime,
|
||||
name = "TestName",
|
||||
message = "Test Message",
|
||||
stackTrace = "test\nstack\ntrace"
|
||||
)
|
||||
|
||||
val foundMatch = db.crashes.anyMatch(
|
||||
listOf(
|
||||
CrashConfig.CrashPattern(namePattern = "Test", stackTracePattern = "stack")
|
||||
),
|
||||
promptThreshold = currentTime
|
||||
)
|
||||
|
||||
foundMatch assertIs true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashTable_matchesNameAndMessageAndStackTracePattern() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
db.crashes.saveCrash(
|
||||
createdAt = currentTime,
|
||||
name = "TestName",
|
||||
message = "Test Message",
|
||||
stackTrace = "test\nstack\ntrace"
|
||||
)
|
||||
|
||||
val foundMatch = db.crashes.anyMatch(
|
||||
listOf(
|
||||
CrashConfig.CrashPattern(namePattern = "Test", messagePattern = "Message", stackTracePattern = "stack")
|
||||
),
|
||||
promptThreshold = currentTime
|
||||
)
|
||||
|
||||
foundMatch assertIs true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashTable_doesNotMatchNamePattern() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
db.crashes.saveCrash(
|
||||
createdAt = currentTime,
|
||||
name = "TestName",
|
||||
message = "Test Message",
|
||||
stackTrace = "test\nstack\ntrace"
|
||||
)
|
||||
|
||||
val foundMatch = db.crashes.anyMatch(
|
||||
listOf(
|
||||
CrashConfig.CrashPattern(namePattern = "Blah")
|
||||
),
|
||||
promptThreshold = currentTime
|
||||
)
|
||||
|
||||
foundMatch assertIs false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashTable_matchesNameButNotMessagePattern() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
db.crashes.saveCrash(
|
||||
createdAt = currentTime,
|
||||
name = "TestName",
|
||||
message = "Test Message",
|
||||
stackTrace = "test\nstack\ntrace"
|
||||
)
|
||||
|
||||
val foundMatch = db.crashes.anyMatch(
|
||||
listOf(
|
||||
CrashConfig.CrashPattern(namePattern = "Test", messagePattern = "Blah")
|
||||
),
|
||||
promptThreshold = currentTime
|
||||
)
|
||||
|
||||
foundMatch assertIs false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashTable_matchesNameButNotStackTracePattern() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
db.crashes.saveCrash(
|
||||
createdAt = currentTime,
|
||||
name = "TestName",
|
||||
message = "Test Message",
|
||||
stackTrace = "test\nstack\ntrace"
|
||||
)
|
||||
|
||||
val foundMatch = db.crashes.anyMatch(
|
||||
listOf(
|
||||
CrashConfig.CrashPattern(namePattern = "Test", stackTracePattern = "Blah")
|
||||
),
|
||||
promptThreshold = currentTime
|
||||
)
|
||||
|
||||
foundMatch assertIs false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashTable_matchesNamePatternButPromptedTooRecently() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
db.crashes.saveCrash(
|
||||
createdAt = currentTime,
|
||||
name = "TestName",
|
||||
message = "Test Message",
|
||||
stackTrace = "test\nstack\ntrace"
|
||||
)
|
||||
|
||||
db.writableDatabase
|
||||
.update(LogDatabase.CrashTable.TABLE_NAME)
|
||||
.values(LogDatabase.CrashTable.LAST_PROMPTED_AT to currentTime)
|
||||
.run()
|
||||
|
||||
val foundMatch = db.crashes.anyMatch(
|
||||
listOf(
|
||||
CrashConfig.CrashPattern(namePattern = "Test")
|
||||
),
|
||||
promptThreshold = currentTime - 100
|
||||
)
|
||||
|
||||
foundMatch assertIs false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashTable_noMatches() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
val foundMatch = db.crashes.anyMatch(
|
||||
listOf(
|
||||
CrashConfig.CrashPattern(namePattern = "Test")
|
||||
),
|
||||
promptThreshold = currentTime - 100
|
||||
)
|
||||
|
||||
foundMatch assertIs false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashTable_updatesLastPromptTime() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
db.crashes.saveCrash(
|
||||
createdAt = currentTime,
|
||||
name = "TestName",
|
||||
message = "Test Message",
|
||||
stackTrace = "test\nstack\ntrace"
|
||||
)
|
||||
|
||||
db.crashes.saveCrash(
|
||||
createdAt = currentTime,
|
||||
name = "XXX",
|
||||
message = "XXX",
|
||||
stackTrace = "XXX"
|
||||
)
|
||||
|
||||
db.crashes.markAsPrompted(
|
||||
listOf(
|
||||
CrashConfig.CrashPattern(namePattern = "Test")
|
||||
),
|
||||
promptedAt = currentTime
|
||||
)
|
||||
|
||||
db.writableDatabase
|
||||
.select(LogDatabase.CrashTable.NAME, LogDatabase.CrashTable.LAST_PROMPTED_AT)
|
||||
.from(LogDatabase.CrashTable.TABLE_NAME)
|
||||
.run()
|
||||
.forEach {
|
||||
if (it.requireNonNullString(LogDatabase.CrashTable.NAME) == "TestName") {
|
||||
it.requireLong(LogDatabase.CrashTable.LAST_PROMPTED_AT) assertIs currentTime
|
||||
} else {
|
||||
it.requireLong(LogDatabase.CrashTable.LAST_PROMPTED_AT) assertIs 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -302,7 +302,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
Log.blockUntilAllWritesFinished();
|
||||
LogDatabase.getInstance(this).trimToSize();
|
||||
LogDatabase.getInstance(this).logs().trimToSize();
|
||||
LogDatabase.getInstance(this).crashes().trimToSize();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@ import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSh
|
|||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor;
|
||||
import org.thoughtcrime.securesms.notifications.SlowNotificationsViewModel;
|
||||
import org.thoughtcrime.securesms.notifications.VitalsViewModel;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
|
@ -45,7 +45,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
|||
|
||||
private VoiceNoteMediaController mediaController;
|
||||
private ConversationListTabsViewModel conversationListTabsViewModel;
|
||||
private SlowNotificationsViewModel slowNotificationsViewModel;
|
||||
private VitalsViewModel vitalsViewModel;
|
||||
|
||||
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
|
||||
|
||||
|
@ -99,25 +99,27 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
|||
conversationListTabsViewModel = new ViewModelProvider(this, factory).get(ConversationListTabsViewModel.class);
|
||||
updateTabVisibility();
|
||||
|
||||
slowNotificationsViewModel = new ViewModelProvider(this).get(SlowNotificationsViewModel.class);
|
||||
vitalsViewModel = new ViewModelProvider(this).get(VitalsViewModel.class);
|
||||
|
||||
lifecycleDisposable.add(
|
||||
slowNotificationsViewModel
|
||||
.getSlowNotificationState()
|
||||
.subscribe(this::presentSlowNotificationState)
|
||||
vitalsViewModel
|
||||
.getVitalsState()
|
||||
.subscribe(this::presentVitalsState)
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private void presentSlowNotificationState(SlowNotificationsViewModel.State slowNotificationState) {
|
||||
switch (slowNotificationState) {
|
||||
private void presentVitalsState(VitalsViewModel.State state) {
|
||||
switch (state) {
|
||||
case NONE:
|
||||
break;
|
||||
case PROMPT_BATTERY_SAVER_DIALOG:
|
||||
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
|
||||
break;
|
||||
case PROMPT_DEBUGLOGS:
|
||||
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager());
|
||||
case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS:
|
||||
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager(), DebugLogsPromptDialogFragment.Purpose.NOTIFICATIONS);
|
||||
case PROMPT_DEBUGLOGS_FOR_CRASH:
|
||||
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager(), DebugLogsPromptDialogFragment.Purpose.CRASH);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -168,7 +170,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
|||
|
||||
updateTabVisibility();
|
||||
|
||||
slowNotificationsViewModel.checkSlowNotificationHeuristics();
|
||||
vitalsViewModel.checkSlowNotificationHeuristics();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -13,11 +13,12 @@ import android.view.ViewGroup
|
|||
import android.widget.Toast
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.fragment.app.viewModels
|
||||
import org.signal.core.util.ResourceUtil
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.databinding.PromptLogsBottomSheetBinding
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
@ -27,14 +28,21 @@ import org.thoughtcrime.securesms.util.SupportEmailUtil
|
|||
class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
private const val KEY_PURPOSE = "purpose"
|
||||
|
||||
@JvmStatic
|
||||
fun show(context: Context, fragmentManager: FragmentManager) {
|
||||
fun show(context: Context, fragmentManager: FragmentManager, purpose: Purpose) {
|
||||
if (NetworkUtil.isConnected(context) && fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
|
||||
DebugLogsPromptDialogFragment().apply {
|
||||
arguments = bundleOf()
|
||||
arguments = bundleOf(
|
||||
KEY_PURPOSE to purpose.serialized
|
||||
)
|
||||
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
SignalStore.uiHints().lastNotificationLogsPrompt = System.currentTimeMillis()
|
||||
|
||||
when (purpose) {
|
||||
Purpose.NOTIFICATIONS -> SignalStore.uiHints().lastNotificationLogsPrompt = System.currentTimeMillis()
|
||||
Purpose.CRASH -> SignalStore.uiHints().lastCrashPrompt = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -44,7 +52,12 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
|
|||
|
||||
private val binding by ViewBinderDelegate(PromptLogsBottomSheetBinding::bind)
|
||||
|
||||
private lateinit var viewModel: PromptLogsViewModel
|
||||
private val viewModel: PromptLogsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
val purpose = Purpose.deserialize(requireArguments().getInt(KEY_PURPOSE))
|
||||
PromptLogsViewModel.Factory(ApplicationDependencies.getApplication(), purpose)
|
||||
}
|
||||
)
|
||||
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
|
@ -55,11 +68,21 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
|
||||
viewModel = ViewModelProvider(this).get(PromptLogsViewModel::class.java)
|
||||
val purpose = Purpose.deserialize(requireArguments().getInt(KEY_PURPOSE))
|
||||
|
||||
when (purpose) {
|
||||
Purpose.NOTIFICATIONS -> {
|
||||
binding.title.setText(R.string.PromptLogsSlowNotificationsDialog__title)
|
||||
}
|
||||
Purpose.CRASH -> {
|
||||
binding.title.setText(R.string.PromptLogsSlowNotificationsDialog__title_crash)
|
||||
}
|
||||
}
|
||||
|
||||
binding.submit.setOnClickListener {
|
||||
val progressDialog = SignalProgressDialog.show(requireContext())
|
||||
disposables += viewModel.submitLogs().subscribe({ result ->
|
||||
submitLogs(result)
|
||||
submitLogs(result, purpose)
|
||||
progressDialog.dismiss()
|
||||
dismiss()
|
||||
}, { _ ->
|
||||
|
@ -68,30 +91,40 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
|
|||
dismiss()
|
||||
})
|
||||
}
|
||||
|
||||
binding.decline.setOnClickListener {
|
||||
if (purpose == Purpose.NOTIFICATIONS) {
|
||||
SignalStore.uiHints().markDeclinedShareNotificationLogs()
|
||||
}
|
||||
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private fun submitLogs(debugLog: String) {
|
||||
private fun submitLogs(debugLog: String, purpose: Purpose) {
|
||||
CommunicationActions.openEmail(
|
||||
requireContext(),
|
||||
SupportEmailUtil.getSupportEmailAddress(requireContext()),
|
||||
getString(R.string.DebugLogsPromptDialogFragment__signal_android_support_request),
|
||||
getEmailBody(debugLog)
|
||||
getEmailBody(debugLog, purpose)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getEmailBody(debugLog: String?): String {
|
||||
private fun getEmailBody(debugLog: String?, purpose: Purpose): String {
|
||||
val suffix = StringBuilder()
|
||||
|
||||
if (debugLog != null) {
|
||||
suffix.append("\n")
|
||||
suffix.append(getString(R.string.HelpFragment__debug_log))
|
||||
suffix.append(" ")
|
||||
suffix.append(debugLog)
|
||||
}
|
||||
val category = ResourceUtil.getEnglishResources(requireContext()).getString(R.string.DebugLogsPromptDialogFragment__slow_notifications_category)
|
||||
|
||||
val category = when (purpose) {
|
||||
Purpose.NOTIFICATIONS -> ResourceUtil.getEnglishResources(requireContext()).getString(R.string.DebugLogsPromptDialogFragment__slow_notifications_category)
|
||||
Purpose.CRASH -> ResourceUtil.getEnglishResources(requireContext()).getString(R.string.DebugLogsPromptDialogFragment__crash_category)
|
||||
}
|
||||
|
||||
return SupportEmailUtil.generateSupportEmailBody(
|
||||
requireContext(),
|
||||
R.string.DebugLogsPromptDialogFragment__signal_android_support_request,
|
||||
|
@ -100,4 +133,21 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
|
|||
suffix.toString()
|
||||
)
|
||||
}
|
||||
|
||||
enum class Purpose(val serialized: Int) {
|
||||
|
||||
NOTIFICATIONS(1), CRASH(2);
|
||||
|
||||
companion object {
|
||||
fun deserialize(serialized: Int): Purpose {
|
||||
for (value in values()) {
|
||||
if (value.serialized == serialized) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
throw IllegalArgumentException("Invalid value: $serialized")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,17 +5,37 @@
|
|||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.subjects.SingleSubject
|
||||
import org.thoughtcrime.securesms.crash.CrashConfig
|
||||
import org.thoughtcrime.securesms.database.LogDatabase
|
||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
|
||||
|
||||
class PromptLogsViewModel : ViewModel() {
|
||||
class PromptLogsViewModel(private val context: Application, purpose: DebugLogsPromptDialogFragment.Purpose) : AndroidViewModel(context) {
|
||||
|
||||
private val submitDebugLogRepository = SubmitDebugLogRepository()
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
if (purpose == DebugLogsPromptDialogFragment.Purpose.CRASH) {
|
||||
disposables += Single
|
||||
.fromCallable {
|
||||
LogDatabase.getInstance(context).crashes.markAsPrompted(CrashConfig.patterns, System.currentTimeMillis())
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
fun submitLogs(): Single<String> {
|
||||
val singleSubject = SingleSubject.create<String?>()
|
||||
submitDebugLogRepository.buildAndSubmitLog { result ->
|
||||
|
@ -28,4 +48,14 @@ class PromptLogsViewModel : ViewModel() {
|
|||
|
||||
return singleSubject.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
class Factory(private val context: Application, private val purpose: DebugLogsPromptDialogFragment.Purpose) : ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(PromptLogsViewModel(context, purpose))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -768,7 +768,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
|||
|
||||
private fun clearKeepLongerLogs() {
|
||||
SimpleTask.run({
|
||||
LogDatabase.getInstance(requireActivity().application).clearKeepLonger()
|
||||
LogDatabase.getInstance(requireActivity().application).logs.clearKeepLonger()
|
||||
}) {
|
||||
Toast.makeText(requireContext(), "Cleared keep longer logs", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.crash
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BucketingUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.io.IOException
|
||||
|
||||
object CrashConfig {
|
||||
|
||||
private val TAG = Log.tag(CrashConfig::class.java)
|
||||
|
||||
/**
|
||||
* A list of patterns for crashes we'd like to find matches for in the crash database.
|
||||
*/
|
||||
val patterns: List<CrashPattern> by lazy { computePatterns() }
|
||||
|
||||
@VisibleForTesting
|
||||
fun computePatterns(): List<CrashPattern> {
|
||||
val aci: ServiceId.ACI = SignalStore.account().aci ?: return emptyList()
|
||||
|
||||
val serialized = FeatureFlags.crashPromptConfig()
|
||||
if (serialized.isNullOrBlank()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
if (SignalStore.account().aci == null) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val list: List<Config> = try {
|
||||
JsonUtils.fromJsonArray(serialized, Config::class.java)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to parse json!", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return list
|
||||
.asSequence()
|
||||
.filter { it.rolledOutToLocalUser(aci) }
|
||||
.map {
|
||||
if (it.name?.isBlank() == true) {
|
||||
it.copy(name = null)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.map {
|
||||
if (it.message ?.isBlank() == true) {
|
||||
it.copy(message = null)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.map {
|
||||
if (it.stackTrace ?.isBlank() == true) {
|
||||
it.copy(stackTrace = null)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.filter { it.name != null || it.message != null || it.stackTrace != null }
|
||||
.map {
|
||||
CrashPattern(
|
||||
namePattern = it.name,
|
||||
messagePattern = it.message,
|
||||
stackTracePattern = it.stackTrace
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a pattern for a crash we're interested in prompting the user about. In this context, "pattern" means
|
||||
* a case-sensitive substring of a larger string. So "IllegalArgument" would match "IllegalArgumentException".
|
||||
* Not a regex or anything.
|
||||
*
|
||||
* One of the fields is guaranteed to be set.
|
||||
*
|
||||
* @param namePattern A possible substring of an exception name we're looking for in the crash table.
|
||||
* @param messagePattern A possible substring of an exception message we're looking for in the crash table.
|
||||
*/
|
||||
data class CrashPattern(
|
||||
val namePattern: String? = null,
|
||||
val messagePattern: String? = null,
|
||||
val stackTracePattern: String? = null
|
||||
) {
|
||||
init {
|
||||
check(namePattern != null || messagePattern != null || stackTracePattern != null)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Config(
|
||||
@JsonProperty val name: String?,
|
||||
@JsonProperty val message: String?,
|
||||
@JsonProperty val stackTrace: String?,
|
||||
@JsonProperty val percent: Float?
|
||||
) {
|
||||
|
||||
/** True if the local user is contained within the percent rollout, otherwise false. */
|
||||
fun rolledOutToLocalUser(aci: ServiceId.ACI): Boolean {
|
||||
if (percent == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
val partsPerMillion = (1_000_000 * percent).toInt()
|
||||
val bucket = BucketingUtil.bucket(FeatureFlags.CRASH_PROMPT_CONFIG, aci.rawUuid, 1_000_000)
|
||||
return partsPerMillion > bucket
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.database
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import net.zetetic.database.sqlcipher.SQLiteOpenHelper
|
||||
|
@ -10,15 +9,24 @@ import org.signal.core.util.CursorUtil
|
|||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.exists
|
||||
import org.signal.core.util.getTableRowCount
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.mebiBytes
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.crash.CrashConfig
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecret
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
|
||||
import org.thoughtcrime.securesms.database.model.LogEntry
|
||||
import org.thoughtcrime.securesms.util.ByteUnit
|
||||
import java.io.Closeable
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* Stores logs.
|
||||
|
@ -48,35 +56,9 @@ class LogDatabase private constructor(
|
|||
companion object {
|
||||
private val TAG = Log.tag(LogDatabase::class.java)
|
||||
|
||||
private val MAX_FILE_SIZE = ByteUnit.MEGABYTES.toBytes(20)
|
||||
private val DEFAULT_LIFESPAN = TimeUnit.DAYS.toMillis(3)
|
||||
private val LONGER_LIFESPAN = TimeUnit.DAYS.toMillis(21)
|
||||
|
||||
private const val DATABASE_VERSION = 2
|
||||
private const val DATABASE_VERSION = 3
|
||||
private const val DATABASE_NAME = "signal-logs.db"
|
||||
|
||||
private const val TABLE_NAME = "log"
|
||||
private const val ID = "_id"
|
||||
private const val CREATED_AT = "created_at"
|
||||
private const val KEEP_LONGER = "keep_longer"
|
||||
private const val BODY = "body"
|
||||
private const val SIZE = "size"
|
||||
|
||||
private val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY,
|
||||
$CREATED_AT INTEGER,
|
||||
$KEEP_LONGER INTEGER DEFAULT 0,
|
||||
$BODY TEXT,
|
||||
$SIZE INTEGER
|
||||
)
|
||||
"""
|
||||
|
||||
private val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX keep_longer_index ON $TABLE_NAME ($KEEP_LONGER)",
|
||||
"CREATE INDEX log_created_at_keep_longer_index ON $TABLE_NAME ($CREATED_AT, $KEEP_LONGER)"
|
||||
)
|
||||
|
||||
@SuppressLint("StaticFieldLeak") // We hold an Application context, not a view context
|
||||
@Volatile
|
||||
private var instance: LogDatabase? = null
|
||||
|
@ -95,10 +77,20 @@ class LogDatabase private constructor(
|
|||
}
|
||||
}
|
||||
|
||||
@get:JvmName("logs")
|
||||
val logs: LogTable by lazy { LogTable(this) }
|
||||
|
||||
@get:JvmName("crashes")
|
||||
val crashes: CrashTable by lazy { CrashTable(this) }
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
Log.i(TAG, "onCreate()")
|
||||
db.execSQL(CREATE_TABLE)
|
||||
CREATE_INDEXES.forEach { db.execSQL(it) }
|
||||
|
||||
db.execSQL(LogTable.CREATE_TABLE)
|
||||
db.execSQL(CrashTable.CREATE_TABLE)
|
||||
|
||||
LogTable.CREATE_INDEXES.forEach { db.execSQL(it) }
|
||||
CrashTable.CREATE_INDEXES.forEach { db.execSQL(it) }
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
|
@ -110,6 +102,12 @@ class LogDatabase private constructor(
|
|||
db.execSQL("CREATE INDEX keep_longer_index ON log (keep_longer)")
|
||||
db.execSQL("CREATE INDEX log_created_at_keep_longer_index ON log (created_at, keep_longer)")
|
||||
}
|
||||
|
||||
if (oldVersion < 3) {
|
||||
db.execSQL("CREATE TABLE crash (_id INTEGER PRIMARY KEY, created_at INTEGER, name TEXT, message TEXT, stack_trace TEXT, last_prompted_at INTEGER)")
|
||||
db.execSQL("CREATE INDEX crash_created_at ON crash (created_at)")
|
||||
db.execSQL("CREATE INDEX crash_name_message ON crash (name, message)")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpen(db: SQLiteDatabase) {
|
||||
|
@ -120,41 +118,74 @@ class LogDatabase private constructor(
|
|||
return writableDatabase
|
||||
}
|
||||
|
||||
fun insert(logs: List<LogEntry>, currentTime: Long) {
|
||||
val db = writableDatabase
|
||||
class LogTable(private val openHelper: LogDatabase) {
|
||||
companion object {
|
||||
const val TABLE_NAME = "log"
|
||||
const val ID = "_id"
|
||||
const val CREATED_AT = "created_at"
|
||||
const val KEEP_LONGER = "keep_longer"
|
||||
const val BODY = "body"
|
||||
const val SIZE = "size"
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
logs.forEach { log ->
|
||||
db.insert(TABLE_NAME, null, buildValues(log))
|
||||
}
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY,
|
||||
$CREATED_AT INTEGER,
|
||||
$KEEP_LONGER INTEGER DEFAULT 0,
|
||||
$BODY TEXT,
|
||||
$SIZE INTEGER
|
||||
)
|
||||
"""
|
||||
|
||||
db.delete(
|
||||
TABLE_NAME,
|
||||
"($CREATED_AT < ? AND $KEEP_LONGER = ?) OR ($CREATED_AT < ? AND $KEEP_LONGER = ?)",
|
||||
SqlUtil.buildArgs(currentTime - DEFAULT_LIFESPAN, 0, currentTime - LONGER_LIFESPAN, 1)
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX keep_longer_index ON $TABLE_NAME ($KEEP_LONGER)",
|
||||
"CREATE INDEX log_created_at_keep_longer_index ON $TABLE_NAME ($CREATED_AT, $KEEP_LONGER)"
|
||||
)
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
val MAX_FILE_SIZE = 20L.mebiBytes.inWholeBytes
|
||||
val DEFAULT_LIFESPAN = 3.days.inWholeMilliseconds
|
||||
val LONGER_LIFESPAN = 21.days.inWholeMilliseconds
|
||||
}
|
||||
|
||||
private val readableDatabase: SQLiteDatabase get() = openHelper.readableDatabase
|
||||
private val writableDatabase: SQLiteDatabase get() = openHelper.writableDatabase
|
||||
|
||||
fun insert(logs: List<LogEntry>, currentTime: Long) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
logs.forEach { log ->
|
||||
db.insertInto(TABLE_NAME)
|
||||
.values(
|
||||
CREATED_AT to log.createdAt,
|
||||
KEEP_LONGER to if (log.keepLonger) 1 else 0,
|
||||
BODY to log.body,
|
||||
SIZE to log.body.length
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
db.delete(TABLE_NAME)
|
||||
.where("($CREATED_AT < ? AND $KEEP_LONGER = 0) OR ($CREATED_AT < ? AND $KEEP_LONGER = 1)", currentTime - DEFAULT_LIFESPAN, currentTime - LONGER_LIFESPAN)
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllBeforeTime(time: Long): Reader {
|
||||
return CursorReader(readableDatabase.query(TABLE_NAME, arrayOf(BODY), "$CREATED_AT < ?", SqlUtil.buildArgs(time), null, null, null))
|
||||
return readableDatabase
|
||||
.select(BODY)
|
||||
.from(TABLE_NAME)
|
||||
.where("$CREATED_AT < $time")
|
||||
.run()
|
||||
.toReader()
|
||||
}
|
||||
|
||||
fun getRangeBeforeTime(start: Int, length: Int, time: Long): List<String> {
|
||||
val lines = mutableListOf<String>()
|
||||
|
||||
readableDatabase.query(TABLE_NAME, arrayOf(BODY), "$CREATED_AT < ?", SqlUtil.buildArgs(time), null, null, null, "$start,$length").use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
lines.add(CursorUtil.requireString(cursor, BODY))
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
return readableDatabase
|
||||
.select(BODY)
|
||||
.from(TABLE_NAME)
|
||||
.where("$CREATED_AT < $time")
|
||||
.limit(limit = length, offset = start)
|
||||
.run()
|
||||
.readToList { it.requireNonNullString(BODY) }
|
||||
}
|
||||
|
||||
fun trimToSize() {
|
||||
|
@ -172,7 +203,9 @@ class LogDatabase private constructor(
|
|||
val logCount = readableDatabase.getTableRowCount(TABLE_NAME)
|
||||
writableDatabase.execSQL("DELETE FROM $TABLE_NAME WHERE $ID < (SELECT MAX($ID) FROM (SELECT $ID FROM $TABLE_NAME LIMIT ${logCount / 2}))")
|
||||
} else {
|
||||
writableDatabase.delete(TABLE_NAME, "$KEEP_LONGER = ?", arrayOf("0"))
|
||||
writableDatabase
|
||||
.delete(TABLE_NAME)
|
||||
.where("$KEEP_LONGER = 0")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -210,30 +243,21 @@ class LogDatabase private constructor(
|
|||
}
|
||||
|
||||
fun getLogCountBeforeTime(time: Long): Int {
|
||||
readableDatabase.query(TABLE_NAME, arrayOf("COUNT(*)"), "$CREATED_AT < ?", SqlUtil.buildArgs(time), null, null, null).use { cursor ->
|
||||
return if (cursor.moveToFirst()) {
|
||||
cursor.getInt(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
return readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(TABLE_NAME)
|
||||
.where("$CREATED_AT < $time")
|
||||
.run()
|
||||
.readToSingleInt()
|
||||
}
|
||||
|
||||
fun clearKeepLonger() {
|
||||
writableDatabase.delete(TABLE_NAME)
|
||||
.where("$KEEP_LONGER = ?", 1)
|
||||
writableDatabase
|
||||
.delete(TABLE_NAME)
|
||||
.where("$KEEP_LONGER = 1")
|
||||
.run()
|
||||
}
|
||||
|
||||
private fun buildValues(log: LogEntry): ContentValues {
|
||||
return ContentValues().apply {
|
||||
put(CREATED_AT, log.createdAt)
|
||||
put(KEEP_LONGER, if (log.keepLonger) 1 else 0)
|
||||
put(BODY, log.body)
|
||||
put(SIZE, log.body.length)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSize(query: String?, args: Array<String>?): Long {
|
||||
readableDatabase.query(TABLE_NAME, arrayOf("SUM($SIZE)"), query, args, null, null, null).use { cursor ->
|
||||
return if (cursor.moveToFirst()) {
|
||||
|
@ -244,6 +268,10 @@ class LogDatabase private constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun Cursor.toReader(): CursorReader {
|
||||
return CursorReader(this)
|
||||
}
|
||||
|
||||
interface Reader : Iterator<String>, Closeable
|
||||
|
||||
class CursorReader(private val cursor: Cursor) : Reader {
|
||||
|
@ -253,7 +281,7 @@ class LogDatabase private constructor(
|
|||
|
||||
override fun next(): String {
|
||||
cursor.moveToNext()
|
||||
return CursorUtil.requireString(cursor, BODY)
|
||||
return CursorUtil.requireString(cursor, LogTable.BODY)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
|
@ -261,3 +289,130 @@ class LogDatabase private constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CrashTable(private val openHelper: LogDatabase) {
|
||||
companion object {
|
||||
const val TABLE_NAME = "crash"
|
||||
const val ID = "_id"
|
||||
const val CREATED_AT = "created_at"
|
||||
const val NAME = "name"
|
||||
const val MESSAGE = "message"
|
||||
const val STACK_TRACE = "stack_trace"
|
||||
const val LAST_PROMPTED_AT = "last_prompted_at"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY,
|
||||
$CREATED_AT INTEGER,
|
||||
$NAME TEXT,
|
||||
$MESSAGE TEXT,
|
||||
$STACK_TRACE TEXT,
|
||||
$LAST_PROMPTED_AT INTEGER
|
||||
)
|
||||
"""
|
||||
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX crash_created_at ON $TABLE_NAME ($CREATED_AT)",
|
||||
"CREATE INDEX crash_name_message ON $TABLE_NAME ($NAME, $MESSAGE)"
|
||||
)
|
||||
}
|
||||
|
||||
private val readableDatabase: SQLiteDatabase get() = openHelper.readableDatabase
|
||||
private val writableDatabase: SQLiteDatabase get() = openHelper.writableDatabase
|
||||
|
||||
fun saveCrash(createdAt: Long, name: String, message: String?, stackTrace: String) {
|
||||
writableDatabase
|
||||
.insertInto(TABLE_NAME)
|
||||
.values(
|
||||
CREATED_AT to createdAt,
|
||||
NAME to name,
|
||||
MESSAGE to message,
|
||||
STACK_TRACE to stackTrace,
|
||||
LAST_PROMPTED_AT to 0
|
||||
)
|
||||
.run()
|
||||
|
||||
trimToSize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if crashes exists that
|
||||
* (1) match any of the provided crash patterns
|
||||
* (2) have not been prompted within the [promptThreshold]
|
||||
*/
|
||||
fun anyMatch(patterns: Collection<CrashConfig.CrashPattern>, promptThreshold: Long): Boolean {
|
||||
for (pattern in patterns) {
|
||||
val (query, args) = pattern.asLikeQuery()
|
||||
|
||||
val found = readableDatabase
|
||||
.exists(TABLE_NAME)
|
||||
.where("$query AND $LAST_PROMPTED_AT < $promptThreshold", args)
|
||||
.run()
|
||||
|
||||
if (found) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all crashes that match any of the provided patterns as being prompted at the provided [promptedAt] time.
|
||||
*/
|
||||
fun markAsPrompted(patterns: Collection<CrashConfig.CrashPattern>, promptedAt: Long) {
|
||||
for (pattern in patterns) {
|
||||
val (query, args) = pattern.asLikeQuery()
|
||||
|
||||
readableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(LAST_PROMPTED_AT to promptedAt)
|
||||
.where(query, args)
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
fun trimToSize() {
|
||||
// Delete crashes older than 30 days
|
||||
val threshold = System.currentTimeMillis() - 30.days.inWholeMilliseconds
|
||||
writableDatabase
|
||||
.delete(TABLE_NAME)
|
||||
.where("$CREATED_AT < $threshold")
|
||||
.run()
|
||||
|
||||
// Only keep 100 most recent crashes to prevent crash loops from filling up the disk
|
||||
writableDatabase
|
||||
.delete(TABLE_NAME)
|
||||
.where("$ID NOT IN (SELECT $ID FROM $TABLE_NAME ORDER BY $CREATED_AT DESC LIMIT 100)")
|
||||
.run()
|
||||
}
|
||||
|
||||
private fun CrashConfig.CrashPattern.asLikeQuery(): Pair<String, Array<String>> {
|
||||
val query = StringBuilder()
|
||||
var args = arrayOf<String>()
|
||||
|
||||
if (namePattern != null) {
|
||||
query.append("$NAME LIKE ?")
|
||||
args += "%$namePattern%"
|
||||
}
|
||||
|
||||
if (messagePattern != null) {
|
||||
if (query.isNotEmpty()) {
|
||||
query.append(" AND ")
|
||||
}
|
||||
query.append("$MESSAGE LIKE ?")
|
||||
args += "%$messagePattern%"
|
||||
}
|
||||
|
||||
if (stackTracePattern != null) {
|
||||
if (query.isNotEmpty()) {
|
||||
query.append(" AND ")
|
||||
}
|
||||
query.append("$STACK_TRACE LIKE ?")
|
||||
args += "%$stackTracePattern%"
|
||||
}
|
||||
|
||||
return query.toString() to args
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ public class UiHints extends SignalStoreValues {
|
|||
private static final String LAST_NOTIFICATION_LOGS_PROMPT_TIME = "uihints.last_notification_logs_prompt";
|
||||
private static final String DISMISSED_BATTERY_SAVER_PROMPT = "uihints.declined_battery_saver_prompt";
|
||||
private static final String LAST_BATTERY_SAVER_PROMPT = "uihints.last_battery_saver_prompt";
|
||||
private static final String LAST_CRASH_PROMPT = "uihints.last_crash_prompt";
|
||||
|
||||
UiHints(@NonNull KeyValueStore store) {
|
||||
super(store);
|
||||
|
@ -154,4 +155,12 @@ public class UiHints extends SignalStoreValues {
|
|||
public void setLastBatterySaverPrompt(long time) {
|
||||
putLong(LAST_BATTERY_SAVER_PROMPT, time);
|
||||
}
|
||||
|
||||
public void setLastCrashPrompt(long time) {
|
||||
putLong(LAST_CRASH_PROMPT, time);
|
||||
}
|
||||
|
||||
public long getLastCrashPrompt() {
|
||||
return getLong(LAST_CRASH_PROMPT, 0);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -112,7 +112,7 @@ class PersistentLogger(
|
|||
override fun run() {
|
||||
while (true) {
|
||||
requests.blockForRequests(buffer)
|
||||
db.insert(buffer.flatMap { requestToEntries(it) }, System.currentTimeMillis())
|
||||
db.logs.insert(buffer.flatMap { requestToEntries(it) }, System.currentTimeMillis())
|
||||
buffer.clear()
|
||||
requests.notifyFlushed()
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ class LogDataSource(
|
|||
val logDatabase = LogDatabase.getInstance(application)
|
||||
|
||||
override fun size(): Int {
|
||||
return prefixLines.size + logDatabase.getLogCountBeforeTime(untilTime)
|
||||
return prefixLines.size + logDatabase.logs.getLogCountBeforeTime(untilTime)
|
||||
}
|
||||
|
||||
override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): List<LogLine> {
|
||||
|
@ -29,9 +29,9 @@ class LogDataSource(
|
|||
return prefixLines.subList(start, start + length)
|
||||
} else if (start < prefixLines.size) {
|
||||
return prefixLines.subList(start, prefixLines.size) +
|
||||
logDatabase.getRangeBeforeTime(0, length - (prefixLines.size - start), untilTime).map { convertToLogLine(it) }
|
||||
logDatabase.logs.getRangeBeforeTime(0, length - (prefixLines.size - start), untilTime).map { convertToLogLine(it) }
|
||||
} else {
|
||||
return logDatabase.getRangeBeforeTime(start - prefixLines.size, length, untilTime).map { convertToLogLine(it) }
|
||||
return logDatabase.logs.getRangeBeforeTime(start - prefixLines.size, length, untilTime).map { convertToLogLine(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -116,7 +116,7 @@ public class SubmitDebugLogRepository {
|
|||
public void buildAndSubmitLog(@NonNull Callback<Optional<String>> callback) {
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
Log.blockUntilAllWritesFinished();
|
||||
LogDatabase.getInstance(context).trimToSize();
|
||||
LogDatabase.getInstance(context).logs().trimToSize();
|
||||
callback.onResult(submitLogInternal(System.currentTimeMillis(), getPrefixLogLinesInternal(), Tracer.getInstance().serialize()));
|
||||
});
|
||||
}
|
||||
|
@ -140,7 +140,7 @@ public class SubmitDebugLogRepository {
|
|||
outputStream.putNextEntry(new ZipEntry("log.txt"));
|
||||
outputStream.write(prefixLines.toString().getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
try (LogDatabase.Reader reader = LogDatabase.getInstance(context).getAllBeforeTime(untilTime)) {
|
||||
try (LogDatabase.LogTable.Reader reader = LogDatabase.getInstance(context).logs().getAllBeforeTime(untilTime)) {
|
||||
while (reader.hasNext()) {
|
||||
outputStream.write(reader.next().getBytes());
|
||||
outputStream.write("\n".getBytes());
|
||||
|
@ -193,7 +193,7 @@ public class SubmitDebugLogRepository {
|
|||
|
||||
stopwatch.split("front-matter");
|
||||
|
||||
try (LogDatabase.Reader reader = LogDatabase.getInstance(context).getAllBeforeTime(untilTime)) {
|
||||
try (LogDatabase.LogTable.Reader reader = LogDatabase.getInstance(context).logs().getAllBeforeTime(untilTime)) {
|
||||
while (reader.hasNext()) {
|
||||
gzipOutput.write(reader.next().getBytes());
|
||||
gzipOutput.write("\n".getBytes());
|
||||
|
|
|
@ -54,7 +54,7 @@ public class SubmitDebugLogViewModel extends ViewModel {
|
|||
this.staticLines.addAll(staticLines);
|
||||
|
||||
Log.blockUntilAllWritesFinished();
|
||||
LogDatabase.getInstance(ApplicationDependencies.getApplication()).trimToSize();
|
||||
LogDatabase.getInstance(ApplicationDependencies.getApplication()).logs().trimToSize();
|
||||
|
||||
LogDataSource dataSource = new LogDataSource(ApplicationDependencies.getApplication(), staticLines, firstViewTime);
|
||||
PagingConfig config = new PagingConfig.Builder().setPageSize(100)
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.notifications
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.thoughtcrime.securesms.crash.CrashConfig
|
||||
import org.thoughtcrime.securesms.database.LogDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* View model for checking for various app vitals, like slow notifications and crashes.
|
||||
*/
|
||||
class VitalsViewModel(private val context: Application) : AndroidViewModel(context) {
|
||||
|
||||
private val checkSubject = BehaviorSubject.create<Unit>()
|
||||
|
||||
val vitalsState: Observable<State>
|
||||
|
||||
init {
|
||||
vitalsState = checkSubject
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.io())
|
||||
.throttleFirst(1, TimeUnit.MINUTES)
|
||||
.switchMapSingle {
|
||||
checkHeuristics()
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun checkSlowNotificationHeuristics() {
|
||||
checkSubject.onNext(Unit)
|
||||
}
|
||||
|
||||
private fun checkHeuristics(): Single<State> {
|
||||
return Single.fromCallable {
|
||||
var state = State.NONE
|
||||
if (SlowNotificationHeuristics.isHavingDelayedNotifications()) {
|
||||
if (SlowNotificationHeuristics.isPotentiallyCausedByBatteryOptimizations() && Build.VERSION.SDK_INT >= 23) {
|
||||
if (SlowNotificationHeuristics.shouldPromptBatterySaver()) {
|
||||
state = State.PROMPT_BATTERY_SAVER_DIALOG
|
||||
}
|
||||
} else if (SlowNotificationHeuristics.shouldPromptUserForLogs()) {
|
||||
state = State.PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS
|
||||
}
|
||||
} else if (LogDatabase.getInstance(context).crashes.anyMatch(patterns = CrashConfig.patterns, promptThreshold = System.currentTimeMillis() - 14.days.inWholeMilliseconds)) {
|
||||
val timeSinceLastPrompt = System.currentTimeMillis() - SignalStore.uiHints().lastCrashPrompt
|
||||
|
||||
if (timeSinceLastPrompt > 1.days.inWholeMilliseconds) {
|
||||
state = State.PROMPT_DEBUGLOGS_FOR_CRASH
|
||||
}
|
||||
}
|
||||
|
||||
return@fromCallable state
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
enum class State {
|
||||
NONE,
|
||||
PROMPT_BATTERY_SAVER_DIALOG,
|
||||
PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS,
|
||||
PROMPT_DEBUGLOGS_FOR_CRASH
|
||||
}
|
||||
}
|
|
@ -115,6 +115,7 @@ public final class FeatureFlags {
|
|||
public static final String USERNAMES = "android.usernames";
|
||||
public static final String INSTANT_VIDEO_PLAYBACK = "android.instantVideoPlayback";
|
||||
private static final String CONVERSATION_ITEM_V2_TEXT = "android.conversationItemV2.text.2";
|
||||
public static final String CRASH_PROMPT_CONFIG = "android.crashPromptConfig";
|
||||
|
||||
/**
|
||||
* We will only store remote values for flags in this set. If you want a flag to be controllable
|
||||
|
@ -181,7 +182,8 @@ public final class FeatureFlags {
|
|||
PROMPT_BATTERY_SAVER,
|
||||
USERNAMES,
|
||||
INSTANT_VIDEO_PLAYBACK,
|
||||
CONVERSATION_ITEM_V2_TEXT
|
||||
CONVERSATION_ITEM_V2_TEXT,
|
||||
CRASH_PROMPT_CONFIG
|
||||
);
|
||||
|
||||
@VisibleForTesting
|
||||
|
@ -252,7 +254,8 @@ public final class FeatureFlags {
|
|||
PROMPT_FOR_NOTIFICATION_LOGS,
|
||||
PROMPT_FOR_NOTIFICATION_CONFIG,
|
||||
PROMPT_BATTERY_SAVER,
|
||||
USERNAMES
|
||||
USERNAMES,
|
||||
CRASH_PROMPT_CONFIG
|
||||
);
|
||||
|
||||
/**
|
||||
|
@ -662,6 +665,11 @@ public final class FeatureFlags {
|
|||
return getString(PROMPT_BATTERY_SAVER, "*");
|
||||
}
|
||||
|
||||
/** Config object for what crashes to prompt about. */
|
||||
public static String crashPromptConfig() {
|
||||
return getString(CRASH_PROMPT_CONFIG, "");
|
||||
}
|
||||
|
||||
|
||||
/** Only for rendering debug info. */
|
||||
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
||||
|
|
|
@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.util;
|
|||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.type.TypeFactory;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
@ -10,6 +11,7 @@ import org.json.JSONObject;
|
|||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.Reader;
|
||||
import java.util.List;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
|
@ -40,6 +42,11 @@ public class JsonUtils {
|
|||
return objectMapper.readValue(serialized, clazz);
|
||||
}
|
||||
|
||||
public static <T> List<T> fromJsonArray(String serialized, Class<T> clazz) throws IOException {
|
||||
TypeFactory typeFactory = objectMapper.getTypeFactory();
|
||||
return objectMapper.readValue(serialized, typeFactory.constructCollectionType(List.class, clazz));
|
||||
}
|
||||
|
||||
public static String toJson(Object object) throws IOException {
|
||||
return objectMapper.writeValueAsString(object);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import androidx.annotation.NonNull;
|
|||
|
||||
import org.signal.core.util.ExceptionUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.LogDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
|
||||
|
@ -39,7 +40,13 @@ public class SignalUncaughtExceptionHandler implements Thread.UncaughtExceptionH
|
|||
e = e.getCause();
|
||||
}
|
||||
|
||||
String exceptionName = e.getClass().getCanonicalName();
|
||||
if (exceptionName == null) {
|
||||
exceptionName = e.getClass().getName();
|
||||
}
|
||||
|
||||
Log.e(TAG, "", e, true);
|
||||
LogDatabase.getInstance(ApplicationDependencies.getApplication()).crashes().saveCrash(System.currentTimeMillis(), exceptionName, e.getMessage(), ExceptionUtil.convertThrowableToString(e));
|
||||
SignalStore.blockUntilAllWritesFinished();
|
||||
Log.blockUntilAllWritesFinished();
|
||||
ApplicationDependencies.getJobManager().flush();
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
android:layout_gravity="center_horizontal"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
style="@style/Signal.Text.TitleLarge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -870,8 +870,10 @@
|
|||
|
||||
<!-- Title for dialog asking user to submit logs for debugging slow notification issues -->
|
||||
<string name="PromptLogsSlowNotificationsDialog__title">We noticed notifications are delayed. Submit debug log?</string>
|
||||
<!-- Message for dialog asking user to submit logs for debugging slow notification issues -->
|
||||
<!-- Message for dialog asking user to submit logs for debugging a crash -->
|
||||
<string name="PromptLogsSlowNotificationsDialog__message">Debug logs helps us diagnose and fix the issue, and do not contain identifying information.</string>
|
||||
<!-- Title for dialog asking user to submit logs for debugging slow notification issues -->
|
||||
<string name="PromptLogsSlowNotificationsDialog__title_crash">Signal encountered a problem. Submit debug log?</string>
|
||||
|
||||
<!-- Title for dialog asking user to submit logs for debugging slow notification issues -->
|
||||
<string name="PromptBatterySaverBottomSheet__title">Notifications may be delayed due to battery optimizations</string>
|
||||
|
@ -2792,6 +2794,8 @@
|
|||
<string name="DebugLogsPromptDialogFragment__signal_android_support_request">Signal Android Debug Log Submission</string>
|
||||
<!-- Category to organize the support email sent -->
|
||||
<string name="DebugLogsPromptDialogFragment__slow_notifications_category">Slow notifications</string>
|
||||
<!-- Category to organize the support email sent -->
|
||||
<string name="DebugLogsPromptDialogFragment__crash_category">Crash</string>
|
||||
<!-- Action to submit logs and take user to send an e-mail -->
|
||||
<string name="DebugLogsPromptDialogFragment__submit">Submit</string>
|
||||
<!-- Action to decline to submit logs -->
|
||||
|
|
|
@ -59,7 +59,10 @@ class SpinnerApplicationContext : ApplicationContext() {
|
|||
"keyvalue" to DatabaseConfig(db = { KeyValueDatabase.getInstance(this).sqlCipherDatabase }),
|
||||
"megaphones" to DatabaseConfig(db = { MegaphoneDatabase.getInstance(this).sqlCipherDatabase }),
|
||||
"localmetrics" to DatabaseConfig(db = { LocalMetricsDatabase.getInstance(this).sqlCipherDatabase }),
|
||||
"logs" to DatabaseConfig(db = { LogDatabase.getInstance(this).sqlCipherDatabase })
|
||||
"logs" to DatabaseConfig(
|
||||
db = { LogDatabase.getInstance(this).sqlCipherDatabase },
|
||||
columnTransformers = listOf(TimestampTransformer)
|
||||
)
|
||||
),
|
||||
linkedMapOf(
|
||||
StorageServicePlugin.PATH to StorageServicePlugin()
|
||||
|
|
|
@ -11,7 +11,8 @@ import java.time.LocalDateTime
|
|||
object TimestampTransformer : ColumnTransformer {
|
||||
override fun matches(tableName: String?, columnName: String): Boolean {
|
||||
return columnName.contains("date", true) ||
|
||||
columnName.contains("timestamp", true)
|
||||
columnName.contains("timestamp", true) ||
|
||||
columnName.contains("created_at", true)
|
||||
}
|
||||
|
||||
override fun transform(tableName: String?, columnName: String, cursor: Cursor): String? {
|
||||
|
|
|
@ -0,0 +1,145 @@
|
|||
package org.thoughtcrime.securesms.crash
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.Mock
|
||||
import org.mockito.MockedStatic
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.junit.MockitoJUnit
|
||||
import org.mockito.junit.MockitoRule
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.thoughtcrime.securesms.assertIs
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.MockApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.keyvalue.AccountValues
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
|
||||
import org.thoughtcrime.securesms.keyvalue.MockKeyValuePersistentStorage
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, application = Application::class)
|
||||
class CrashConfigTest {
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val mockitoRule: MockitoRule = MockitoJUnit.rule()
|
||||
|
||||
@Mock
|
||||
private lateinit var featureFlags: MockedStatic<FeatureFlags>
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
if (!ApplicationDependencies.isInitialized()) {
|
||||
ApplicationDependencies.init(ApplicationProvider.getApplicationContext(), MockApplicationDependencyProvider())
|
||||
}
|
||||
|
||||
val store = KeyValueStore(
|
||||
MockKeyValuePersistentStorage.withDataSet(
|
||||
KeyValueDataSet().apply {
|
||||
putString(AccountValues.KEY_ACI, UUID.randomUUID().toString())
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
SignalStore.inject(store)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `simple name pattern`() {
|
||||
`when`(FeatureFlags.crashPromptConfig()).thenReturn("""[ { "name": "test", "percent": 100 } ]""")
|
||||
CrashConfig.computePatterns() assertIs listOf(CrashConfig.CrashPattern(namePattern = "test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `simple message pattern`() {
|
||||
`when`(FeatureFlags.crashPromptConfig()).thenReturn("""[ { "message": "test", "percent": 100 } ]""")
|
||||
CrashConfig.computePatterns() assertIs listOf(CrashConfig.CrashPattern(messagePattern = "test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `simple stackTrace pattern`() {
|
||||
`when`(FeatureFlags.crashPromptConfig()).thenReturn("""[ { "stackTrace": "test", "percent": 100 } ]""")
|
||||
CrashConfig.computePatterns() assertIs listOf(CrashConfig.CrashPattern(stackTracePattern = "test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all fields set`() {
|
||||
`when`(FeatureFlags.crashPromptConfig()).thenReturn("""[ { "name": "test1", "message": "test2", "stackTrace": "test3", "percent": 100 } ]""")
|
||||
CrashConfig.computePatterns() assertIs listOf(CrashConfig.CrashPattern(namePattern = "test1", messagePattern = "test2", stackTracePattern = "test3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple configs`() {
|
||||
`when`(FeatureFlags.crashPromptConfig()).thenReturn(
|
||||
"""
|
||||
[
|
||||
{ "name": "test1", "percent": 100 },
|
||||
{ "message": "test2", "percent": 100 },
|
||||
{ "stackTrace": "test3", "percent": 100 }
|
||||
]
|
||||
"""
|
||||
)
|
||||
|
||||
CrashConfig.computePatterns() assertIs listOf(
|
||||
CrashConfig.CrashPattern(namePattern = "test1"),
|
||||
CrashConfig.CrashPattern(messagePattern = "test2"),
|
||||
CrashConfig.CrashPattern(stackTracePattern = "test3")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty fields are considered null`() {
|
||||
`when`(FeatureFlags.crashPromptConfig()).thenReturn(
|
||||
"""
|
||||
[
|
||||
{ "name": "", "percent": 100 },
|
||||
{ "name": "test1", "message": "", "percent": 100 },
|
||||
{ "message": "test2", "stackTrace": "", "percent": 100 }
|
||||
]
|
||||
"""
|
||||
)
|
||||
|
||||
CrashConfig.computePatterns() assertIs listOf(
|
||||
CrashConfig.CrashPattern(namePattern = "test1"),
|
||||
CrashConfig.CrashPattern(messagePattern = "test2")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignore zero percent`() {
|
||||
`when`(FeatureFlags.crashPromptConfig()).thenReturn("""[ { "name": "test", "percent": 0 } ]""")
|
||||
CrashConfig.computePatterns() assertIs emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not setting percent is the same as zero percent`() {
|
||||
`when`(FeatureFlags.crashPromptConfig()).thenReturn("""[ { "name": "test" } ]""")
|
||||
CrashConfig.computePatterns() assertIs emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignore configs without a pattern`() {
|
||||
`when`(FeatureFlags.crashPromptConfig()).thenReturn("""[ { "percent": 100 } ]""")
|
||||
CrashConfig.computePatterns() assertIs emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignore invalid json`() {
|
||||
`when`(FeatureFlags.crashPromptConfig()).thenReturn("asdf")
|
||||
CrashConfig.computePatterns() assertIs emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignore empty json`() {
|
||||
`when`(FeatureFlags.crashPromptConfig()).thenReturn("")
|
||||
CrashConfig.computePatterns() assertIs emptyList()
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue