Add UI for prompting about crashes.

This commit is contained in:
Greyson Parrelli 2023-09-06 15:05:23 -04:00 committed by Alex Hart
parent 0a6c3baf24
commit f959543c19
23 changed files with 1089 additions and 182 deletions

View file

@ -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()
} }
} }
} }

View file

@ -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
}
}
}
}

View file

@ -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();
}); });
} }

View file

@ -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

View file

@ -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")
}
}
}
} }

View file

@ -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))!!
}
}
} }

View file

@ -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()
} }

View file

@ -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
}
}
}

View file

@ -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
} }
} }
} }

View file

@ -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);
}
} }

View file

@ -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()
} }

View file

@ -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) }
} }
} }

View file

@ -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());

View file

@ -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)

View file

@ -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
}
}

View file

@ -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() {

View file

@ -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);
} }

View file

@ -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();

View file

@ -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"

View file

@ -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 -->

View file

@ -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()

View file

@ -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? {

View file

@ -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()
}
}