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 {
|
SignalExecutors.UNBOUNDED.execute {
|
||||||
Log.blockUntilAllWritesFinished()
|
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(() -> {
|
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||||
Log.blockUntilAllWritesFinished();
|
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.devicetransfer.olddevice.OldDeviceExitActivity;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor;
|
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.ConversationListTabRepository;
|
||||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
|
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
|
||||||
import org.thoughtcrime.securesms.util.AppStartup;
|
import org.thoughtcrime.securesms.util.AppStartup;
|
||||||
|
@ -45,7 +45,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||||
|
|
||||||
private VoiceNoteMediaController mediaController;
|
private VoiceNoteMediaController mediaController;
|
||||||
private ConversationListTabsViewModel conversationListTabsViewModel;
|
private ConversationListTabsViewModel conversationListTabsViewModel;
|
||||||
private SlowNotificationsViewModel slowNotificationsViewModel;
|
private VitalsViewModel vitalsViewModel;
|
||||||
|
|
||||||
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
|
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);
|
conversationListTabsViewModel = new ViewModelProvider(this, factory).get(ConversationListTabsViewModel.class);
|
||||||
updateTabVisibility();
|
updateTabVisibility();
|
||||||
|
|
||||||
slowNotificationsViewModel = new ViewModelProvider(this).get(SlowNotificationsViewModel.class);
|
vitalsViewModel = new ViewModelProvider(this).get(VitalsViewModel.class);
|
||||||
|
|
||||||
lifecycleDisposable.add(
|
lifecycleDisposable.add(
|
||||||
slowNotificationsViewModel
|
vitalsViewModel
|
||||||
.getSlowNotificationState()
|
.getVitalsState()
|
||||||
.subscribe(this::presentSlowNotificationState)
|
.subscribe(this::presentVitalsState)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("NewApi")
|
@SuppressLint("NewApi")
|
||||||
private void presentSlowNotificationState(SlowNotificationsViewModel.State slowNotificationState) {
|
private void presentVitalsState(VitalsViewModel.State state) {
|
||||||
switch (slowNotificationState) {
|
switch (state) {
|
||||||
case NONE:
|
case NONE:
|
||||||
break;
|
break;
|
||||||
case PROMPT_BATTERY_SAVER_DIALOG:
|
case PROMPT_BATTERY_SAVER_DIALOG:
|
||||||
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
|
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
|
||||||
break;
|
break;
|
||||||
case PROMPT_DEBUGLOGS:
|
case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS:
|
||||||
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager());
|
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager(), DebugLogsPromptDialogFragment.Purpose.NOTIFICATIONS);
|
||||||
|
case PROMPT_DEBUGLOGS_FOR_CRASH:
|
||||||
|
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager(), DebugLogsPromptDialogFragment.Purpose.CRASH);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -168,7 +170,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||||
|
|
||||||
updateTabVisibility();
|
updateTabVisibility();
|
||||||
|
|
||||||
slowNotificationsViewModel.checkSlowNotificationHeuristics();
|
vitalsViewModel.checkSlowNotificationHeuristics();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -13,11 +13,12 @@ import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
import androidx.fragment.app.FragmentManager
|
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.ResourceUtil
|
||||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||||
import org.thoughtcrime.securesms.R
|
import org.thoughtcrime.securesms.R
|
||||||
import org.thoughtcrime.securesms.databinding.PromptLogsBottomSheetBinding
|
import org.thoughtcrime.securesms.databinding.PromptLogsBottomSheetBinding
|
||||||
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||||
|
@ -27,14 +28,21 @@ import org.thoughtcrime.securesms.util.SupportEmailUtil
|
||||||
class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val KEY_PURPOSE = "purpose"
|
||||||
|
|
||||||
@JvmStatic
|
@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) {
|
if (NetworkUtil.isConnected(context) && fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
|
||||||
DebugLogsPromptDialogFragment().apply {
|
DebugLogsPromptDialogFragment().apply {
|
||||||
arguments = bundleOf()
|
arguments = bundleOf(
|
||||||
|
KEY_PURPOSE to purpose.serialized
|
||||||
|
)
|
||||||
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
}.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 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()
|
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||||
|
|
||||||
|
@ -55,11 +68,21 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
disposables.bindTo(viewLifecycleOwner)
|
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 {
|
binding.submit.setOnClickListener {
|
||||||
val progressDialog = SignalProgressDialog.show(requireContext())
|
val progressDialog = SignalProgressDialog.show(requireContext())
|
||||||
disposables += viewModel.submitLogs().subscribe({ result ->
|
disposables += viewModel.submitLogs().subscribe({ result ->
|
||||||
submitLogs(result)
|
submitLogs(result, purpose)
|
||||||
progressDialog.dismiss()
|
progressDialog.dismiss()
|
||||||
dismiss()
|
dismiss()
|
||||||
}, { _ ->
|
}, { _ ->
|
||||||
|
@ -68,30 +91,40 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
|
||||||
dismiss()
|
dismiss()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.decline.setOnClickListener {
|
binding.decline.setOnClickListener {
|
||||||
SignalStore.uiHints().markDeclinedShareNotificationLogs()
|
if (purpose == Purpose.NOTIFICATIONS) {
|
||||||
|
SignalStore.uiHints().markDeclinedShareNotificationLogs()
|
||||||
|
}
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun submitLogs(debugLog: String) {
|
private fun submitLogs(debugLog: String, purpose: Purpose) {
|
||||||
CommunicationActions.openEmail(
|
CommunicationActions.openEmail(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
SupportEmailUtil.getSupportEmailAddress(requireContext()),
|
SupportEmailUtil.getSupportEmailAddress(requireContext()),
|
||||||
getString(R.string.DebugLogsPromptDialogFragment__signal_android_support_request),
|
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()
|
val suffix = StringBuilder()
|
||||||
|
|
||||||
if (debugLog != null) {
|
if (debugLog != null) {
|
||||||
suffix.append("\n")
|
suffix.append("\n")
|
||||||
suffix.append(getString(R.string.HelpFragment__debug_log))
|
suffix.append(getString(R.string.HelpFragment__debug_log))
|
||||||
suffix.append(" ")
|
suffix.append(" ")
|
||||||
suffix.append(debugLog)
|
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(
|
return SupportEmailUtil.generateSupportEmailBody(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
R.string.DebugLogsPromptDialogFragment__signal_android_support_request,
|
R.string.DebugLogsPromptDialogFragment__signal_android_support_request,
|
||||||
|
@ -100,4 +133,21 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
|
||||||
suffix.toString()
|
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
|
package org.thoughtcrime.securesms.components
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.rxjava3.core.Single
|
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.schedulers.Schedulers
|
||||||
import io.reactivex.rxjava3.subjects.SingleSubject
|
import io.reactivex.rxjava3.subjects.SingleSubject
|
||||||
|
import org.thoughtcrime.securesms.crash.CrashConfig
|
||||||
|
import org.thoughtcrime.securesms.database.LogDatabase
|
||||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
|
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 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> {
|
fun submitLogs(): Single<String> {
|
||||||
val singleSubject = SingleSubject.create<String?>()
|
val singleSubject = SingleSubject.create<String?>()
|
||||||
submitDebugLogRepository.buildAndSubmitLog { result ->
|
submitDebugLogRepository.buildAndSubmitLog { result ->
|
||||||
|
@ -28,4 +48,14 @@ class PromptLogsViewModel : ViewModel() {
|
||||||
|
|
||||||
return singleSubject.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
|
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() {
|
private fun clearKeepLongerLogs() {
|
||||||
SimpleTask.run({
|
SimpleTask.run({
|
||||||
LogDatabase.getInstance(requireActivity().application).clearKeepLonger()
|
LogDatabase.getInstance(requireActivity().application).logs.clearKeepLonger()
|
||||||
}) {
|
}) {
|
||||||
Toast.makeText(requireContext(), "Cleared keep longer logs", Toast.LENGTH_SHORT).show()
|
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.annotation.SuppressLint
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.ContentValues
|
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||||
import net.zetetic.database.sqlcipher.SQLiteOpenHelper
|
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.SqlUtil
|
||||||
import org.signal.core.util.Stopwatch
|
import org.signal.core.util.Stopwatch
|
||||||
import org.signal.core.util.delete
|
import org.signal.core.util.delete
|
||||||
|
import org.signal.core.util.exists
|
||||||
import org.signal.core.util.getTableRowCount
|
import org.signal.core.util.getTableRowCount
|
||||||
|
import org.signal.core.util.insertInto
|
||||||
import org.signal.core.util.logging.Log
|
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.DatabaseSecret
|
||||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
|
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
|
||||||
import org.thoughtcrime.securesms.database.model.LogEntry
|
import org.thoughtcrime.securesms.database.model.LogEntry
|
||||||
import org.thoughtcrime.securesms.util.ByteUnit
|
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores logs.
|
* Stores logs.
|
||||||
|
@ -48,35 +56,9 @@ class LogDatabase private constructor(
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = Log.tag(LogDatabase::class.java)
|
private val TAG = Log.tag(LogDatabase::class.java)
|
||||||
|
|
||||||
private val MAX_FILE_SIZE = ByteUnit.MEGABYTES.toBytes(20)
|
private const val DATABASE_VERSION = 3
|
||||||
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_NAME = "signal-logs.db"
|
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
|
@SuppressLint("StaticFieldLeak") // We hold an Application context, not a view context
|
||||||
@Volatile
|
@Volatile
|
||||||
private var instance: LogDatabase? = null
|
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) {
|
override fun onCreate(db: SQLiteDatabase) {
|
||||||
Log.i(TAG, "onCreate()")
|
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) {
|
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 keep_longer_index ON log (keep_longer)")
|
||||||
db.execSQL("CREATE INDEX log_created_at_keep_longer_index ON log (created_at, 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) {
|
override fun onOpen(db: SQLiteDatabase) {
|
||||||
|
@ -120,144 +118,301 @@ class LogDatabase private constructor(
|
||||||
return writableDatabase
|
return writableDatabase
|
||||||
}
|
}
|
||||||
|
|
||||||
fun insert(logs: List<LogEntry>, currentTime: Long) {
|
class LogTable(private val openHelper: LogDatabase) {
|
||||||
val db = writableDatabase
|
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()
|
const val CREATE_TABLE = """
|
||||||
try {
|
CREATE TABLE $TABLE_NAME (
|
||||||
logs.forEach { log ->
|
$ID INTEGER PRIMARY KEY,
|
||||||
db.insert(TABLE_NAME, null, buildValues(log))
|
$CREATED_AT INTEGER,
|
||||||
}
|
$KEEP_LONGER INTEGER DEFAULT 0,
|
||||||
|
$BODY TEXT,
|
||||||
|
$SIZE INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
db.delete(
|
val CREATE_INDEXES = arrayOf(
|
||||||
TABLE_NAME,
|
"CREATE INDEX keep_longer_index ON $TABLE_NAME ($KEEP_LONGER)",
|
||||||
"($CREATED_AT < ? AND $KEEP_LONGER = ?) OR ($CREATED_AT < ? AND $KEEP_LONGER = ?)",
|
"CREATE INDEX log_created_at_keep_longer_index ON $TABLE_NAME ($CREATED_AT, $KEEP_LONGER)"
|
||||||
SqlUtil.buildArgs(currentTime - DEFAULT_LIFESPAN, 0, currentTime - LONGER_LIFESPAN, 1)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
db.setTransactionSuccessful()
|
val MAX_FILE_SIZE = 20L.mebiBytes.inWholeBytes
|
||||||
} finally {
|
val DEFAULT_LIFESPAN = 3.days.inWholeMilliseconds
|
||||||
db.endTransaction()
|
val LONGER_LIFESPAN = 21.days.inWholeMilliseconds
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAllBeforeTime(time: Long): Reader {
|
|
||||||
return CursorReader(readableDatabase.query(TABLE_NAME, arrayOf(BODY), "$CREATED_AT < ?", SqlUtil.buildArgs(time), null, null, null))
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
private val readableDatabase: SQLiteDatabase get() = openHelper.readableDatabase
|
||||||
}
|
private val writableDatabase: SQLiteDatabase get() = openHelper.writableDatabase
|
||||||
|
|
||||||
fun trimToSize() {
|
fun insert(logs: List<LogEntry>, currentTime: Long) {
|
||||||
val currentTime = System.currentTimeMillis()
|
writableDatabase.withinTransaction { db ->
|
||||||
val stopwatch = Stopwatch("trim")
|
logs.forEach { log ->
|
||||||
|
db.insertInto(TABLE_NAME)
|
||||||
val sizeOfSpecialLogs: Long = getSize("$KEEP_LONGER = ?", arrayOf("1"))
|
.values(
|
||||||
val remainingSize = MAX_FILE_SIZE - sizeOfSpecialLogs
|
CREATED_AT to log.createdAt,
|
||||||
|
KEEP_LONGER to if (log.keepLonger) 1 else 0,
|
||||||
stopwatch.split("keepers-size")
|
BODY to log.body,
|
||||||
|
SIZE to log.body.length
|
||||||
if (remainingSize <= 0) {
|
)
|
||||||
if (abs(remainingSize) > MAX_FILE_SIZE / 2) {
|
.run()
|
||||||
// Not only are KEEP_LONGER logs putting us over the storage limit, it's doing it by a lot! Delete half.
|
|
||||||
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"))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val sizeDiffThreshold = MAX_FILE_SIZE * 0.01
|
|
||||||
|
|
||||||
var lhs: Long = currentTime - DEFAULT_LIFESPAN
|
|
||||||
var rhs: Long = currentTime
|
|
||||||
var mid: Long = 0
|
|
||||||
var sizeOfChunk: Long
|
|
||||||
|
|
||||||
while (lhs < rhs - 2) {
|
|
||||||
mid = (lhs + rhs) / 2
|
|
||||||
sizeOfChunk = getSize("$CREATED_AT > ? AND $CREATED_AT < ? AND $KEEP_LONGER = ?", SqlUtil.buildArgs(mid, currentTime, 0))
|
|
||||||
|
|
||||||
if (sizeOfChunk > remainingSize) {
|
|
||||||
lhs = mid
|
|
||||||
} else if (sizeOfChunk < remainingSize) {
|
|
||||||
if (remainingSize - sizeOfChunk < sizeDiffThreshold) {
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
rhs = mid
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
break
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopwatch.split("binary-search")
|
fun getAllBeforeTime(time: Long): Reader {
|
||||||
|
return readableDatabase
|
||||||
|
.select(BODY)
|
||||||
|
.from(TABLE_NAME)
|
||||||
|
.where("$CREATED_AT < $time")
|
||||||
|
.run()
|
||||||
|
.toReader()
|
||||||
|
}
|
||||||
|
|
||||||
writableDatabase.delete(TABLE_NAME, "$CREATED_AT < ? AND $KEEP_LONGER = ?", SqlUtil.buildArgs(mid, 0))
|
fun getRangeBeforeTime(start: Int, length: Int, time: Long): List<String> {
|
||||||
|
return readableDatabase
|
||||||
|
.select(BODY)
|
||||||
|
.from(TABLE_NAME)
|
||||||
|
.where("$CREATED_AT < $time")
|
||||||
|
.limit(limit = length, offset = start)
|
||||||
|
.run()
|
||||||
|
.readToList { it.requireNonNullString(BODY) }
|
||||||
|
}
|
||||||
|
|
||||||
stopwatch.split("delete")
|
fun trimToSize() {
|
||||||
stopwatch.stop(TAG)
|
val currentTime = System.currentTimeMillis()
|
||||||
}
|
val stopwatch = Stopwatch("trim")
|
||||||
|
|
||||||
fun getLogCountBeforeTime(time: Long): Int {
|
val sizeOfSpecialLogs: Long = getSize("$KEEP_LONGER = ?", arrayOf("1"))
|
||||||
readableDatabase.query(TABLE_NAME, arrayOf("COUNT(*)"), "$CREATED_AT < ?", SqlUtil.buildArgs(time), null, null, null).use { cursor ->
|
val remainingSize = MAX_FILE_SIZE - sizeOfSpecialLogs
|
||||||
return if (cursor.moveToFirst()) {
|
|
||||||
cursor.getInt(0)
|
stopwatch.split("keepers-size")
|
||||||
} else {
|
|
||||||
0
|
if (remainingSize <= 0) {
|
||||||
|
if (abs(remainingSize) > MAX_FILE_SIZE / 2) {
|
||||||
|
// Not only are KEEP_LONGER logs putting us over the storage limit, it's doing it by a lot! Delete half.
|
||||||
|
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)
|
||||||
|
.where("$KEEP_LONGER = 0")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val sizeDiffThreshold = MAX_FILE_SIZE * 0.01
|
||||||
|
|
||||||
|
var lhs: Long = currentTime - DEFAULT_LIFESPAN
|
||||||
|
var rhs: Long = currentTime
|
||||||
|
var mid: Long = 0
|
||||||
|
var sizeOfChunk: Long
|
||||||
|
|
||||||
|
while (lhs < rhs - 2) {
|
||||||
|
mid = (lhs + rhs) / 2
|
||||||
|
sizeOfChunk = getSize("$CREATED_AT > ? AND $CREATED_AT < ? AND $KEEP_LONGER = ?", SqlUtil.buildArgs(mid, currentTime, 0))
|
||||||
|
|
||||||
|
if (sizeOfChunk > remainingSize) {
|
||||||
|
lhs = mid
|
||||||
|
} else if (sizeOfChunk < remainingSize) {
|
||||||
|
if (remainingSize - sizeOfChunk < sizeDiffThreshold) {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
rhs = mid
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopwatch.split("binary-search")
|
||||||
|
|
||||||
|
writableDatabase.delete(TABLE_NAME, "$CREATED_AT < ? AND $KEEP_LONGER = ?", SqlUtil.buildArgs(mid, 0))
|
||||||
|
|
||||||
|
stopwatch.split("delete")
|
||||||
|
stopwatch.stop(TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLogCountBeforeTime(time: Long): Int {
|
||||||
|
return readableDatabase
|
||||||
|
.select("COUNT(*)")
|
||||||
|
.from(TABLE_NAME)
|
||||||
|
.where("$CREATED_AT < $time")
|
||||||
|
.run()
|
||||||
|
.readToSingleInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearKeepLonger() {
|
||||||
|
writableDatabase
|
||||||
|
.delete(TABLE_NAME)
|
||||||
|
.where("$KEEP_LONGER = 1")
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
cursor.getLong(0)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Cursor.toReader(): CursorReader {
|
||||||
|
return CursorReader(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Reader : Iterator<String>, Closeable
|
||||||
|
|
||||||
|
class CursorReader(private val cursor: Cursor) : Reader {
|
||||||
|
override fun hasNext(): Boolean {
|
||||||
|
return !cursor.isLast && cursor.count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun next(): String {
|
||||||
|
cursor.moveToNext()
|
||||||
|
return CursorUtil.requireString(cursor, LogTable.BODY)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
cursor.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearKeepLonger() {
|
class CrashTable(private val openHelper: LogDatabase) {
|
||||||
writableDatabase.delete(TABLE_NAME)
|
companion object {
|
||||||
.where("$KEEP_LONGER = ?", 1)
|
const val TABLE_NAME = "crash"
|
||||||
.run()
|
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"
|
||||||
|
|
||||||
private fun buildValues(log: LogEntry): ContentValues {
|
const val CREATE_TABLE = """
|
||||||
return ContentValues().apply {
|
CREATE TABLE $TABLE_NAME (
|
||||||
put(CREATED_AT, log.createdAt)
|
$ID INTEGER PRIMARY KEY,
|
||||||
put(KEEP_LONGER, if (log.keepLonger) 1 else 0)
|
$CREATED_AT INTEGER,
|
||||||
put(BODY, log.body)
|
$NAME TEXT,
|
||||||
put(SIZE, log.body.length)
|
$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 fun getSize(query: String?, args: Array<String>?): Long {
|
private val readableDatabase: SQLiteDatabase get() = openHelper.readableDatabase
|
||||||
readableDatabase.query(TABLE_NAME, arrayOf("SUM($SIZE)"), query, args, null, null, null).use { cursor ->
|
private val writableDatabase: SQLiteDatabase get() = openHelper.writableDatabase
|
||||||
return if (cursor.moveToFirst()) {
|
|
||||||
cursor.getLong(0)
|
fun saveCrash(createdAt: Long, name: String, message: String?, stackTrace: String) {
|
||||||
} else {
|
writableDatabase
|
||||||
0
|
.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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
interface Reader : Iterator<String>, Closeable
|
fun trimToSize() {
|
||||||
|
// Delete crashes older than 30 days
|
||||||
|
val threshold = System.currentTimeMillis() - 30.days.inWholeMilliseconds
|
||||||
|
writableDatabase
|
||||||
|
.delete(TABLE_NAME)
|
||||||
|
.where("$CREATED_AT < $threshold")
|
||||||
|
.run()
|
||||||
|
|
||||||
class CursorReader(private val cursor: Cursor) : Reader {
|
// Only keep 100 most recent crashes to prevent crash loops from filling up the disk
|
||||||
override fun hasNext(): Boolean {
|
writableDatabase
|
||||||
return !cursor.isLast && cursor.count > 0
|
.delete(TABLE_NAME)
|
||||||
|
.where("$ID NOT IN (SELECT $ID FROM $TABLE_NAME ORDER BY $CREATED_AT DESC LIMIT 100)")
|
||||||
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun next(): String {
|
private fun CrashConfig.CrashPattern.asLikeQuery(): Pair<String, Array<String>> {
|
||||||
cursor.moveToNext()
|
val query = StringBuilder()
|
||||||
return CursorUtil.requireString(cursor, BODY)
|
var args = arrayOf<String>()
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
if (namePattern != null) {
|
||||||
cursor.close()
|
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 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 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_BATTERY_SAVER_PROMPT = "uihints.last_battery_saver_prompt";
|
||||||
|
private static final String LAST_CRASH_PROMPT = "uihints.last_crash_prompt";
|
||||||
|
|
||||||
UiHints(@NonNull KeyValueStore store) {
|
UiHints(@NonNull KeyValueStore store) {
|
||||||
super(store);
|
super(store);
|
||||||
|
@ -154,4 +155,12 @@ public class UiHints extends SignalStoreValues {
|
||||||
public void setLastBatterySaverPrompt(long time) {
|
public void setLastBatterySaverPrompt(long time) {
|
||||||
putLong(LAST_BATTERY_SAVER_PROMPT, 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() {
|
override fun run() {
|
||||||
while (true) {
|
while (true) {
|
||||||
requests.blockForRequests(buffer)
|
requests.blockForRequests(buffer)
|
||||||
db.insert(buffer.flatMap { requestToEntries(it) }, System.currentTimeMillis())
|
db.logs.insert(buffer.flatMap { requestToEntries(it) }, System.currentTimeMillis())
|
||||||
buffer.clear()
|
buffer.clear()
|
||||||
requests.notifyFlushed()
|
requests.notifyFlushed()
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ class LogDataSource(
|
||||||
val logDatabase = LogDatabase.getInstance(application)
|
val logDatabase = LogDatabase.getInstance(application)
|
||||||
|
|
||||||
override fun size(): Int {
|
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> {
|
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)
|
return prefixLines.subList(start, start + length)
|
||||||
} else if (start < prefixLines.size) {
|
} else if (start < prefixLines.size) {
|
||||||
return prefixLines.subList(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 {
|
} 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) {
|
public void buildAndSubmitLog(@NonNull Callback<Optional<String>> callback) {
|
||||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||||
Log.blockUntilAllWritesFinished();
|
Log.blockUntilAllWritesFinished();
|
||||||
LogDatabase.getInstance(context).trimToSize();
|
LogDatabase.getInstance(context).logs().trimToSize();
|
||||||
callback.onResult(submitLogInternal(System.currentTimeMillis(), getPrefixLogLinesInternal(), Tracer.getInstance().serialize()));
|
callback.onResult(submitLogInternal(System.currentTimeMillis(), getPrefixLogLinesInternal(), Tracer.getInstance().serialize()));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -140,7 +140,7 @@ public class SubmitDebugLogRepository {
|
||||||
outputStream.putNextEntry(new ZipEntry("log.txt"));
|
outputStream.putNextEntry(new ZipEntry("log.txt"));
|
||||||
outputStream.write(prefixLines.toString().getBytes(StandardCharsets.UTF_8));
|
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()) {
|
while (reader.hasNext()) {
|
||||||
outputStream.write(reader.next().getBytes());
|
outputStream.write(reader.next().getBytes());
|
||||||
outputStream.write("\n".getBytes());
|
outputStream.write("\n".getBytes());
|
||||||
|
@ -193,7 +193,7 @@ public class SubmitDebugLogRepository {
|
||||||
|
|
||||||
stopwatch.split("front-matter");
|
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()) {
|
while (reader.hasNext()) {
|
||||||
gzipOutput.write(reader.next().getBytes());
|
gzipOutput.write(reader.next().getBytes());
|
||||||
gzipOutput.write("\n".getBytes());
|
gzipOutput.write("\n".getBytes());
|
||||||
|
|
|
@ -54,7 +54,7 @@ public class SubmitDebugLogViewModel extends ViewModel {
|
||||||
this.staticLines.addAll(staticLines);
|
this.staticLines.addAll(staticLines);
|
||||||
|
|
||||||
Log.blockUntilAllWritesFinished();
|
Log.blockUntilAllWritesFinished();
|
||||||
LogDatabase.getInstance(ApplicationDependencies.getApplication()).trimToSize();
|
LogDatabase.getInstance(ApplicationDependencies.getApplication()).logs().trimToSize();
|
||||||
|
|
||||||
LogDataSource dataSource = new LogDataSource(ApplicationDependencies.getApplication(), staticLines, firstViewTime);
|
LogDataSource dataSource = new LogDataSource(ApplicationDependencies.getApplication(), staticLines, firstViewTime);
|
||||||
PagingConfig config = new PagingConfig.Builder().setPageSize(100)
|
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 USERNAMES = "android.usernames";
|
||||||
public static final String INSTANT_VIDEO_PLAYBACK = "android.instantVideoPlayback";
|
public static final String INSTANT_VIDEO_PLAYBACK = "android.instantVideoPlayback";
|
||||||
private static final String CONVERSATION_ITEM_V2_TEXT = "android.conversationItemV2.text.2";
|
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
|
* 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,
|
PROMPT_BATTERY_SAVER,
|
||||||
USERNAMES,
|
USERNAMES,
|
||||||
INSTANT_VIDEO_PLAYBACK,
|
INSTANT_VIDEO_PLAYBACK,
|
||||||
CONVERSATION_ITEM_V2_TEXT
|
CONVERSATION_ITEM_V2_TEXT,
|
||||||
|
CRASH_PROMPT_CONFIG
|
||||||
);
|
);
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
|
@ -252,7 +254,8 @@ public final class FeatureFlags {
|
||||||
PROMPT_FOR_NOTIFICATION_LOGS,
|
PROMPT_FOR_NOTIFICATION_LOGS,
|
||||||
PROMPT_FOR_NOTIFICATION_CONFIG,
|
PROMPT_FOR_NOTIFICATION_CONFIG,
|
||||||
PROMPT_BATTERY_SAVER,
|
PROMPT_BATTERY_SAVER,
|
||||||
USERNAMES
|
USERNAMES,
|
||||||
|
CRASH_PROMPT_CONFIG
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -662,6 +665,11 @@ public final class FeatureFlags {
|
||||||
return getString(PROMPT_BATTERY_SAVER, "*");
|
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. */
|
/** Only for rendering debug info. */
|
||||||
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
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.DeserializationFeature;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.type.TypeFactory;
|
||||||
|
|
||||||
import org.json.JSONException;
|
import org.json.JSONException;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
@ -10,6 +11,7 @@ import org.json.JSONObject;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.Reader;
|
import java.io.Reader;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
@ -40,6 +42,11 @@ public class JsonUtils {
|
||||||
return objectMapper.readValue(serialized, clazz);
|
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 {
|
public static String toJson(Object object) throws IOException {
|
||||||
return objectMapper.writeValueAsString(object);
|
return objectMapper.writeValueAsString(object);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import org.signal.core.util.ExceptionUtil;
|
import org.signal.core.util.ExceptionUtil;
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
|
import org.thoughtcrime.securesms.database.LogDatabase;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
|
|
||||||
|
@ -39,7 +40,13 @@ public class SignalUncaughtExceptionHandler implements Thread.UncaughtExceptionH
|
||||||
e = e.getCause();
|
e = e.getCause();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String exceptionName = e.getClass().getCanonicalName();
|
||||||
|
if (exceptionName == null) {
|
||||||
|
exceptionName = e.getClass().getName();
|
||||||
|
}
|
||||||
|
|
||||||
Log.e(TAG, "", e, true);
|
Log.e(TAG, "", e, true);
|
||||||
|
LogDatabase.getInstance(ApplicationDependencies.getApplication()).crashes().saveCrash(System.currentTimeMillis(), exceptionName, e.getMessage(), ExceptionUtil.convertThrowableToString(e));
|
||||||
SignalStore.blockUntilAllWritesFinished();
|
SignalStore.blockUntilAllWritesFinished();
|
||||||
Log.blockUntilAllWritesFinished();
|
Log.blockUntilAllWritesFinished();
|
||||||
ApplicationDependencies.getJobManager().flush();
|
ApplicationDependencies.getJobManager().flush();
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
android:layout_gravity="center_horizontal"/>
|
android:layout_gravity="center_horizontal"/>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
android:id="@+id/title"
|
||||||
style="@style/Signal.Text.TitleLarge"
|
style="@style/Signal.Text.TitleLarge"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|
|
@ -870,8 +870,10 @@
|
||||||
|
|
||||||
<!-- Title for dialog asking user to submit logs for debugging slow notification issues -->
|
<!-- 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>
|
<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>
|
<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 -->
|
<!-- 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>
|
<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>
|
<string name="DebugLogsPromptDialogFragment__signal_android_support_request">Signal Android Debug Log Submission</string>
|
||||||
<!-- Category to organize the support email sent -->
|
<!-- Category to organize the support email sent -->
|
||||||
<string name="DebugLogsPromptDialogFragment__slow_notifications_category">Slow notifications</string>
|
<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 -->
|
<!-- Action to submit logs and take user to send an e-mail -->
|
||||||
<string name="DebugLogsPromptDialogFragment__submit">Submit</string>
|
<string name="DebugLogsPromptDialogFragment__submit">Submit</string>
|
||||||
<!-- Action to decline to submit logs -->
|
<!-- Action to decline to submit logs -->
|
||||||
|
|
|
@ -59,7 +59,10 @@ class SpinnerApplicationContext : ApplicationContext() {
|
||||||
"keyvalue" to DatabaseConfig(db = { KeyValueDatabase.getInstance(this).sqlCipherDatabase }),
|
"keyvalue" to DatabaseConfig(db = { KeyValueDatabase.getInstance(this).sqlCipherDatabase }),
|
||||||
"megaphones" to DatabaseConfig(db = { MegaphoneDatabase.getInstance(this).sqlCipherDatabase }),
|
"megaphones" to DatabaseConfig(db = { MegaphoneDatabase.getInstance(this).sqlCipherDatabase }),
|
||||||
"localmetrics" to DatabaseConfig(db = { LocalMetricsDatabase.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(
|
linkedMapOf(
|
||||||
StorageServicePlugin.PATH to StorageServicePlugin()
|
StorageServicePlugin.PATH to StorageServicePlugin()
|
||||||
|
|
|
@ -11,7 +11,8 @@ import java.time.LocalDateTime
|
||||||
object TimestampTransformer : ColumnTransformer {
|
object TimestampTransformer : ColumnTransformer {
|
||||||
override fun matches(tableName: String?, columnName: String): Boolean {
|
override fun matches(tableName: String?, columnName: String): Boolean {
|
||||||
return columnName.contains("date", true) ||
|
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? {
|
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