feat(tracker): auto purge, filters, export

This commit is contained in:
rhunk
2024-05-24 23:18:07 +02:00
parent dd8590d274
commit 819820b5c0
9 changed files with 732 additions and 265 deletions

View File

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

View File

@@ -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() }
)
}
private lateinit var activityLauncherHelper: ActivityLauncherHelper
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
@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
})
}
}
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) }
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() }
}
)
ExtendedFloatingActionButton(
icon = { Icon(Icons.Default.DeleteOutline, contentDescription = "Delete") },
expanded = true,
text = { Text("Delete") },
onClick = {
context.coroutineScope.launch { logDeleteAction() }
}
)
}
}
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")
}
}
}
}
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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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