diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index 1ebbedd2bb..e5787d787e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -51,6 +51,7 @@ import org.session.libsignal.utilities.HTTP.isConnectedToNetwork import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.AppContext.configureKovenant import org.thoughtcrime.securesms.debugmenu.DebugActivity +import org.thoughtcrime.securesms.debugmenu.DebugLogger import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseModule.init import org.thoughtcrime.securesms.dependencies.OnAppStartupComponents @@ -89,6 +90,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio @Inject lateinit var startupComponents: Lazy @Inject lateinit var persistentLogger: Lazy + @Inject lateinit var debugLogger: Lazy @Inject lateinit var textSecurePreferences: Lazy @Inject lateinit var migrationManager: Lazy @@ -208,7 +210,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio } private fun initializeLogging() { - Log.initialize(AndroidLogger(), persistentLogger.get()) + Log.initialize(AndroidLogger(), persistentLogger.get(), debugLogger.get()) Logger.addLogger(object : Logger { private val tag = "LibSession" diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt index ac6369708a..0c1aca9c25 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.attachments import android.content.Context -import android.widget.Toast import androidx.compose.ui.unit.IntSize import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy @@ -17,8 +16,6 @@ import dagger.Lazy import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import network.loki.messenger.BuildConfig import okhttp3.HttpUrl.Companion.toHttpUrl import okio.BufferedSource @@ -31,8 +28,8 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.util.BitmapUtil -import org.thoughtcrime.securesms.util.CurrentActivityObserver import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import org.thoughtcrime.securesms.util.ImageUtils import java.time.Duration @@ -54,23 +51,14 @@ class AvatarReuploadWorker @AssistedInject constructor( private val configFactory: ConfigFactoryProtocol, private val avatarUploadManager: Lazy, private val localEncryptedFileInputStreamFactory: LocalEncryptedFileInputStream.Factory, - private val fileServerApi: FileServerApi, - private val prefs: TextSecurePreferences, - private val currentActivityObserver: CurrentActivityObserver, + private val fileServerApi: FileServerApi ) : CoroutineWorker(context, params) { /** * Log the given message and show a toast if in debug mode */ - private suspend inline fun logAndToast(message: String, e: Throwable? = null) { - Log.d(TAG, message, e) - - val context = currentActivityObserver.currentActivity.value ?: return - if (prefs.debugAvatarReupload || BuildConfig.DEBUG) { - withContext(Dispatchers.Main) { - Toast.makeText(context, "AvatarReupload[debug only]: $message", Toast.LENGTH_SHORT).show() - } - } + private fun log(message: String, e: Throwable? = null) { + Log.d(DebugLogGroup.AVATAR.label, "Avatar Reupload: $message", e) } override suspend fun doWork(): Result { @@ -79,13 +67,13 @@ class AvatarReuploadWorker @AssistedInject constructor( } if (profile == null) { - logAndToast("No profile picture set; nothing to do.") + log("No profile picture set; nothing to do.") return Result.success() } val localFile = AvatarDownloadManager.computeFileName(context, profile) if (!localFile.exists()) { - logAndToast("Avatar file is missing locally; nothing to do.") + log("Avatar file is missing locally; nothing to do.") return Result.success() } @@ -94,7 +82,7 @@ class AvatarReuploadWorker @AssistedInject constructor( // Check if the file exists and whether we need to do reprocessing, if we do, we reprocess and re-upload localEncryptedFileInputStreamFactory.create(localFile).use { stream -> if (stream.meta.hasPermanentDownloadError) { - logAndToast("Permanent download error for current avatar; nothing to do.") + log("Permanent download error for current avatar; nothing to do.") return Result.success() } @@ -103,7 +91,7 @@ class AvatarReuploadWorker @AssistedInject constructor( val source = stream.source().buffer() if ((lastUpdated != null && needsReProcessing(source)) || lastUpdated == null) { - logAndToast("About to start reuploading avatar.") + log("About to start reuploading avatar.") val attachment = attachmentProcessor.processAvatar( data = source.use { it.readByteArray() }, ) ?: return Result.failure() @@ -118,14 +106,14 @@ class AvatarReuploadWorker @AssistedInject constructor( } catch (e: CancellationException) { throw e } catch (e: NonRetryableException) { - logAndToast("Non-retryable error while reuploading avatar.", e) + log("Non-retryable error while reuploading avatar.", e) return Result.failure() } catch (e: Exception) { - logAndToast("Error while reuploading avatar.", e) + log("Error while reuploading avatar.", e) return Result.retry() } - logAndToast("Successfully reuploaded avatar.") + log("Successfully reuploaded avatar.") return Result.success() } } @@ -133,7 +121,7 @@ class AvatarReuploadWorker @AssistedInject constructor( // Otherwise, we only need to renew the same avatar on the server val parsed = fileServerApi.parseAttachmentUrl(profile.url.toHttpUrl()) - logAndToast("Renewing user avatar on ${parsed.fileServer}") + log("Renewing user avatar on ${parsed.fileServer}") try { fileServerApi.renew( fileId = parsed.fileId, @@ -149,7 +137,7 @@ class AvatarReuploadWorker @AssistedInject constructor( val now = Instant.now() if (fileExpiry?.isBefore(now) == true || (lastUpdated?.isBefore(now.minus(Duration.ofDays(12)))) == true) { - logAndToast("FileServer renew failed, trying to upload", e) + log("FileServer renew failed, trying to upload", e) val pictureData = localEncryptedFileInputStreamFactory.create(localFile).use { stream -> check(!stream.meta.hasPermanentDownloadError) { @@ -166,18 +154,18 @@ class AvatarReuploadWorker @AssistedInject constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - logAndToast("Error while reuploading avatar after renew failed.", e) + log("Error while reuploading avatar after renew failed.", e) return Result.failure() } - logAndToast("Successfully reuploaded avatar after renew failed.") + log("Successfully reuploaded avatar after renew failed.") } else { - logAndToast( "Not reuploading avatar after renew failed; last updated too recent.") + log( "Not reuploading avatar after renew failed; last updated too recent.") } return Result.success() } else { - logAndToast("Error while renewing avatar. Retrying...", e) + log("Error while renewing avatar. Retrying...", e) return Result.retry() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt index b4bd8db154..d9ffb76596 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt @@ -21,6 +21,7 @@ import org.session.libsession.utilities.Util import org.session.libsession.utilities.recipients.RemoteFile import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.castAwayType @@ -41,7 +42,7 @@ class AvatarUploadManager @Inject constructor( @ManagerScope scope: CoroutineScope, private val localEncryptedFileOutputStreamFactory: LocalEncryptedFileOutputStream.Factory, private val fileServerApi: FileServerApi, - private val attachmentProcessor: AttachmentProcessor, + private val attachmentProcessor: AttachmentProcessor ) : OnAppStartupComponent { init { // Manage scheduling/cancellation of the AvatarReuploadWorker based on login state @@ -99,7 +100,7 @@ class AvatarUploadManager @Inject constructor( customExpiresDuration = DEBUG_AVATAR_TTL.takeIf { prefs.forcedShortTTL() } ) - Log.d(TAG, "Avatar upload finished with $uploadResult") + Log.d(DebugLogGroup.AVATAR.label, "Avatar upload finished with $uploadResult") val remoteFile = RemoteFile.Encrypted(url = uploadResult.fileUrl, key = Bytes(result.key)) @@ -111,7 +112,7 @@ class AvatarUploadManager @Inject constructor( it.write(pictureData) } - Log.d(TAG, "Avatar file written to local storage") + Log.d(DebugLogGroup.AVATAR.label, "Avatar file written to local storage") // Now that we have the file both locally and remotely, we can update the user profile val oldPic = configFactory.withMutableUserConfigs { @@ -134,7 +135,7 @@ class AvatarUploadManager @Inject constructor( // If we had an old avatar, delete it from local storage val oldFile = AvatarDownloadManager.computeFileName(application, oldPic) if (oldFile.exists()) { - Log.d(TAG, "Deleting old avatar file: $oldFile") + Log.d(DebugLogGroup.AVATAR.label, "Deleting old avatar file: $oldFile") oldFile.delete() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt index d89c3b78c0..458968bb4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt @@ -9,8 +9,8 @@ class DebugActivity : FullComposeActivity() { @Composable override fun ComposeContent() { - DebugMenuScreen( - onClose = { finish() } + DebugMenuNavHost( + onBack = { finish() } ) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt new file mode 100644 index 0000000000..bc197eb605 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt @@ -0,0 +1,209 @@ +package org.thoughtcrime.securesms.debugmenu + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.DropDown +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.bold +import org.thoughtcrime.securesms.ui.theme.monospace +import org.thoughtcrime.securesms.util.DateUtils +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + + +@Composable +fun DebugLogScreen( + viewModel: DebugMenuViewModel, + onBack: () -> Unit, +){ + val flowLogs = remember { viewModel.debugLogs } + val logs by flowLogs.collectAsStateWithLifecycle(initialValue = emptyList()) + + DebugLogs( + logs = logs, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DebugLogs( + logs: List, + sendCommand: (DebugMenuViewModel.Commands) -> Unit, + onBack: () -> Unit, +){ + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + // App bar + BackAppBar(title = "Debug Logs", onBack = onBack) + }, + ) { contentPadding -> + val scrollState = rememberLazyListState() + + Column( + modifier = Modifier.fillMaxSize() + .padding(contentPadding) + .padding(LocalDimensions.current.smallSpacing) + ) { + var filter: DebugLogGroup? by remember { mutableStateOf(null) } + + DropDown( + selected = filter, + values = DebugLogGroup.entries, + onValueSelected = { filter = it }, + labeler = { it?.label ?: "Show All" }, + allowSelectingNullValue = true, + ) + + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + + Cell( + modifier = Modifier.weight(1f), + ) { + val haptics = LocalHapticFeedback.current + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(LocalDimensions.current.smallSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), + state = scrollState + ) { + items(items = logs.filter { filter == null || it.group == filter }) { log -> + Column( + modifier = Modifier.fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + sendCommand(DebugMenuViewModel.Commands.CopyLog(log)) + } + ) + } + ) { + Row { + val locale = remember(Unit) { Locale.getDefault() } + val formatter = remember(Unit){ DateTimeFormatter.ofPattern("HH:mm", locale)} + + Text( + text = Instant.ofEpochMilli(log.date.toEpochMilli()) + .atZone(ZoneId.systemDefault()) + .format(formatter), + style = LocalType.current.small.bold() + ) + + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + + Text( + text = "[${log.group.label}]", + style = LocalType.current.small.bold().copy( + color = log.group.color + ) + ) + } + + Spacer(Modifier.height(2.dp)) + + Text( + text = log.message, + style = LocalType.current.large.monospace().bold() + ) + } + } + } + } + + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) + ) { + AccentFillButtonRect( + modifier = Modifier.weight(1f), + text = "Copy all logs", + onClick = { + sendCommand(DebugMenuViewModel.Commands.CopyAllLogs) + } + ) + AccentFillButtonRect( + modifier = Modifier.weight(1f), + text = "Clear logs", + onClick = { + sendCommand(DebugMenuViewModel.Commands.ClearAllDebugLogs) + } + ) + } + } + } +} + +@Preview +@Composable +fun PrewviewDebugLogs( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + DebugLogs( + logs = listOf( + DebugLogData( + message = "This is a log", + group = DebugLogGroup.PRO_SUBSCRIPTION, + date = Instant.now(), + ), + DebugLogData( + message = "This is another log", + group = DebugLogGroup.PRO_SUBSCRIPTION, + date = Instant.now() - Duration.ofMinutes(4), + ), + DebugLogData( + message = "This is also a log", + group = DebugLogGroup.AVATAR, + date = Instant.now() - Duration.ofMinutes(7), + ), + ), + sendCommand = {}, + onBack = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt new file mode 100644 index 0000000000..e1a0421c1a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt @@ -0,0 +1,131 @@ +package org.thoughtcrime.securesms.debugmenu + +import android.app.Application +import android.widget.Toast +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.ui.theme.primaryGreen +import org.thoughtcrime.securesms.ui.theme.primaryOrange +import org.thoughtcrime.securesms.util.DateUtils +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +private const val MAX_LOG_ENTRIES = 200 + +/** + * A class that keeps track of certain logs and allows certain logs to pop as toasts + * To use: Set the tag as one of the known [DebugLogGroup] + */ +@Singleton +class DebugLogger @Inject constructor( + private val app: Application, + private val prefs: TextSecurePreferences, + private val dateUtils: DateUtils, + @ManagerScope private val scope: CoroutineScope +) : Log.Logger() { + private val prefPrefix: String = "debug_logger_" + + private val buffer = ArrayDeque(MAX_LOG_ENTRIES) + + private val logChanges = MutableSharedFlow( + replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + private val allowedTags: Set = + DebugLogGroup.entries.map { it.label.lowercase() }.toSet() + + private fun groupForTag(tag: String): DebugLogGroup? = + DebugLogGroup.entries.firstOrNull { it.label.equals(tag, ignoreCase = true) } + + val logSnapshots: Flow> + get() = logChanges.onStart { emit(Unit) }.map { currentSnapshot() } + + // In-memory cache for toast prefs + private val toastEnabled = java.util.EnumMap(DebugLogGroup::class.java).apply { + DebugLogGroup.entries.forEach { group -> + this[group] = prefs.getBooleanPreference(prefPrefix + group.label, false) + } + } + + fun currentSnapshot(): List = + synchronized(buffer) { buffer.toList().asReversed() } + + fun clearAll() { + synchronized(buffer) { buffer.clear() } + logChanges.tryEmit(Unit) + } + + fun showGroupToast(group: DebugLogGroup, showToast: Boolean) { + toastEnabled[group] = showToast + prefs.setBooleanPreference(prefPrefix + group.label, showToast) + } + + fun getGroupToastPreference(group: DebugLogGroup): Boolean = + toastEnabled[group] == true + + // ---- Log.Logger overrides (no “level” logic) ---- + override fun v(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun d(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun i(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun w(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun e(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun wtf(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun blockUntilAllWritesFinished() { /* no-op */ } + + private fun add(tag: String, message: String?, t: Throwable?) { + // Capture ONLY if tag is in our allow-list + if (!allowedTags.contains(tag.lowercase())) return + + val group = groupForTag(tag) ?: return + + val now = Instant.now() + val text = when { + !message.isNullOrBlank() -> message + t != null -> t.localizedMessage ?: t::class.java.simpleName + else -> "" // nothing meaningful + } + + val entry = DebugLogData( + message = text, + group = group, + date = now, + ) + + synchronized(buffer) { + if (buffer.size == MAX_LOG_ENTRIES) buffer.removeFirst() + buffer.addLast(entry) + } + logChanges.tryEmit(Unit) + + // Toast decision is independent from capture. + if (toastEnabled[group] == true) { + scope.launch(Dispatchers.Main) { + Toast.makeText(app, text, Toast.LENGTH_SHORT).show() + } + } + } +} + +data class DebugLogData( + val message: String, + val group: DebugLogGroup, + val date: Instant, +) + +enum class DebugLogGroup(val label: String, val color: Color){ + AVATAR("Avatar", primaryOrange), PRO_SUBSCRIPTION("Pro Subscription", primaryGreen) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index 4db3947687..3249da0ef1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -71,13 +71,15 @@ import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.LoadingDialog +import org.thoughtcrime.securesms.ui.components.SlimFillButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.Button import org.thoughtcrime.securesms.ui.components.ButtonType import org.thoughtcrime.securesms.ui.components.DropDown import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionSwitch -import org.thoughtcrime.securesms.ui.components.SlimOutlineButton +import org.thoughtcrime.securesms.ui.components.SlimFillButtonRect +import org.thoughtcrime.securesms.ui.components.SlimFillButtonRect import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -216,17 +218,34 @@ fun DebugMenu( ) } - if (uiState.dbInspectorState != DebugMenuViewModel.DatabaseInspectorState.NOT_AVAILABLE) { - DebugCell("Database inspector") { - Button( - onClick = { - sendCommand(DebugMenuViewModel.Commands.ToggleDatabaseInspector) - }, - text = if (uiState.dbInspectorState == DebugMenuViewModel.DatabaseInspectorState.STOPPED) - "Start" - else "Stop", - type = ButtonType.AccentFill, - ) + // Debug Logger + DebugCell( + "Debug Logger", + verticalArrangement = Arrangement.spacedBy(0.dp)) { + Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) + + SlimFillButtonRect( + modifier = Modifier.fillMaxWidth(), + text = "Show Debug Logs", + ) { + sendCommand(DebugMenuViewModel.Commands.NavigateTo(DebugMenuDestination.DebugMenuLogs)) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + Column { + DebugLogGroup.entries.forEach { logGroup -> + DebugSwitchRow( + text = "Show toasts for ${logGroup.label}", + checked = uiState.showToastForGroups[logGroup.label] == true, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ToggleDebugLogGroup( + group = logGroup, + showToast = it) + ) + } + ) + } } } @@ -409,6 +428,20 @@ fun DebugMenu( } } + if (uiState.dbInspectorState != DebugMenuViewModel.DatabaseInspectorState.NOT_AVAILABLE) { + DebugCell("Database inspector") { + SlimFillButtonRect( + modifier = Modifier.fillMaxWidth(), + onClick = { + sendCommand(DebugMenuViewModel.Commands.ToggleDatabaseInspector) + }, + text = if (uiState.dbInspectorState == DebugMenuViewModel.DatabaseInspectorState.STOPPED) + "Start" + else "Stop", + ) + } + } + // Fake contacts DebugCell("Generate fake contacts") { var prefix by remember { mutableStateOf("User-") } @@ -433,7 +466,7 @@ fun DebugMenu( ) } - SlimOutlineButton(modifier = Modifier.fillMaxWidth(), text = "Generate") { + SlimFillButtonRect(modifier = Modifier.fillMaxWidth(), text = "Generate") { sendCommand( GenerateContacts( prefix = prefix, @@ -446,7 +479,7 @@ fun DebugMenu( // Session Token DebugCell("Session Token") { // Schedule a test token-drop notification for 10 seconds from now - SlimOutlineButton( + SlimFillButtonRect( modifier = Modifier.fillMaxWidth(), text = "Schedule Token Page Notification (10s)", onClick = { sendCommand(ScheduleTokenNotification) } @@ -456,7 +489,7 @@ fun DebugMenu( // Keys DebugCell("User Details") { - SlimOutlineButton ( + SlimFillButtonRect ( text = "Copy Account ID", modifier = Modifier.fillMaxWidth(), onClick = { @@ -464,7 +497,7 @@ fun DebugMenu( } ) - SlimOutlineButton( + SlimFillButtonRect( text = "Copy 07-prefixed Version Blinded Public Key", modifier = Modifier.fillMaxWidth(), onClick = { @@ -493,7 +526,7 @@ fun DebugMenu( } ) - SlimOutlineButton( + SlimFillButtonRect( modifier = Modifier.fillMaxWidth(), text = "Clear All Trusted Downloads", ) { @@ -542,14 +575,14 @@ fun DebugMenu( } ) - SlimOutlineButton( + SlimFillButtonRect( modifier = Modifier.fillMaxWidth(), text = "Reset Push Token", ) { sendCommand(DebugMenuViewModel.Commands.ResetPushToken) } - SlimOutlineButton( + SlimFillButtonRect( modifier = Modifier.fillMaxWidth(), text = "Clear All Trusted Downloads", ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt new file mode 100644 index 0000000000..0eb22ac784 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.debugmenu + +import android.annotation.SuppressLint +import android.os.Parcelable +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import org.thoughtcrime.securesms.ui.NavigationAction +import org.thoughtcrime.securesms.ui.ObserveAsEvents +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.ui.horizontalSlideComposable + +// Destinations +sealed interface DebugMenuDestination: Parcelable { + @Serializable + @Parcelize + data object DebugMenuHome: DebugMenuDestination + + @Serializable + @Parcelize + data object DebugMenuLogs: DebugMenuDestination + +} + +@Serializable object DebugMenuGraph + +@SuppressLint("RestrictedApi") +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun DebugMenuNavHost( + startDestination: DebugMenuDestination = DebugMenuDestination.DebugMenuHome, + onBack: () -> Unit +){ + val navController = rememberNavController() + val navigator: UINavigator = remember { + UINavigator() + } + + val handleBack: () -> Unit = { + if (navController.previousBackStackEntry != null) { + navController.navigateUp() + } else { + onBack() // Finish activity if at root + } + } + + + ObserveAsEvents(flow = navigator.navigationActions) { action -> + when (action) { + is NavigationAction.Navigate -> navController.navigate( + action.destination + ) { + action.navOptions(this) + } + + NavigationAction.NavigateUp -> handleBack() + + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) + } + + is NavigationAction.ReturnResult -> {} + } + } + + NavHost( + navController = navController, + startDestination = DebugMenuGraph + ) { + navigation(startDestination = startDestination) { + // Home + horizontalSlideComposable { entry -> + val viewModel = navController.debugGraphViewModel(entry, navigator) + + DebugMenuScreen( + viewModel = viewModel, + onBack = onBack + ) + } + + // Logs + horizontalSlideComposable { entry -> + val viewModel = navController.debugGraphViewModel(entry, navigator) + + DebugLogScreen( + viewModel = viewModel, + onBack = handleBack + ) + } + } + } +} + +@Composable +private fun NavController.debugGraphViewModel( + entry: androidx.navigation.NavBackStackEntry, + navigator: UINavigator +): DebugMenuViewModel { + val graphEntry = remember(entry) { getBackStackEntry(DebugMenuGraph) } + return hiltViewModel< + DebugMenuViewModel, + DebugMenuViewModel.Factory + >(graphEntry) { factory -> factory.create(navigator) } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt index 6c0f22805a..7162f89014 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt @@ -4,20 +4,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel @Composable fun DebugMenuScreen( modifier: Modifier = Modifier, - debugMenuViewModel: DebugMenuViewModel = viewModel(), - onClose: () -> Unit + viewModel: DebugMenuViewModel, + onBack: () -> Unit ) { - val uiState by debugMenuViewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsState() DebugMenu( modifier = modifier, uiState = uiState, - sendCommand = debugMenuViewModel::onCommand, - onClose = onClose + sendCommand = viewModel::onCommand, + onClose = onBack ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index fb2b6786cf..ad9d31c0ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -7,14 +7,18 @@ import android.os.Build import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -43,13 +47,15 @@ import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager +import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.util.ClearDataUtils +import org.thoughtcrime.securesms.util.DateUtils import java.time.ZonedDateTime -import javax.inject.Inject -@HiltViewModel -class DebugMenuViewModel @Inject constructor( +@HiltViewModel(assistedFactory = DebugMenuViewModel.Factory::class) +class DebugMenuViewModel @AssistedInject constructor( + @Assisted private val navigator: UINavigator, @param:ApplicationContext private val context: Context, private val textSecurePreferences: TextSecurePreferences, private val tokenPageNotificationManager: TokenPageNotificationManager, @@ -62,10 +68,17 @@ class DebugMenuViewModel @Inject constructor( private val conversationRepository: ConversationRepository, private val databaseInspector: DatabaseInspector, private val tokenFetcher: TokenFetcher, + private val debugLogger: DebugLogger, + private val dateUtils: DateUtils, subscriptionManagers: Set<@JvmSuppressWildcards SubscriptionManager>, ) : ViewModel() { private val TAG = "DebugMenu" + @AssistedFactory + interface Factory { + fun create(navigator: UINavigator): DebugMenuViewModel + } + private val _uiState = MutableStateFlow( UIState( currentEnvironment = textSecurePreferences.getEnvironment().label, @@ -113,11 +126,14 @@ class DebugMenuViewModel @Inject constructor( withinQuickRefund = textSecurePreferences.getDebugIsWithinQuickRefund(), availableAltFileServers = TEST_FILE_SERVERS, alternativeFileServer = textSecurePreferences.alternativeFileServer, + showToastForGroups = getDebugGroupToastPref() ) ) val uiState: StateFlow get() = _uiState + val debugLogs: Flow> get() = debugLogger.logSnapshots + init { if (databaseInspector.available) { viewModelScope.launch { @@ -368,9 +384,66 @@ class DebugMenuViewModel @Inject constructor( _uiState.update { it.copy(alternativeFileServer = command.fileServer) } textSecurePreferences.alternativeFileServer = command.fileServer } + + is Commands.NavigateTo -> { + viewModelScope.launch { + navigator.navigate(command.destination) + } + } + + is Commands.ToggleDebugLogGroup -> { + debugLogger.showGroupToast(command.group, command.showToast) + _uiState.update { + it.copy(showToastForGroups = getDebugGroupToastPref()) + } + } + + is Commands.ClearAllDebugLogs -> { + debugLogger.clearAll() + } + + is Commands.CopyAllLogs -> { + val logs = debugLogger.currentSnapshot().joinToString("\n\n") { + "${dateUtils.getLocaleFormattedTime(it.date.toEpochMilli())}: ${it.message}" + } + + val clip = ClipData.newPlainText("Debug Logs", logs) + clipboardManager.setPrimaryClip(ClipData(clip)) + + // Show a toast if the version is below Android 13 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + context, + "Copied Debug Logs to clipboard", + Toast.LENGTH_SHORT + ).show() + } + } + + is Commands.CopyLog -> { + val log = "${dateUtils.getLocaleFormattedTime(command.log.date.toEpochMilli())}: ${command.log.message}" + + val clip = ClipData.newPlainText("Debug Log", log) + clipboardManager.setPrimaryClip(ClipData(clip)) + + // Show a toast if the version is below Android 13 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + context, + "Copied Debug Log to clipboard", + Toast.LENGTH_SHORT + ).show() + } + } } } + private fun getDebugGroupToastPref(): Map { + return DebugLogGroup.entries.associate { group -> + group.label to debugLogger.getGroupToastPreference(group) + } + } + private fun showEnvironmentWarningDialog(environment: String) { if(environment == _uiState.value.currentEnvironment) return val env = Environment.entries.firstOrNull { it.label == environment } ?: return @@ -482,6 +555,7 @@ class DebugMenuViewModel @Inject constructor( val withinQuickRefund: Boolean, val alternativeFileServer: FileServer? = null, val availableAltFileServers: List = emptyList(), + val showToastForGroups: Map = emptyMap(), ) enum class DatabaseInspectorState { @@ -539,6 +613,11 @@ class DebugMenuViewModel @Inject constructor( data object ToggleDebugAvatarReupload : Commands() data object ResetPushToken : Commands() data class SelectAltFileServer(val fileServer: FileServer?) : Commands() + data class NavigateTo(val destination: DebugMenuDestination) : Commands() + data class ToggleDebugLogGroup(val group: DebugLogGroup, val showToast: Boolean) : Commands() + data object ClearAllDebugLogs : Commands() + data object CopyAllLogs : Commands() + data class CopyLog(val log: DebugLogData) : Commands() } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index 82e904ad48..cc7b20e95e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -156,7 +156,7 @@ fun ProSettingsNavHost( } @Composable -fun NavController.proGraphViewModel( +private fun NavController.proGraphViewModel( entry: androidx.navigation.NavBackStackEntry, navigator: UINavigator ): ProSettingsViewModel { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 5b10c2c273..26239d97db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -419,7 +419,7 @@ fun Cell( modifier: Modifier = Modifier, dropShadow: Boolean = false, bgColor: Color = LocalColors.current.backgroundSecondary, - content: @Composable () -> Unit + content: @Composable BoxScope.() -> Unit ) { Box( modifier = modifier diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 106a65a4f9..dce3d0ee46 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import org.thoughtcrime.securesms.util.CurrentActivityObserver @@ -51,7 +52,7 @@ class PlayStoreSubscriptionManager @Inject constructor( private val application: Application, @param:ManagerScope private val scope: CoroutineScope, private val currentActivityObserver: CurrentActivityObserver, - private val prefs: TextSecurePreferences + private val prefs: TextSecurePreferences, ) : SubscriptionManager { override val id = "google_play_store" override val name = "Google Play Store" @@ -84,7 +85,7 @@ class PlayStoreSubscriptionManager @Inject constructor( private val billingClient by lazy { BillingClient.newBuilder(application) .setListener { result, purchases -> - Log.d(TAG, "onPurchasesUpdated: $result, $purchases") + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "onPurchasesUpdated: $result, $purchases") if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { purchases.firstOrNull()?.let{ scope.launch { @@ -99,7 +100,7 @@ class PlayStoreSubscriptionManager @Inject constructor( } } } else { - Log.w(TAG, "Purchase failed or cancelled: $result") + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Purchase failed or cancelled: $result") scope.launch { _purchaseEvents.emit(PurchaseEvent.Cancelled) } @@ -156,7 +157,7 @@ class PlayStoreSubscriptionManager @Inject constructor( // If user has an existing subscription, configure upgrade/downgrade if (existingPurchase != null) { - Log.d(TAG, "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION") + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION") billingFlowParamsBuilder.setSubscriptionUpdateParams( BillingFlowParams.SubscriptionUpdateParams.newBuilder() @@ -183,7 +184,7 @@ class PlayStoreSubscriptionManager @Inject constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.e(TAG, "Error purchase plan", e) + Log.e(DebugLogGroup.PRO_SUBSCRIPTION.label, "Error purchase plan", e) withContext(Dispatchers.Main) { Toast.makeText(application, application.getString(R.string.errorGeneric), Toast.LENGTH_LONG).show() @@ -218,7 +219,7 @@ class PlayStoreSubscriptionManager @Inject constructor( } override fun onBillingSetupFinished(result: BillingResult) { - Log.d(TAG, "onBillingSetupFinished with $result") + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "onBillingSetupFinished with $result") if (result.responseCode == BillingClient.BillingResponseCode.OK) { _playBillingAvailable.update { true } } @@ -243,7 +244,7 @@ class PlayStoreSubscriptionManager @Inject constructor( it.purchaseState == Purchase.PurchaseState.PURCHASED //todo PRO Should we also OR PENDING here? } } catch (e: Exception) { - Log.e(TAG, "Error querying existing subscription", e) + Log.e(DebugLogGroup.PRO_SUBSCRIPTION.label, "Error querying existing subscription", e) null } } @@ -278,7 +279,7 @@ class PlayStoreSubscriptionManager @Inject constructor( val productDetails = result.productDetailsList?.firstOrNull() ?: run { - Log.w(TAG, "No ProductDetails returned for product id session_pro") + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "No ProductDetails returned for product id session_pro") return emptyList() } @@ -290,7 +291,7 @@ class PlayStoreSubscriptionManager @Inject constructor( return availablePlans.mapNotNull { duration -> val offer = offersByBasePlan[duration.id] if (offer == null) { - Log.w(TAG, "No offer found for basePlanId=${duration.id}") + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "No offer found for basePlanId=${duration.id}") return@mapNotNull null }