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