feat(tracker): auto purge, filters, export
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user