diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/SignalInstrumentationApplicationContext.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/SignalInstrumentationApplicationContext.kt index 77dad27038..b4689f72e6 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/SignalInstrumentationApplicationContext.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/SignalInstrumentationApplicationContext.kt @@ -34,7 +34,7 @@ class SignalInstrumentationApplicationContext : ApplicationContext() { SignalExecutors.UNBOUNDED.execute { Log.blockUntilAllWritesFinished() - LogDatabase.getInstance(this).trimToSize() + LogDatabase.getInstance(this).logs.trimToSize() } } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/LogDatabaseTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/LogDatabaseTest.kt new file mode 100644 index 0000000000..12bcf4dcd5 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/LogDatabaseTest.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 7b69285b05..f2c550b636 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -302,7 +302,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr SignalExecutors.UNBOUNDED.execute(() -> { Log.blockUntilAllWritesFinished(); - LogDatabase.getInstance(this).trimToSize(); + LogDatabase.getInstance(this).logs().trimToSize(); + LogDatabase.getInstance(this).crashes().trimToSize(); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java index fe2461bd14..3311faa41b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java @@ -25,7 +25,7 @@ import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSh import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor; -import org.thoughtcrime.securesms.notifications.SlowNotificationsViewModel; +import org.thoughtcrime.securesms.notifications.VitalsViewModel; import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository; import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel; import org.thoughtcrime.securesms.util.AppStartup; @@ -45,7 +45,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot private VoiceNoteMediaController mediaController; private ConversationListTabsViewModel conversationListTabsViewModel; - private SlowNotificationsViewModel slowNotificationsViewModel; + private VitalsViewModel vitalsViewModel; private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable(); @@ -99,25 +99,27 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot conversationListTabsViewModel = new ViewModelProvider(this, factory).get(ConversationListTabsViewModel.class); updateTabVisibility(); - slowNotificationsViewModel = new ViewModelProvider(this).get(SlowNotificationsViewModel.class); + vitalsViewModel = new ViewModelProvider(this).get(VitalsViewModel.class); lifecycleDisposable.add( - slowNotificationsViewModel - .getSlowNotificationState() - .subscribe(this::presentSlowNotificationState) + vitalsViewModel + .getVitalsState() + .subscribe(this::presentVitalsState) ); } @SuppressLint("NewApi") - private void presentSlowNotificationState(SlowNotificationsViewModel.State slowNotificationState) { - switch (slowNotificationState) { + private void presentVitalsState(VitalsViewModel.State state) { + switch (state) { case NONE: break; case PROMPT_BATTERY_SAVER_DIALOG: PromptBatterySaverDialogFragment.show(getSupportFragmentManager()); break; - case PROMPT_DEBUGLOGS: - DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager()); + case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS: + DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager(), DebugLogsPromptDialogFragment.Purpose.NOTIFICATIONS); + case PROMPT_DEBUGLOGS_FOR_CRASH: + DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager(), DebugLogsPromptDialogFragment.Purpose.CRASH); break; } } @@ -168,7 +170,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot updateTabVisibility(); - slowNotificationsViewModel.checkSlowNotificationHeuristics(); + vitalsViewModel.checkSlowNotificationHeuristics(); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/DebugLogsPromptDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/DebugLogsPromptDialogFragment.kt index 81d5a05b03..a63eda1760 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/DebugLogsPromptDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/DebugLogsPromptDialogFragment.kt @@ -13,11 +13,12 @@ import android.view.ViewGroup import android.widget.Toast import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager -import androidx.lifecycle.ViewModelProvider +import androidx.fragment.app.viewModels import org.signal.core.util.ResourceUtil import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.databinding.PromptLogsBottomSheetBinding +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.CommunicationActions @@ -27,14 +28,21 @@ import org.thoughtcrime.securesms.util.SupportEmailUtil class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() { companion object { + private const val KEY_PURPOSE = "purpose" @JvmStatic - fun show(context: Context, fragmentManager: FragmentManager) { + fun show(context: Context, fragmentManager: FragmentManager, purpose: Purpose) { if (NetworkUtil.isConnected(context) && fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) { DebugLogsPromptDialogFragment().apply { - arguments = bundleOf() + arguments = bundleOf( + KEY_PURPOSE to purpose.serialized + ) }.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) - SignalStore.uiHints().lastNotificationLogsPrompt = System.currentTimeMillis() + + when (purpose) { + Purpose.NOTIFICATIONS -> SignalStore.uiHints().lastNotificationLogsPrompt = System.currentTimeMillis() + Purpose.CRASH -> SignalStore.uiHints().lastCrashPrompt = System.currentTimeMillis() + } } } } @@ -44,7 +52,12 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen private val binding by ViewBinderDelegate(PromptLogsBottomSheetBinding::bind) - private lateinit var viewModel: PromptLogsViewModel + private val viewModel: PromptLogsViewModel by viewModels( + factoryProducer = { + val purpose = Purpose.deserialize(requireArguments().getInt(KEY_PURPOSE)) + PromptLogsViewModel.Factory(ApplicationDependencies.getApplication(), purpose) + } + ) private val disposables: LifecycleDisposable = LifecycleDisposable() @@ -55,11 +68,21 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen override fun onViewCreated(view: View, savedInstanceState: Bundle?) { disposables.bindTo(viewLifecycleOwner) - viewModel = ViewModelProvider(this).get(PromptLogsViewModel::class.java) + val purpose = Purpose.deserialize(requireArguments().getInt(KEY_PURPOSE)) + + when (purpose) { + Purpose.NOTIFICATIONS -> { + binding.title.setText(R.string.PromptLogsSlowNotificationsDialog__title) + } + Purpose.CRASH -> { + binding.title.setText(R.string.PromptLogsSlowNotificationsDialog__title_crash) + } + } + binding.submit.setOnClickListener { val progressDialog = SignalProgressDialog.show(requireContext()) disposables += viewModel.submitLogs().subscribe({ result -> - submitLogs(result) + submitLogs(result, purpose) progressDialog.dismiss() dismiss() }, { _ -> @@ -68,30 +91,40 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen dismiss() }) } + binding.decline.setOnClickListener { - SignalStore.uiHints().markDeclinedShareNotificationLogs() + if (purpose == Purpose.NOTIFICATIONS) { + SignalStore.uiHints().markDeclinedShareNotificationLogs() + } + dismiss() } } - private fun submitLogs(debugLog: String) { + private fun submitLogs(debugLog: String, purpose: Purpose) { CommunicationActions.openEmail( requireContext(), SupportEmailUtil.getSupportEmailAddress(requireContext()), getString(R.string.DebugLogsPromptDialogFragment__signal_android_support_request), - getEmailBody(debugLog) + getEmailBody(debugLog, purpose) ) } - private fun getEmailBody(debugLog: String?): String { + private fun getEmailBody(debugLog: String?, purpose: Purpose): String { val suffix = StringBuilder() + if (debugLog != null) { suffix.append("\n") suffix.append(getString(R.string.HelpFragment__debug_log)) suffix.append(" ") suffix.append(debugLog) } - val category = ResourceUtil.getEnglishResources(requireContext()).getString(R.string.DebugLogsPromptDialogFragment__slow_notifications_category) + + val category = when (purpose) { + Purpose.NOTIFICATIONS -> ResourceUtil.getEnglishResources(requireContext()).getString(R.string.DebugLogsPromptDialogFragment__slow_notifications_category) + Purpose.CRASH -> ResourceUtil.getEnglishResources(requireContext()).getString(R.string.DebugLogsPromptDialogFragment__crash_category) + } + return SupportEmailUtil.generateSupportEmailBody( requireContext(), R.string.DebugLogsPromptDialogFragment__signal_android_support_request, @@ -100,4 +133,21 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen suffix.toString() ) } + + enum class Purpose(val serialized: Int) { + + NOTIFICATIONS(1), CRASH(2); + + companion object { + fun deserialize(serialized: Int): Purpose { + for (value in values()) { + if (value.serialized == serialized) { + return value + } + } + + throw IllegalArgumentException("Invalid value: $serialized") + } + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/PromptLogsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/PromptLogsViewModel.kt index 1d4fce8a82..9e22c50818 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/PromptLogsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/PromptLogsViewModel.kt @@ -5,17 +5,37 @@ package org.thoughtcrime.securesms.components +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.subjects.SingleSubject +import org.thoughtcrime.securesms.crash.CrashConfig +import org.thoughtcrime.securesms.database.LogDatabase import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository -class PromptLogsViewModel : ViewModel() { +class PromptLogsViewModel(private val context: Application, purpose: DebugLogsPromptDialogFragment.Purpose) : AndroidViewModel(context) { private val submitDebugLogRepository = SubmitDebugLogRepository() + private val disposables = CompositeDisposable() + + init { + if (purpose == DebugLogsPromptDialogFragment.Purpose.CRASH) { + disposables += Single + .fromCallable { + LogDatabase.getInstance(context).crashes.markAsPrompted(CrashConfig.patterns, System.currentTimeMillis()) + } + .subscribeOn(Schedulers.io()) + .subscribe() + } + } + fun submitLogs(): Single { val singleSubject = SingleSubject.create() submitDebugLogRepository.buildAndSubmitLog { result -> @@ -28,4 +48,14 @@ class PromptLogsViewModel : ViewModel() { return singleSubject.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) } + + override fun onCleared() { + disposables.clear() + } + + class Factory(private val context: Application, private val purpose: DebugLogsPromptDialogFragment.Purpose) : ViewModelProvider.NewInstanceFactory() { + override fun create(modelClass: Class): T { + return modelClass.cast(PromptLogsViewModel(context, purpose))!! + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 5cd1ca5e44..87ea48cf0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -768,7 +768,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter private fun clearKeepLongerLogs() { SimpleTask.run({ - LogDatabase.getInstance(requireActivity().application).clearKeepLonger() + LogDatabase.getInstance(requireActivity().application).logs.clearKeepLonger() }) { Toast.makeText(requireContext(), "Cleared keep longer logs", Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/crash/CrashConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/crash/CrashConfig.kt new file mode 100644 index 0000000000..0f687d8a82 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/crash/CrashConfig.kt @@ -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 by lazy { computePatterns() } + + @VisibleForTesting + fun computePatterns(): List { + 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 = 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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LogDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LogDatabase.kt index 616df73854..033f9ad988 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LogDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LogDatabase.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.database import android.annotation.SuppressLint import android.app.Application -import android.content.ContentValues import android.database.Cursor import net.zetetic.database.sqlcipher.SQLiteDatabase import net.zetetic.database.sqlcipher.SQLiteOpenHelper @@ -10,15 +9,24 @@ import org.signal.core.util.CursorUtil import org.signal.core.util.SqlUtil import org.signal.core.util.Stopwatch import org.signal.core.util.delete +import org.signal.core.util.exists import org.signal.core.util.getTableRowCount +import org.signal.core.util.insertInto import org.signal.core.util.logging.Log +import org.signal.core.util.mebiBytes +import org.signal.core.util.readToList +import org.signal.core.util.readToSingleInt +import org.signal.core.util.requireNonNullString +import org.signal.core.util.select +import org.signal.core.util.update +import org.signal.core.util.withinTransaction +import org.thoughtcrime.securesms.crash.CrashConfig import org.thoughtcrime.securesms.crypto.DatabaseSecret import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider import org.thoughtcrime.securesms.database.model.LogEntry -import org.thoughtcrime.securesms.util.ByteUnit import java.io.Closeable -import java.util.concurrent.TimeUnit import kotlin.math.abs +import kotlin.time.Duration.Companion.days /** * Stores logs. @@ -48,35 +56,9 @@ class LogDatabase private constructor( companion object { private val TAG = Log.tag(LogDatabase::class.java) - private val MAX_FILE_SIZE = ByteUnit.MEGABYTES.toBytes(20) - private val DEFAULT_LIFESPAN = TimeUnit.DAYS.toMillis(3) - private val LONGER_LIFESPAN = TimeUnit.DAYS.toMillis(21) - - private const val DATABASE_VERSION = 2 + private const val DATABASE_VERSION = 3 private const val DATABASE_NAME = "signal-logs.db" - private const val TABLE_NAME = "log" - private const val ID = "_id" - private const val CREATED_AT = "created_at" - private const val KEEP_LONGER = "keep_longer" - private const val BODY = "body" - private const val SIZE = "size" - - private val CREATE_TABLE = """ - CREATE TABLE $TABLE_NAME ( - $ID INTEGER PRIMARY KEY, - $CREATED_AT INTEGER, - $KEEP_LONGER INTEGER DEFAULT 0, - $BODY TEXT, - $SIZE INTEGER - ) - """ - - private val CREATE_INDEXES = arrayOf( - "CREATE INDEX keep_longer_index ON $TABLE_NAME ($KEEP_LONGER)", - "CREATE INDEX log_created_at_keep_longer_index ON $TABLE_NAME ($CREATED_AT, $KEEP_LONGER)" - ) - @SuppressLint("StaticFieldLeak") // We hold an Application context, not a view context @Volatile private var instance: LogDatabase? = null @@ -95,10 +77,20 @@ class LogDatabase private constructor( } } + @get:JvmName("logs") + val logs: LogTable by lazy { LogTable(this) } + + @get:JvmName("crashes") + val crashes: CrashTable by lazy { CrashTable(this) } + override fun onCreate(db: SQLiteDatabase) { Log.i(TAG, "onCreate()") - db.execSQL(CREATE_TABLE) - CREATE_INDEXES.forEach { db.execSQL(it) } + + db.execSQL(LogTable.CREATE_TABLE) + db.execSQL(CrashTable.CREATE_TABLE) + + LogTable.CREATE_INDEXES.forEach { db.execSQL(it) } + CrashTable.CREATE_INDEXES.forEach { db.execSQL(it) } } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -110,6 +102,12 @@ class LogDatabase private constructor( db.execSQL("CREATE INDEX keep_longer_index ON log (keep_longer)") db.execSQL("CREATE INDEX log_created_at_keep_longer_index ON log (created_at, keep_longer)") } + + if (oldVersion < 3) { + db.execSQL("CREATE TABLE crash (_id INTEGER PRIMARY KEY, created_at INTEGER, name TEXT, message TEXT, stack_trace TEXT, last_prompted_at INTEGER)") + db.execSQL("CREATE INDEX crash_created_at ON crash (created_at)") + db.execSQL("CREATE INDEX crash_name_message ON crash (name, message)") + } } override fun onOpen(db: SQLiteDatabase) { @@ -120,144 +118,301 @@ class LogDatabase private constructor( return writableDatabase } - fun insert(logs: List, currentTime: Long) { - val db = writableDatabase + class LogTable(private val openHelper: LogDatabase) { + companion object { + const val TABLE_NAME = "log" + const val ID = "_id" + const val CREATED_AT = "created_at" + const val KEEP_LONGER = "keep_longer" + const val BODY = "body" + const val SIZE = "size" - db.beginTransaction() - try { - logs.forEach { log -> - db.insert(TABLE_NAME, null, buildValues(log)) - } + const val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY, + $CREATED_AT INTEGER, + $KEEP_LONGER INTEGER DEFAULT 0, + $BODY TEXT, + $SIZE INTEGER + ) + """ - db.delete( - TABLE_NAME, - "($CREATED_AT < ? AND $KEEP_LONGER = ?) OR ($CREATED_AT < ? AND $KEEP_LONGER = ?)", - SqlUtil.buildArgs(currentTime - DEFAULT_LIFESPAN, 0, currentTime - LONGER_LIFESPAN, 1) + val CREATE_INDEXES = arrayOf( + "CREATE INDEX keep_longer_index ON $TABLE_NAME ($KEEP_LONGER)", + "CREATE INDEX log_created_at_keep_longer_index ON $TABLE_NAME ($CREATED_AT, $KEEP_LONGER)" ) - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } - - 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 { - val lines = mutableListOf() - - 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)) - } + val MAX_FILE_SIZE = 20L.mebiBytes.inWholeBytes + val DEFAULT_LIFESPAN = 3.days.inWholeMilliseconds + val LONGER_LIFESPAN = 21.days.inWholeMilliseconds } - return lines - } + private val readableDatabase: SQLiteDatabase get() = openHelper.readableDatabase + private val writableDatabase: SQLiteDatabase get() = openHelper.writableDatabase - fun trimToSize() { - val currentTime = System.currentTimeMillis() - val stopwatch = Stopwatch("trim") - - val sizeOfSpecialLogs: Long = getSize("$KEEP_LONGER = ?", arrayOf("1")) - val remainingSize = MAX_FILE_SIZE - sizeOfSpecialLogs - - stopwatch.split("keepers-size") - - 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, "$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 + fun insert(logs: List, currentTime: Long) { + writableDatabase.withinTransaction { db -> + logs.forEach { log -> + db.insertInto(TABLE_NAME) + .values( + CREATED_AT to log.createdAt, + KEEP_LONGER to if (log.keepLonger) 1 else 0, + BODY to log.body, + SIZE to log.body.length + ) + .run() } - } 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 { + return readableDatabase + .select(BODY) + .from(TABLE_NAME) + .where("$CREATED_AT < $time") + .limit(limit = length, offset = start) + .run() + .readToList { it.requireNonNullString(BODY) } + } - stopwatch.split("delete") - stopwatch.stop(TAG) - } + fun trimToSize() { + val currentTime = System.currentTimeMillis() + val stopwatch = Stopwatch("trim") - fun getLogCountBeforeTime(time: Long): Int { - readableDatabase.query(TABLE_NAME, arrayOf("COUNT(*)"), "$CREATED_AT < ?", SqlUtil.buildArgs(time), null, null, null).use { cursor -> - return if (cursor.moveToFirst()) { - cursor.getInt(0) - } else { - 0 + val sizeOfSpecialLogs: Long = getSize("$KEEP_LONGER = ?", arrayOf("1")) + val remainingSize = MAX_FILE_SIZE - sizeOfSpecialLogs + + stopwatch.split("keepers-size") + + 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?): 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, 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() { - writableDatabase.delete(TABLE_NAME) - .where("$KEEP_LONGER = ?", 1) - .run() - } + class CrashTable(private val openHelper: LogDatabase) { + companion object { + const val TABLE_NAME = "crash" + const val ID = "_id" + const val CREATED_AT = "created_at" + const val NAME = "name" + const val MESSAGE = "message" + const val STACK_TRACE = "stack_trace" + const val LAST_PROMPTED_AT = "last_prompted_at" - private fun buildValues(log: LogEntry): ContentValues { - return ContentValues().apply { - put(CREATED_AT, log.createdAt) - put(KEEP_LONGER, if (log.keepLonger) 1 else 0) - put(BODY, log.body) - put(SIZE, log.body.length) + const val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY, + $CREATED_AT INTEGER, + $NAME TEXT, + $MESSAGE TEXT, + $STACK_TRACE TEXT, + $LAST_PROMPTED_AT INTEGER + ) + """ + + val CREATE_INDEXES = arrayOf( + "CREATE INDEX crash_created_at ON $TABLE_NAME ($CREATED_AT)", + "CREATE INDEX crash_name_message ON $TABLE_NAME ($NAME, $MESSAGE)" + ) } - } - private fun getSize(query: String?, args: Array?): 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 val readableDatabase: SQLiteDatabase get() = openHelper.readableDatabase + private val writableDatabase: SQLiteDatabase get() = openHelper.writableDatabase + + fun saveCrash(createdAt: Long, name: String, message: String?, stackTrace: String) { + writableDatabase + .insertInto(TABLE_NAME) + .values( + CREATED_AT to createdAt, + NAME to name, + MESSAGE to message, + STACK_TRACE to stackTrace, + LAST_PROMPTED_AT to 0 + ) + .run() + + trimToSize() + } + + /** + * Returns true if crashes exists that + * (1) match any of the provided crash patterns + * (2) have not been prompted within the [promptThreshold] + */ + fun anyMatch(patterns: Collection, 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, 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, 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 { - override fun hasNext(): Boolean { - return !cursor.isLast && cursor.count > 0 + // Only keep 100 most recent crashes to prevent crash loops from filling up the disk + writableDatabase + .delete(TABLE_NAME) + .where("$ID NOT IN (SELECT $ID FROM $TABLE_NAME ORDER BY $CREATED_AT DESC LIMIT 100)") + .run() } - override fun next(): String { - cursor.moveToNext() - return CursorUtil.requireString(cursor, BODY) - } + private fun CrashConfig.CrashPattern.asLikeQuery(): Pair> { + val query = StringBuilder() + var args = arrayOf() - override fun close() { - cursor.close() + if (namePattern != null) { + query.append("$NAME LIKE ?") + args += "%$namePattern%" + } + + if (messagePattern != null) { + if (query.isNotEmpty()) { + query.append(" AND ") + } + query.append("$MESSAGE LIKE ?") + args += "%$messagePattern%" + } + + if (stackTracePattern != null) { + if (query.isNotEmpty()) { + query.append(" AND ") + } + query.append("$STACK_TRACE LIKE ?") + args += "%$stackTracePattern%" + } + + return query.toString() to args } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java index b5c2ce8db2..bbd8a70b18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java @@ -22,6 +22,7 @@ public class UiHints extends SignalStoreValues { private static final String LAST_NOTIFICATION_LOGS_PROMPT_TIME = "uihints.last_notification_logs_prompt"; private static final String DISMISSED_BATTERY_SAVER_PROMPT = "uihints.declined_battery_saver_prompt"; private static final String LAST_BATTERY_SAVER_PROMPT = "uihints.last_battery_saver_prompt"; + private static final String LAST_CRASH_PROMPT = "uihints.last_crash_prompt"; UiHints(@NonNull KeyValueStore store) { super(store); @@ -154,4 +155,12 @@ public class UiHints extends SignalStoreValues { public void setLastBatterySaverPrompt(long time) { putLong(LAST_BATTERY_SAVER_PROMPT, time); } + + public void setLastCrashPrompt(long time) { + putLong(LAST_CRASH_PROMPT, time); + } + + public long getLastCrashPrompt() { + return getLong(LAST_CRASH_PROMPT, 0); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.kt b/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.kt index c884de0543..69eaa94ab9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/logging/PersistentLogger.kt @@ -112,7 +112,7 @@ class PersistentLogger( override fun run() { while (true) { requests.blockForRequests(buffer) - db.insert(buffer.flatMap { requestToEntries(it) }, System.currentTimeMillis()) + db.logs.insert(buffer.flatMap { requestToEntries(it) }, System.currentTimeMillis()) buffer.clear() requests.notifyFlushed() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogDataSource.kt index d20bd48d5f..374e36d5c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogDataSource.kt @@ -21,7 +21,7 @@ class LogDataSource( val logDatabase = LogDatabase.getInstance(application) override fun size(): Int { - return prefixLines.size + logDatabase.getLogCountBeforeTime(untilTime) + return prefixLines.size + logDatabase.logs.getLogCountBeforeTime(untilTime) } override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): List { @@ -29,9 +29,9 @@ class LogDataSource( return prefixLines.subList(start, start + length) } else if (start < prefixLines.size) { return prefixLines.subList(start, prefixLines.size) + - logDatabase.getRangeBeforeTime(0, length - (prefixLines.size - start), untilTime).map { convertToLogLine(it) } + logDatabase.logs.getRangeBeforeTime(0, length - (prefixLines.size - start), untilTime).map { convertToLogLine(it) } } else { - return logDatabase.getRangeBeforeTime(start - prefixLines.size, length, untilTime).map { convertToLogLine(it) } + return logDatabase.logs.getRangeBeforeTime(start - prefixLines.size, length, untilTime).map { convertToLogLine(it) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogRepository.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogRepository.java index dbb247db00..fba84a11ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogRepository.java @@ -116,7 +116,7 @@ public class SubmitDebugLogRepository { public void buildAndSubmitLog(@NonNull Callback> callback) { SignalExecutors.UNBOUNDED.execute(() -> { Log.blockUntilAllWritesFinished(); - LogDatabase.getInstance(context).trimToSize(); + LogDatabase.getInstance(context).logs().trimToSize(); callback.onResult(submitLogInternal(System.currentTimeMillis(), getPrefixLogLinesInternal(), Tracer.getInstance().serialize())); }); } @@ -140,7 +140,7 @@ public class SubmitDebugLogRepository { outputStream.putNextEntry(new ZipEntry("log.txt")); outputStream.write(prefixLines.toString().getBytes(StandardCharsets.UTF_8)); - try (LogDatabase.Reader reader = LogDatabase.getInstance(context).getAllBeforeTime(untilTime)) { + try (LogDatabase.LogTable.Reader reader = LogDatabase.getInstance(context).logs().getAllBeforeTime(untilTime)) { while (reader.hasNext()) { outputStream.write(reader.next().getBytes()); outputStream.write("\n".getBytes()); @@ -193,7 +193,7 @@ public class SubmitDebugLogRepository { stopwatch.split("front-matter"); - try (LogDatabase.Reader reader = LogDatabase.getInstance(context).getAllBeforeTime(untilTime)) { + try (LogDatabase.LogTable.Reader reader = LogDatabase.getInstance(context).logs().getAllBeforeTime(untilTime)) { while (reader.hasNext()) { gzipOutput.write(reader.next().getBytes()); gzipOutput.write("\n".getBytes()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogViewModel.java index 1eff722c3f..404b384a90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogViewModel.java @@ -54,7 +54,7 @@ public class SubmitDebugLogViewModel extends ViewModel { this.staticLines.addAll(staticLines); Log.blockUntilAllWritesFinished(); - LogDatabase.getInstance(ApplicationDependencies.getApplication()).trimToSize(); + LogDatabase.getInstance(ApplicationDependencies.getApplication()).logs().trimToSize(); LogDataSource dataSource = new LogDataSource(ApplicationDependencies.getApplication(), staticLines, firstViewTime); PagingConfig config = new PagingConfig.Builder().setPageSize(100) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/VitalsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/VitalsViewModel.kt new file mode 100644 index 0000000000..f0f855d2e4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/VitalsViewModel.kt @@ -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() + + val vitalsState: Observable + + 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 { + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 78066840e6..bef8f45349 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -115,6 +115,7 @@ public final class FeatureFlags { public static final String USERNAMES = "android.usernames"; public static final String INSTANT_VIDEO_PLAYBACK = "android.instantVideoPlayback"; private static final String CONVERSATION_ITEM_V2_TEXT = "android.conversationItemV2.text.2"; + public static final String CRASH_PROMPT_CONFIG = "android.crashPromptConfig"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -181,7 +182,8 @@ public final class FeatureFlags { PROMPT_BATTERY_SAVER, USERNAMES, INSTANT_VIDEO_PLAYBACK, - CONVERSATION_ITEM_V2_TEXT + CONVERSATION_ITEM_V2_TEXT, + CRASH_PROMPT_CONFIG ); @VisibleForTesting @@ -252,7 +254,8 @@ public final class FeatureFlags { PROMPT_FOR_NOTIFICATION_LOGS, PROMPT_FOR_NOTIFICATION_CONFIG, PROMPT_BATTERY_SAVER, - USERNAMES + USERNAMES, + CRASH_PROMPT_CONFIG ); /** @@ -662,6 +665,11 @@ public final class FeatureFlags { return getString(PROMPT_BATTERY_SAVER, "*"); } + /** Config object for what crashes to prompt about. */ + public static String crashPromptConfig() { + return getString(CRASH_PROMPT_CONFIG, ""); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java index f14d697243..2fc1da0ac4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/JsonUtils.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.util; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.type.TypeFactory; import org.json.JSONException; import org.json.JSONObject; @@ -10,6 +11,7 @@ import org.json.JSONObject; import java.io.IOException; import java.io.InputStream; import java.io.Reader; +import java.util.List; import javax.annotation.Nullable; @@ -40,6 +42,11 @@ public class JsonUtils { return objectMapper.readValue(serialized, clazz); } + public static List fromJsonArray(String serialized, Class clazz) throws IOException { + TypeFactory typeFactory = objectMapper.getTypeFactory(); + return objectMapper.readValue(serialized, typeFactory.constructCollectionType(List.class, clazz)); + } + public static String toJson(Object object) throws IOException { return objectMapper.writeValueAsString(object); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SignalUncaughtExceptionHandler.java b/app/src/main/java/org/thoughtcrime/securesms/util/SignalUncaughtExceptionHandler.java index 986b6ab2cf..102bc770f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SignalUncaughtExceptionHandler.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SignalUncaughtExceptionHandler.java @@ -4,6 +4,7 @@ import androidx.annotation.NonNull; import org.signal.core.util.ExceptionUtil; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.LogDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -39,7 +40,13 @@ public class SignalUncaughtExceptionHandler implements Thread.UncaughtExceptionH e = e.getCause(); } + String exceptionName = e.getClass().getCanonicalName(); + if (exceptionName == null) { + exceptionName = e.getClass().getName(); + } + Log.e(TAG, "", e, true); + LogDatabase.getInstance(ApplicationDependencies.getApplication()).crashes().saveCrash(System.currentTimeMillis(), exceptionName, e.getMessage(), ExceptionUtil.convertThrowableToString(e)); SignalStore.blockUntilAllWritesFinished(); Log.blockUntilAllWritesFinished(); ApplicationDependencies.getJobManager().flush(); diff --git a/app/src/main/res/layout/prompt_logs_bottom_sheet.xml b/app/src/main/res/layout/prompt_logs_bottom_sheet.xml index b5e967e019..94c9267e51 100644 --- a/app/src/main/res/layout/prompt_logs_bottom_sheet.xml +++ b/app/src/main/res/layout/prompt_logs_bottom_sheet.xml @@ -26,6 +26,7 @@ android:layout_gravity="center_horizontal"/> We noticed notifications are delayed. Submit debug log? - + Debug logs helps us diagnose and fix the issue, and do not contain identifying information. + + Signal encountered a problem. Submit debug log? Notifications may be delayed due to battery optimizations @@ -2792,6 +2794,8 @@ Signal Android Debug Log Submission Slow notifications + + Crash Submit diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt index 2e78e0e2be..55de359079 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt @@ -59,7 +59,10 @@ class SpinnerApplicationContext : ApplicationContext() { "keyvalue" to DatabaseConfig(db = { KeyValueDatabase.getInstance(this).sqlCipherDatabase }), "megaphones" to DatabaseConfig(db = { MegaphoneDatabase.getInstance(this).sqlCipherDatabase }), "localmetrics" to DatabaseConfig(db = { LocalMetricsDatabase.getInstance(this).sqlCipherDatabase }), - "logs" to DatabaseConfig(db = { LogDatabase.getInstance(this).sqlCipherDatabase }) + "logs" to DatabaseConfig( + db = { LogDatabase.getInstance(this).sqlCipherDatabase }, + columnTransformers = listOf(TimestampTransformer) + ) ), linkedMapOf( StorageServicePlugin.PATH to StorageServicePlugin() diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/database/TimestampTransformer.kt b/app/src/spinner/java/org/thoughtcrime/securesms/database/TimestampTransformer.kt index fa484695f1..f31a1d308f 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/database/TimestampTransformer.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/database/TimestampTransformer.kt @@ -11,7 +11,8 @@ import java.time.LocalDateTime object TimestampTransformer : ColumnTransformer { override fun matches(tableName: String?, columnName: String): Boolean { return columnName.contains("date", true) || - columnName.contains("timestamp", true) + columnName.contains("timestamp", true) || + columnName.contains("created_at", true) } override fun transform(tableName: String?, columnName: String, cursor: Cursor): String? { diff --git a/app/src/test/java/org/thoughtcrime/securesms/crash/CrashConfigTest.kt b/app/src/test/java/org/thoughtcrime/securesms/crash/CrashConfigTest.kt new file mode 100644 index 0000000000..2f2b7b0ba9 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/crash/CrashConfigTest.kt @@ -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 + + @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() + } +}