commit 819820b5c085295a4e7ade3501657db4c44cb45b
parent dd8590d274d9496ef35b8dd1bdfd46561af53a23
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Fri, 24 May 2024 23:18:07 +0200

feat(tracker): auto purge, filters, export

Diffstat:
Mapp/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt | 10++++++++--
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt | 285+++++++++++++------------------------------------------------------------------
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/LogsTab.kt | 598+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/src/main/assets/lang/en_US.json | 4++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt | 39+++++++++++++++++++++++++++++++++++----
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt | 6+++++-
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt | 7+++++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt | 25++++++-------------------
Acommon/src/main/kotlin/me/rhunk/snapenhance/common/util/Purge.kt | 25+++++++++++++++++++++++++
9 files changed, 734 insertions(+), 265 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -28,6 +28,7 @@ import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.common.bridge.wrapper.LoggerWrapper import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.common.config.ModConfig +import me.rhunk.snapenhance.common.util.getPurgeTime import me.rhunk.snapenhance.e2ee.E2EEImplementation import me.rhunk.snapenhance.scripting.RemoteScriptManager import me.rhunk.snapenhance.storage.AppDatabase @@ -44,7 +45,6 @@ import java.io.ByteArrayInputStream import java.lang.ref.WeakReference import java.security.cert.CertificateFactory import java.security.cert.X509Certificate -import kotlin.time.Duration.Companion.days class RemoteSideContext( @@ -117,9 +117,15 @@ class RemoteSideContext( taskManager.init() config.root.messaging.messageLogger.takeIf { it.globalState == true - }?.getAutoPurgeTime()?.let { + }?.autoPurge?.let { getPurgeTime(it.getNullable()) }?.let { messageLogger.purgeAll(it) } + + config.root.friendTracker.takeIf { + it.globalState == true + }?.autoPurge?.let { getPurgeTime(it.getNullable()) }?.let { + messageLogger.purgeTrackerLogs(it) + } } } }.onFailure { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt @@ -14,10 +14,14 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.SaveAlt import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -27,25 +31,17 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.PopupProperties import androidx.navigation.NavBackStackEntry -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.common.bridge.wrapper.TrackerLog -import me.rhunk.snapenhance.common.data.MessagingFriendInfo -import me.rhunk.snapenhance.common.data.TrackerEventType import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.storage.* import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper import me.rhunk.snapenhance.ui.util.coil.BitmojiImage import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset -import java.text.DateFormat @OptIn(ExperimentalFoundationApi::class) @@ -56,242 +52,48 @@ class FriendTrackerManagerRoot : Routes.Route() { private val titles = listOf("Logs", "Rules") private var currentPage by mutableIntStateOf(0) + private lateinit var logDeleteAction : () -> Unit + private lateinit var exportAction : () -> Unit - override val floatingActionButton: @Composable () -> Unit = { - if (currentPage == 1) { - ExtendedFloatingActionButton( - icon = { Icon(Icons.Default.Add, contentDescription = "Add Rule") }, - expanded = true, - text = { Text("Add Rule") }, - onClick = { routes.editRule.navigate() } - ) - } - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - private fun LogsTab() { - val coroutineScope = rememberCoroutineScope() - - val logs = remember { mutableStateListOf<TrackerLog>() } - var lastTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) } - var filterType by remember { mutableStateOf(FilterType.USERNAME) } - - var filter by remember { mutableStateOf("") } - var searchTimeoutJob by remember { mutableStateOf<Job?>(null) } - - suspend fun loadNewLogs() { - withContext(Dispatchers.IO) { - logs.addAll(context.messageLogger.getLogs(lastTimestamp, filter = { - when (filterType) { - FilterType.USERNAME -> it.username.contains(filter, ignoreCase = true) - FilterType.CONVERSATION -> it.conversationTitle?.contains(filter, ignoreCase = true) == true || (it.username == filter && !it.isGroup) - FilterType.EVENT -> it.eventType.contains(filter, ignoreCase = true) - } - }).apply { - lastTimestamp = minOfOrNull { it.timestamp } ?: lastTimestamp - }) - } - } + private lateinit var activityLauncherHelper: ActivityLauncherHelper - suspend fun resetAndLoadLogs() { - logs.clear() - lastTimestamp = Long.MAX_VALUE - loadNewLogs() - } - - Column( - modifier = Modifier.fillMaxSize() - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - var showAutoComplete by remember { mutableStateOf(false) } - var dropDownExpanded by remember { mutableStateOf(false) } + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } - ExposedDropdownMenuBox( - expanded = showAutoComplete, - onExpandedChange = { showAutoComplete = it }, + override val floatingActionButton: @Composable () -> Unit = { + when (currentPage) { + 0 -> { + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - TextField( - value = filter, - modifier = Modifier - .fillMaxWidth() - .menuAnchor() - .padding(8.dp), - onValueChange = { - filter = it - coroutineScope.launch { - searchTimeoutJob?.cancel() - searchTimeoutJob = coroutineScope.launch { - delay(200) - showAutoComplete = true - resetAndLoadLogs() - } - } - }, - placeholder = { Text("Search") }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent - ), - maxLines = 1, - leadingIcon = { - ExposedDropdownMenuBox( - expanded = dropDownExpanded, - onExpandedChange = { dropDownExpanded = it }, - ) { - ElevatedCard( - modifier = Modifier - .menuAnchor() - .padding(2.dp) - ) { - Text(filterType.name, modifier = Modifier.padding(8.dp)) - } - DropdownMenu(expanded = dropDownExpanded, onDismissRequest = { - dropDownExpanded = false - }) { - FilterType.entries.forEach { type -> - DropdownMenuItem(onClick = { - filter = "" - filterType = type - dropDownExpanded = false - coroutineScope.launch { - resetAndLoadLogs() - } - }, text = { - Text(type.name) - }) - } - } - } - }, - trailingIcon = { - if (filter != "") { - IconButton(onClick = { - filter = "" - coroutineScope.launch { - resetAndLoadLogs() - } - }) { - Icon(Icons.Default.Clear, contentDescription = "Clear") - } - } - - DropdownMenu( - expanded = showAutoComplete, - onDismissRequest = { - showAutoComplete = false - }, - properties = PopupProperties(focusable = false), - ) { - val suggestedEntries = remember(filter) { - mutableStateListOf<String>() - } - - LaunchedEffect(filter) { - launch(Dispatchers.IO) { - suggestedEntries.addAll(when (filterType) { - FilterType.USERNAME -> context.messageLogger.findUsername(filter) - FilterType.CONVERSATION -> context.messageLogger.findConversation(filter) + context.messageLogger.findUsername(filter) - FilterType.EVENT -> TrackerEventType.entries.filter { it.name.contains(filter, ignoreCase = true) }.map { it.key } - }.take(5)) - } - } - - suggestedEntries.forEach { entry -> - DropdownMenuItem(onClick = { - filter = entry - coroutineScope.launch { - resetAndLoadLogs() - } - showAutoComplete = false - }, text = { - Text(entry) - }) - } - } - }, + ExtendedFloatingActionButton( + icon = { Icon(Icons.Default.SaveAlt, contentDescription = "Export") }, + expanded = true, + text = { Text("Export") }, + onClick = { + context.coroutineScope.launch { exportAction() } + } ) - } - } - - LazyColumn( - modifier = Modifier.weight(1f) - ) { - item { - if (logs.isEmpty()) { - Text("No logs found", modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), textAlign = TextAlign.Center, fontWeight = FontWeight.Light) - } - } - items(logs, key = { it.userId + it.id }) { log -> - ElevatedCard( - modifier = Modifier - .fillMaxWidth() - .padding(5.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - var databaseFriend by remember { mutableStateOf<MessagingFriendInfo?>(null) } - - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - databaseFriend = context.database.getFriendInfo(log.userId) - } - } - BitmojiImage( - modifier = Modifier.padding(10.dp), - size = 70, - context = context, - url = databaseFriend?.takeIf { it.bitmojiId != null }?.let { - BitmojiSelfie.getBitmojiSelfie(it.selfieId, it.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) - }, - ) - - Column( - modifier = Modifier - .weight(1f), - ) { - Text(databaseFriend?.displayName?.let { - "$it (${log.username})" - } ?: log.username, lineHeight = 20.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("${log.eventType} in ${log.conversationTitle}", fontSize = 15.sp, fontWeight = FontWeight.Light, lineHeight = 20.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - DateFormat.getDateTimeInstance().format(log.timestamp), - fontSize = 10.sp, - fontWeight = FontWeight.Light, - lineHeight = 15.sp, - ) - } - - OutlinedIconButton( - onClick = { - context.messageLogger.deleteTrackerLog(log.id) - logs.remove(log) - } - ) { - Icon(Icons.Default.DeleteOutline, contentDescription = "Delete") - } + ExtendedFloatingActionButton( + icon = { Icon(Icons.Default.DeleteOutline, contentDescription = "Delete") }, + expanded = true, + text = { Text("Delete") }, + onClick = { + context.coroutineScope.launch { logDeleteAction() } } - } - } - item { - Spacer(modifier = Modifier.height(16.dp)) - - LaunchedEffect(lastTimestamp) { - loadNewLogs() - } + ) } } + 1 -> { + ExtendedFloatingActionButton( + icon = { Icon(Icons.Default.Add, contentDescription = "Add Rule") }, + expanded = true, + text = { Text("Add Rule") }, + onClick = { routes.editRule.navigate() } + ) + } } - } @Composable @@ -461,7 +263,12 @@ class FriendTrackerManagerRoot : Routes.Route() { state = pagerState ) { page -> when (page) { - 0 -> LogsTab() + 0 -> LogsTab( + context = context, + activityLauncherHelper = activityLauncherHelper, + deleteAction = { logDeleteAction = it }, + exportAction = { exportAction = it } + ) 1 -> ConfigRulesTab() } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/LogsTab.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/LogsTab.kt @@ -0,0 +1,597 @@ +package me.rhunk.snapenhance.ui.manager.pages.tracker + +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.PopupProperties +import com.google.gson.stream.JsonWriter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.common.bridge.wrapper.TrackerLog +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.TrackerEventType +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.storage.getFriendInfo +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.coil.BitmojiImage +import me.rhunk.snapenhance.ui.util.saveFile +import java.text.DateFormat + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogsTab( + context: RemoteSideContext, + activityLauncherHelper: ActivityLauncherHelper, + deleteAction: (() -> Unit) -> Unit, + exportAction: (() -> Unit) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + + val logs = remember { mutableStateListOf<TrackerLog>() } + var isLoading by remember { mutableStateOf(false) } + var pageIndex by remember { mutableIntStateOf(0) } + var filterType by remember { mutableStateOf(FriendTrackerManagerRoot.FilterType.USERNAME) } + var reverseSortOrder by remember { mutableStateOf(true) } + val sinceDatePickerState = rememberDatePickerState( + initialDisplayMode = DisplayMode.Picker + ) + + var filter by remember { mutableStateOf("") } + var searchTimeoutJob by remember { mutableStateOf<Job?>(null) } + + fun getPaginatedLogs(pageIndex: Int) = context.messageLogger.getLogs( + pageIndex = pageIndex, + pageSize = 30, + timestamp = sinceDatePickerState.selectedDateMillis, + reverseOrder = reverseSortOrder, + filter = { + when (filterType) { + FriendTrackerManagerRoot.FilterType.USERNAME -> it.username.contains(filter, ignoreCase = true) + FriendTrackerManagerRoot.FilterType.CONVERSATION -> it.conversationTitle?.contains(filter, ignoreCase = true) == true || (it.username == filter && !it.isGroup) + FriendTrackerManagerRoot.FilterType.EVENT -> it.eventType.contains(filter, ignoreCase = true) + } + }) + + suspend fun loadNewLogs() { + withContext(Dispatchers.IO) { + logs.addAll(getPaginatedLogs(pageIndex).apply { + pageIndex += 1 + }) + } + } + + suspend fun resetAndLoadLogs() { + isLoading = true + logs.clear() + pageIndex = 0 + loadNewLogs() + isLoading = false + } + + var showDeleteDialog by remember { mutableStateOf(false) } + var showExportSelectionDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + deleteAction { showDeleteDialog = true } + exportAction { showExportSelectionDialog = true } + } + + if (showDeleteDialog) { + val deleteCoroutineScope = rememberCoroutineScope { Dispatchers.IO } + var deleteLogsTask by remember { mutableStateOf<Job?>(null) } + var deletedLogsCount by remember { mutableIntStateOf(0) } + + fun deleteLogs() { + deleteLogsTask = deleteCoroutineScope.launch { + var index = 0 + while (true) { + val newLogs = getPaginatedLogs(index++) + if (newLogs.isEmpty()) { + break + } + newLogs.forEach { + context.messageLogger.deleteTrackerLog(it.id) + deletedLogsCount++ + } + } + + withContext(Dispatchers.Main) { + delay(500) + resetAndLoadLogs() + context.shortToast("Deleted $deletedLogsCount logs") + showDeleteDialog = false + } + } + } + + DisposableEffect(Unit) { + onDispose { + deleteLogsTask?.cancel() + } + } + + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete logs?") }, + text = { + if (deleteLogsTask != null) { + Text("Deleting $deletedLogsCount logs...") + } else { + Text("This will delete logs based on the current filter and the search query. This action cannot be undone.") + } + }, + confirmButton = { + Button( + enabled = deleteLogsTask == null, + onClick = { + deleteLogs() + } + ) { + if (deleteLogsTask != null) { + CircularProgressIndicator(modifier = Modifier + .size(30.dp), + strokeWidth = 3.dp + ) + } else { + Text("Delete") + } + } + }, + dismissButton = { + Button(onClick = { showDeleteDialog = false }) { + Text(context.translation["button.cancel"]) + } + } + ) + } + + if (showExportSelectionDialog) { + val exportCoroutineScope = rememberCoroutineScope { Dispatchers.IO } + var exportTask by remember { mutableStateOf<Job?>(null) } + var exportType by remember { mutableStateOf("json") } + + fun exportLogs() { + activityLauncherHelper.saveFile("tracker_logs_${System.currentTimeMillis()}.$exportType") { uri -> + exportTask = exportCoroutineScope.launch { + context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { + val writer = it.writer() + val jsonWriter by lazy { + JsonWriter(writer).apply { + setIndent(" ") + beginArray() + } + } + + var index = 0 + while (true) { + val newLogs = getPaginatedLogs(index++) + if (newLogs.isEmpty()) { + break + } + newLogs.forEach { log -> + when (exportType) { + "json" -> { + jsonWriter.jsonValue(log.toJson().toString()) + } + "csv" -> { + writer.write(log.toCsv()) + writer.write("\n") + } + } + writer.flush() + } + } + when (exportType) { + "json" -> { + jsonWriter.endArray() + jsonWriter.close() + } + "csv" -> writer.close() + } + } + }.apply { + invokeOnCompletion { + exportTask = null + showExportSelectionDialog = false + if (it == null) { + context.shortToast("Exported logs!") + } else { + context.log.error("Failed to export logs", it) + context.shortToast("Failed to export logs. Check logcat for more details.") + } + } + } + } + } + + AlertDialog( + onDismissRequest = { showExportSelectionDialog = false }, + title = { Text("Export logs?") }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (exportTask != null) { + Text("Exporting logs...") + } else { + Text("This will export logs based on the current filter and the search query.") + Spacer(modifier = Modifier.height(10.dp)) + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + Card( + modifier = Modifier + .menuAnchor() + .padding(2.dp) + ) { + Text("Export as $exportType", modifier = Modifier.padding(8.dp)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { + expanded = false + }) { + listOf("json", "csv").forEach { type -> + DropdownMenuItem(onClick = { + exportType = type + expanded = false + }, text = { + Text(type) + }) + } + } + } + } + } + }, + confirmButton = { + Button( + enabled = exportTask == null, + onClick = { + exportLogs() + } + ) { + if (exportTask != null) { + CircularProgressIndicator(modifier = Modifier + .size(30.dp), + strokeWidth = 3.dp + ) + } else { + Text("Export") + } + } + }, + dismissButton = { + Button(onClick = { showExportSelectionDialog = false }) { + Text(context.translation["button.cancel"]) + } + } + ) + } + + + @Composable + fun FilterSelection( + selectionExpanded: MutableState<Boolean> + ) { + var dropDownExpanded by remember { mutableStateOf(false) } + var showDatePicker by remember { mutableStateOf(false) } + + if (showDatePicker) { + DatePickerDialog(onDismissRequest = { + showDatePicker = false + }, confirmButton = {}) { + DatePicker( + state = sinceDatePickerState, + modifier = Modifier.weight(1f), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Button(onClick = { + showDatePicker = false + sinceDatePickerState.selectedDateMillis = null + }) { + Text(context.translation["button.cancel"]) + } + Button(onClick = { + showDatePicker = false + }) { + Text(context.translation["button.ok"]) + } + } + } + } + + DropdownMenu(expanded = selectionExpanded.value, onDismissRequest = { + selectionExpanded.value = false + }) { + Column( + modifier = Modifier + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val rowHSpacing = 10.dp + + Text("Filters", fontWeight = FontWeight.Bold, fontSize = 20.sp) + Row( + horizontalArrangement = Arrangement.spacedBy(rowHSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Search by") + ExposedDropdownMenuBox( + expanded = dropDownExpanded, + onExpandedChange = { dropDownExpanded = it }, + ) { + Card( + modifier = Modifier + .menuAnchor() + .padding(2.dp) + ) { + Text(filterType.name, modifier = Modifier.padding(8.dp)) + } + DropdownMenu(expanded = dropDownExpanded, onDismissRequest = { + dropDownExpanded = false + }) { + FriendTrackerManagerRoot.FilterType.entries.forEach { type -> + DropdownMenuItem(onClick = { + filter = "" + filterType = type + dropDownExpanded = false + coroutineScope.launch { + resetAndLoadLogs() + } + }, text = { + Text(type.name) + }) + } + } + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(rowHSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Newest first") + Switch( + checked = reverseSortOrder, + onCheckedChange = { + reverseSortOrder = it + selectionExpanded.value = false + } + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(rowHSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(if (reverseSortOrder) "Since" else "Until") + Button(onClick = { + showDatePicker = true + }) { + Text(remember(showDatePicker) { + sinceDatePickerState.selectedDateMillis?.let { + DateFormat.getDateInstance().format(it) + } ?: "Pick a date" + }) + } + } + } + } + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + var showAutoComplete by remember { mutableStateOf(false) } + val showFilterSelection = remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = showAutoComplete, + onExpandedChange = { showAutoComplete = it }, + ) { + TextField( + value = filter, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + .padding(8.dp), + onValueChange = { + filter = it + coroutineScope.launch { + searchTimeoutJob?.cancel() + searchTimeoutJob = coroutineScope.launch { + delay(200) + showAutoComplete = true + resetAndLoadLogs() + } + } + }, + placeholder = { Text("Search") }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent + ), + maxLines = 1, + leadingIcon = { + IconButton( + onClick = { + showFilterSelection.value = !showFilterSelection.value + }, + modifier = Modifier + .padding(2.dp) + ) { + Icon(Icons.Default.FilterList, contentDescription = "Filter") + } + FilterSelection(showFilterSelection) + if (showFilterSelection.value) { + DisposableEffect(Unit) { + onDispose { + coroutineScope.launch { + resetAndLoadLogs() + } + } + } + } + }, + trailingIcon = { + if (filter != "") { + IconButton(onClick = { + filter = "" + coroutineScope.launch { + resetAndLoadLogs() + } + }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + + DropdownMenu( + expanded = showAutoComplete, + onDismissRequest = { + showAutoComplete = false + }, + properties = PopupProperties(focusable = false), + ) { + val suggestedEntries = remember(filter) { + mutableStateListOf<String>() + } + + LaunchedEffect(filter) { + launch(Dispatchers.IO) { + suggestedEntries.addAll(when (filterType) { + FriendTrackerManagerRoot.FilterType.USERNAME -> context.messageLogger.findUsername(filter) + FriendTrackerManagerRoot.FilterType.CONVERSATION -> context.messageLogger.findConversation(filter) + context.messageLogger.findUsername(filter) + FriendTrackerManagerRoot.FilterType.EVENT -> TrackerEventType.entries.filter { it.name.contains(filter, ignoreCase = true) }.map { it.key } + }.take(5)) + } + } + + suggestedEntries.forEach { entry -> + DropdownMenuItem(onClick = { + filter = entry + coroutineScope.launch { + resetAndLoadLogs() + } + showAutoComplete = false + }, text = { + Text(entry) + }) + } + } + }, + ) + } + } + + LazyColumn( + modifier = Modifier.weight(1f) + ) { + item { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + if (logs.isEmpty() && !isLoading) { + Text("No logs found", modifier = Modifier.padding(16.dp), fontWeight = FontWeight.Light, textAlign = TextAlign.Center) + } + } + } + items(logs, key = { it.userId + it.id }) { log -> + var databaseFriend by remember { mutableStateOf<MessagingFriendInfo?>(null) } + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + databaseFriend = context.database.getFriendInfo(log.userId) + } + } + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(3.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + BitmojiImage( + modifier = Modifier.padding(5.dp), + size = 55, + context = context, + url = databaseFriend?.takeIf { it.bitmojiId != null }?.let { + BitmojiSelfie.getBitmojiSelfie(it.selfieId, it.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) + }, + ) + + Column( + modifier = Modifier + .weight(1f), + ) { + Text(databaseFriend?.displayName?.let { + "$it (${log.username})" + } ?: log.username, lineHeight = 20.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 14.sp) + Text("${log.eventType} in ${log.conversationTitle}", fontSize = 10.sp, fontWeight = FontWeight.Light, lineHeight = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + DateFormat.getDateTimeInstance().format(log.timestamp), + fontSize = 10.sp, + fontWeight = FontWeight.Light, + lineHeight = 15.sp, + ) + } + + IconButton( + onClick = { + context.messageLogger.deleteTrackerLog(log.id) + logs.remove(log) + } + ) { + Icon(Icons.Default.DeleteOutline, contentDescription = "Delete") + } + } + } + } + item { + Spacer(modifier = Modifier.height(16.dp)) + + LaunchedEffect(pageIndex) { + loadNewLogs() + } + } + + item { + Spacer(modifier = Modifier.height(100.dp)) + } + } + } +}+ \ No newline at end of file diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -1081,6 +1081,10 @@ "allow_running_in_background": { "name": "Allow Running in Background", "description": "Allows the tracker to run in the background. Note: This will significantly drain your battery" + }, + "auto_purge": { + "name": "Auto Purge", + "description": "Automatically deletes cached events that are older than the specified amount of time" } } } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt @@ -38,7 +38,25 @@ class TrackerLog( val userId: String, val eventType: String, val data: String -) +) { + fun toJson(): JsonObject { + return JsonObject().apply { + addProperty("id", id) + addProperty("timestamp", timestamp) + addProperty("conversationId", conversationId) + addProperty("conversationTitle", conversationTitle) + addProperty("isGroup", isGroup) + addProperty("username", username) + addProperty("userId", userId) + addProperty("eventType", eventType) + addProperty("data", data) + } + } + + fun toCsv(): String { + return "$id,$timestamp,$conversationId,$conversationTitle,$isGroup,$username,$userId,$eventType,$data" + } +} class LoggerWrapper( val databaseFile: File @@ -283,12 +301,18 @@ class LoggerWrapper( } fun getLogs( - lastTimestamp: Long, + pageIndex: Int, + pageSize: Int, + reverseOrder: Boolean = true, + timestamp: Long? = null, filter: ((TrackerLog) -> Boolean)? = null ): List<TrackerLog> { - return database.rawQuery("SELECT * FROM tracker_events WHERE timestamp < ? ORDER BY timestamp DESC", arrayOf(lastTimestamp.toString())).use { + return database.rawQuery("SELECT * FROM tracker_events " + + "WHERE timestamp ${if (reverseOrder) "<" else ">"} ? " + + "ORDER BY timestamp ${if (reverseOrder) "DESC" else ""} " + + "LIMIT $pageSize OFFSET ${pageIndex * pageSize}", arrayOf((timestamp ?: if (reverseOrder) Long.MAX_VALUE else 0).toString())).use { val logs = mutableListOf<TrackerLog>() - while (it.moveToNext() && logs.size < 50) { + while (it.moveToNext()) { val log = TrackerLog( id = it.getIntOrNull("id") ?: continue, timestamp = it.getLongOrNull("timestamp") ?: continue, @@ -307,6 +331,13 @@ class LoggerWrapper( } } + fun purgeTrackerLogs(maxAge: Long) { + coroutineScope.launch { + val maxTime = System.currentTimeMillis() - maxAge + database.execSQL("DELETE FROM tracker_events WHERE timestamp < ?", arrayOf(maxTime.toString())) + } + } + fun findConversation(search: String): List<String> { return database.rawQuery("SELECT DISTINCT conversation_id FROM tracker_events WHERE is_group = 1 AND conversation_id LIKE ?", arrayOf("%$search%")).use { val conversations = mutableListOf<String>() diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt @@ -99,7 +99,11 @@ data class PropertyKey<T>( fun propertyOption(translation: LocaleWrapper, key: String): String { if (key == "null") { - return translation[params.disabledKey ?: "manager.sections.features.disabled"] + return translation[params.disabledKey?.let { disabledKey -> + params.customOptionTranslationPath?.let { + "$it.$disabledKey" + } ?: key + } ?: "manager.sections.features.disabled"] } return if (!params.flags.contains(ConfigFlag.NO_TRANSLATE)) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt @@ -1,8 +1,15 @@ package me.rhunk.snapenhance.common.config.impl import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.util.PURGE_DISABLED_KEY +import me.rhunk.snapenhance.common.util.PURGE_VALUES +import me.rhunk.snapenhance.common.util.PURGE_TRANSLATION_KEY class FriendTrackerConfig: ConfigContainer(hasGlobalState = true) { val recordMessagingEvents = boolean("record_messaging_events", false) val allowRunningInBackground = boolean("allow_running_in_background", false) + val autoPurge = unique("auto_purge", *PURGE_VALUES) { + customOptionTranslationPath = PURGE_TRANSLATION_KEY + disabledKey = PURGE_DISABLED_KEY + }.apply { set(PURGE_DISABLED_KEY) } } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -4,6 +4,9 @@ import me.rhunk.snapenhance.common.config.ConfigContainer import me.rhunk.snapenhance.common.config.FeatureNotice import me.rhunk.snapenhance.common.config.PropertyValue import me.rhunk.snapenhance.common.data.NotificationType +import me.rhunk.snapenhance.common.util.PURGE_DISABLED_KEY +import me.rhunk.snapenhance.common.util.PURGE_TRANSLATION_KEY +import me.rhunk.snapenhance.common.util.PURGE_VALUES class MessagingTweaks : ConfigContainer() { inner class HalfSwipeNotifierConfig : ConfigContainer(hasGlobalState = true) { @@ -17,27 +20,11 @@ class MessagingTweaks : ConfigContainer() { inner class MessageLoggerConfig : ConfigContainer(hasGlobalState = true) { val keepMyOwnMessages = boolean("keep_my_own_messages") - private val autoPurge = unique("auto_purge", "1_hour", "3_hours", "6_hours", "12_hours", "1_day", "3_days", "1_week", "2_weeks", "1_month", "3_months", "6_months") { - disabledKey = "features.options.auto_purge.never" + val autoPurge = unique("auto_purge", *PURGE_VALUES) { + customOptionTranslationPath = PURGE_TRANSLATION_KEY + disabledKey = PURGE_DISABLED_KEY }.apply { set("3_days") } - fun getAutoPurgeTime(): Long? { - return when (autoPurge.getNullable()) { - "1_hour" -> 3600000L - "3_hours" -> 10800000L - "6_hours" -> 21600000L - "12_hours" -> 43200000L - "1_day" -> 86400000L - "3_days" -> 259200000L - "1_week" -> 604800000L - "2_weeks" -> 1209600000L - "1_month" -> 2592000000L - "3_months" -> 7776000000L - "6_months" -> 15552000000L - else -> null - } - } - val messageFilter = multiple("message_filter", "CHAT", "SNAP", "NOTE", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/Purge.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/Purge.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.common.util + +val PURGE_VALUES = arrayOf("1_hour", "3_hours", "6_hours", "12_hours", "1_day", "3_days", "1_week", "2_weeks", "1_month", "3_months", "6_months") +const val PURGE_TRANSLATION_KEY = "features.options.auto_purge" +const val PURGE_DISABLED_KEY = "never" + +fun getPurgeTime( + value: String? +): Long? { + return when (value) { + "1_hour" -> 3600000L + "3_hours" -> 10800000L + "6_hours" -> 21600000L + "12_hours" -> 43200000L + "1_day" -> 86400000L + "3_days" -> 259200000L + "1_week" -> 604800000L + "2_weeks" -> 1209600000L + "1_month" -> 2592000000L + "3_months" -> 7776000000L + "6_months" -> 15552000000L + else -> null + } +}+ \ No newline at end of file