LogsTab.kt (24619B) - raw


      1 package me.rhunk.snapenhance.ui.manager.pages.tracker
      2 
      3 import android.net.Uri
      4 import androidx.compose.foundation.layout.*
      5 import androidx.compose.foundation.lazy.LazyColumn
      6 import androidx.compose.foundation.lazy.items
      7 import androidx.compose.material.icons.Icons
      8 import androidx.compose.material.icons.filled.Clear
      9 import androidx.compose.material.icons.filled.DeleteOutline
     10 import androidx.compose.material.icons.filled.FilterList
     11 import androidx.compose.material3.*
     12 import androidx.compose.runtime.*
     13 import androidx.compose.ui.Alignment
     14 import androidx.compose.ui.Modifier
     15 import androidx.compose.ui.graphics.Color
     16 import androidx.compose.ui.text.font.FontWeight
     17 import androidx.compose.ui.text.style.TextAlign
     18 import androidx.compose.ui.text.style.TextOverflow
     19 import androidx.compose.ui.unit.dp
     20 import androidx.compose.ui.unit.sp
     21 import androidx.compose.ui.window.PopupProperties
     22 import com.google.gson.stream.JsonWriter
     23 import kotlinx.coroutines.Dispatchers
     24 import kotlinx.coroutines.Job
     25 import kotlinx.coroutines.delay
     26 import kotlinx.coroutines.launch
     27 import kotlinx.coroutines.withContext
     28 import me.rhunk.snapenhance.RemoteSideContext
     29 import me.rhunk.snapenhance.common.bridge.wrapper.TrackerLog
     30 import me.rhunk.snapenhance.common.data.MessagingFriendInfo
     31 import me.rhunk.snapenhance.common.data.TrackerEventType
     32 import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
     33 import me.rhunk.snapenhance.storage.getFriendInfo
     34 import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
     35 import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
     36 import me.rhunk.snapenhance.ui.util.saveFile
     37 import java.text.DateFormat
     38 
     39 
     40 @OptIn(ExperimentalMaterial3Api::class)
     41 @Composable
     42 fun LogsTab(
     43     context: RemoteSideContext,
     44     activityLauncherHelper: ActivityLauncherHelper,
     45     deleteAction: (() -> Unit) -> Unit,
     46     exportAction: (() -> Unit) -> Unit,
     47 ) {
     48     val coroutineScope = rememberCoroutineScope()
     49 
     50     val logs = remember { mutableStateListOf<TrackerLog>() }
     51     var isLoading by remember { mutableStateOf(false) }
     52     var pageIndex by remember { mutableIntStateOf(0) }
     53     var filterType by remember { mutableStateOf(FriendTrackerManagerRoot.FilterType.USERNAME) }
     54     var reverseSortOrder by remember { mutableStateOf(true) }
     55     val sinceDatePickerState = rememberDatePickerState(
     56         initialDisplayMode = DisplayMode.Picker
     57     )
     58 
     59     var filter by remember { mutableStateOf("") }
     60     var searchTimeoutJob by remember { mutableStateOf<Job?>(null) }
     61 
     62     fun getPaginatedLogs(pageIndex: Int) = context.messageLogger.getLogs(
     63         pageIndex = pageIndex,
     64         pageSize = 30,
     65         timestamp = sinceDatePickerState.selectedDateMillis,
     66         reverseOrder = reverseSortOrder,
     67         filter = {
     68         when (filterType) {
     69             FriendTrackerManagerRoot.FilterType.USERNAME -> it.username.contains(filter, ignoreCase = true)
     70             FriendTrackerManagerRoot.FilterType.CONVERSATION -> it.conversationTitle?.contains(filter, ignoreCase = true) == true || (it.username == filter && !it.isGroup)
     71             FriendTrackerManagerRoot.FilterType.EVENT -> it.eventType.contains(filter, ignoreCase = true)
     72         }
     73     })
     74 
     75     suspend fun loadNewLogs() {
     76         withContext(Dispatchers.IO) {
     77             getPaginatedLogs(pageIndex).let {
     78                 withContext(Dispatchers.Main) {
     79                     logs.addAll(it)
     80                     pageIndex += 1
     81                 }
     82             }
     83         }
     84     }
     85 
     86     suspend fun resetAndLoadLogs() {
     87         isLoading = true
     88         logs.clear()
     89         pageIndex = 0
     90         loadNewLogs()
     91         isLoading = false
     92     }
     93 
     94     var showDeleteDialog by remember { mutableStateOf(false) }
     95     var showExportSelectionDialog by remember { mutableStateOf(false) }
     96 
     97     LaunchedEffect(Unit) {
     98         deleteAction { showDeleteDialog = true }
     99         exportAction { showExportSelectionDialog = true }
    100     }
    101 
    102     if (showDeleteDialog) {
    103         val deleteCoroutineScope = rememberCoroutineScope { Dispatchers.IO }
    104         var deleteLogsTask by remember { mutableStateOf<Job?>(null) }
    105         var deletedLogsCount by remember { mutableIntStateOf(0) }
    106 
    107         fun deleteLogs() {
    108             deleteLogsTask = deleteCoroutineScope.launch {
    109                 var index = 0
    110                 while (true) {
    111                     val newLogs = getPaginatedLogs(index++)
    112                     if (newLogs.isEmpty()) {
    113                         break
    114                     }
    115                     newLogs.forEach {
    116                         context.messageLogger.deleteTrackerLog(it.id)
    117                         deletedLogsCount++
    118                     }
    119                 }
    120 
    121                 withContext(Dispatchers.Main) {
    122                     delay(500)
    123                     resetAndLoadLogs()
    124                     context.shortToast("Deleted $deletedLogsCount logs")
    125                     showDeleteDialog = false
    126                 }
    127             }
    128         }
    129 
    130         DisposableEffect(Unit) {
    131             onDispose {
    132                 deleteLogsTask?.cancel()
    133             }
    134         }
    135 
    136         AlertDialog(
    137             onDismissRequest = { showDeleteDialog = false },
    138             title = { Text("Delete logs?") },
    139             text = {
    140                 if (deleteLogsTask != null) {
    141                     Text("Deleting $deletedLogsCount logs...")
    142                 } else {
    143                     Text("This will delete logs based on the current filter and the search query. This action cannot be undone.")
    144                 }
    145             },
    146             confirmButton = {
    147                 Button(
    148                     enabled = deleteLogsTask == null,
    149                     onClick = {
    150                         deleteLogs()
    151                     }
    152                 ) {
    153                     if (deleteLogsTask != null) {
    154                         CircularProgressIndicator(modifier = Modifier
    155                             .size(30.dp),
    156                             strokeWidth = 3.dp
    157                         )
    158                     } else {
    159                         Text("Delete")
    160                     }
    161                 }
    162             },
    163             dismissButton = {
    164                 Button(onClick = { showDeleteDialog = false }) {
    165                     Text(context.translation["button.cancel"])
    166                 }
    167             }
    168         )
    169     }
    170 
    171     if (showExportSelectionDialog) {
    172         val exportCoroutineScope = rememberCoroutineScope { Dispatchers.IO }
    173         var exportTask by remember { mutableStateOf<Job?>(null) }
    174         var exportType by remember { mutableStateOf("json") }
    175 
    176         fun exportLogs() {
    177             activityLauncherHelper.saveFile("tracker_logs_${System.currentTimeMillis()}.$exportType") { uri ->
    178                 exportTask = exportCoroutineScope.launch {
    179                     context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
    180                         val writer = it.writer()
    181                         val jsonWriter by lazy {
    182                             JsonWriter(writer).apply {
    183                                 setIndent("  ")
    184                                 beginArray()
    185                             }
    186                         }
    187 
    188                         var index = 0
    189                         while (true) {
    190                             val newLogs = getPaginatedLogs(index++)
    191                             if (newLogs.isEmpty()) {
    192                                 break
    193                             }
    194                             newLogs.forEach { log ->
    195                                 when (exportType) {
    196                                     "json" -> {
    197                                         jsonWriter.jsonValue(log.toJson().toString())
    198                                     }
    199                                     "csv" -> {
    200                                         writer.write(log.toCsv())
    201                                         writer.write("\n")
    202                                     }
    203                                 }
    204                                 writer.flush()
    205                             }
    206                         }
    207                         when (exportType) {
    208                             "json" -> {
    209                                 jsonWriter.endArray()
    210                                 jsonWriter.close()
    211                             }
    212                             "csv" -> writer.close()
    213                         }
    214                     }
    215                 }.apply {
    216                     invokeOnCompletion {
    217                         exportTask = null
    218                         showExportSelectionDialog = false
    219                         if (it == null) {
    220                             context.shortToast("Exported logs!")
    221                         } else {
    222                             context.log.error("Failed to export logs", it)
    223                             context.shortToast("Failed to export logs. Check logcat for more details.")
    224                         }
    225                     }
    226                 }
    227             }
    228         }
    229 
    230         AlertDialog(
    231             onDismissRequest = { showExportSelectionDialog = false },
    232             title = { Text("Export logs?") },
    233             text = {
    234                 Column(
    235                     modifier = Modifier.fillMaxWidth(),
    236                     horizontalAlignment = Alignment.CenterHorizontally,
    237                 ) {
    238                     if (exportTask != null) {
    239                         Text("Exporting logs...")
    240                     } else {
    241                         Text("This will export logs based on the current filter and the search query.")
    242                         Spacer(modifier = Modifier.height(10.dp))
    243                         var expanded by remember { mutableStateOf(false) }
    244                         ExposedDropdownMenuBox(
    245                             expanded = expanded,
    246                             onExpandedChange = { expanded = it },
    247                         ) {
    248                             Card(
    249                                 modifier = Modifier
    250                                     .menuAnchor(MenuAnchorType.PrimaryNotEditable)
    251                                     .padding(2.dp)
    252                             ) {
    253                                 Text("Export as $exportType", modifier = Modifier.padding(8.dp))
    254                             }
    255                             DropdownMenu(expanded = expanded, onDismissRequest = {
    256                                 expanded = false
    257                             }) {
    258                                 listOf("json", "csv").forEach { type ->
    259                                     DropdownMenuItem(onClick = {
    260                                         exportType = type
    261                                         expanded = false
    262                                     }, text = {
    263                                         Text(type)
    264                                     })
    265                                 }
    266                             }
    267                         }
    268                     }
    269                 }
    270             },
    271             confirmButton = {
    272                 Button(
    273                     enabled = exportTask == null,
    274                     onClick = {
    275                         exportLogs()
    276                     }
    277                 ) {
    278                     if (exportTask != null) {
    279                         CircularProgressIndicator(modifier = Modifier
    280                             .size(30.dp),
    281                             strokeWidth = 3.dp
    282                         )
    283                     } else {
    284                         Text("Export")
    285                     }
    286                 }
    287             },
    288             dismissButton = {
    289                 Button(onClick = { showExportSelectionDialog = false }) {
    290                     Text(context.translation["button.cancel"])
    291                 }
    292             }
    293         )
    294     }
    295 
    296 
    297     @Composable
    298     fun FilterSelection(
    299         selectionExpanded: MutableState<Boolean>
    300     ) {
    301         var dropDownExpanded by remember { mutableStateOf(false) }
    302         var showDatePicker by remember { mutableStateOf(false) }
    303 
    304         if (showDatePicker) {
    305             DatePickerDialog(onDismissRequest = {
    306                 showDatePicker = false
    307             }, confirmButton = {}) {
    308                 DatePicker(
    309                     state = sinceDatePickerState,
    310                     modifier = Modifier.weight(1f),
    311                 )
    312                 Row(
    313                     modifier = Modifier
    314                         .fillMaxWidth()
    315                         .padding(8.dp),
    316                     horizontalArrangement = Arrangement.SpaceEvenly
    317                 ) {
    318                     Button(onClick = {
    319                         showDatePicker = false
    320                         sinceDatePickerState.selectedDateMillis = null
    321                     }) {
    322                         Text(context.translation["button.cancel"])
    323                     }
    324                     Button(onClick = {
    325                         showDatePicker = false
    326                     }) {
    327                         Text(context.translation["button.ok"])
    328                     }
    329                 }
    330             }
    331         }
    332 
    333         DropdownMenu(expanded = selectionExpanded.value, onDismissRequest = {
    334             selectionExpanded.value = false
    335         }) {
    336             Column(
    337                 modifier = Modifier
    338                     .padding(16.dp),
    339                 verticalArrangement = Arrangement.spacedBy(4.dp),
    340             ) {
    341                 val rowHSpacing = 10.dp
    342 
    343                 Text("Filters", fontWeight = FontWeight.Bold, fontSize = 20.sp)
    344                 Row(
    345                     horizontalArrangement = Arrangement.spacedBy(rowHSpacing),
    346                     verticalAlignment = Alignment.CenterVertically,
    347                 ) {
    348                     Text("Search by")
    349                     ExposedDropdownMenuBox(
    350                         expanded = dropDownExpanded,
    351                         onExpandedChange = { dropDownExpanded = it },
    352                     ) {
    353                         Card(
    354                             modifier = Modifier
    355                                 .menuAnchor(MenuAnchorType.PrimaryNotEditable)
    356                                 .padding(2.dp)
    357                         ) {
    358                             Text(filterType.name, modifier = Modifier.padding(8.dp))
    359                         }
    360                         DropdownMenu(expanded = dropDownExpanded, onDismissRequest = {
    361                             dropDownExpanded = false
    362                         }) {
    363                             FriendTrackerManagerRoot.FilterType.entries.forEach { type ->
    364                                 DropdownMenuItem(onClick = {
    365                                     filter = ""
    366                                     filterType = type
    367                                     dropDownExpanded = false
    368                                     coroutineScope.launch {
    369                                         resetAndLoadLogs()
    370                                     }
    371                                 }, text = {
    372                                     Text(type.name)
    373                                 })
    374                             }
    375                         }
    376                     }
    377                 }
    378                 Row(
    379                     horizontalArrangement = Arrangement.spacedBy(rowHSpacing),
    380                     verticalAlignment = Alignment.CenterVertically,
    381                 ) {
    382                     Text("Newest first")
    383                     Switch(
    384                         checked = reverseSortOrder,
    385                         onCheckedChange = {
    386                             reverseSortOrder = it
    387                             selectionExpanded.value = false
    388                         }
    389                     )
    390                 }
    391                 Row(
    392                     horizontalArrangement = Arrangement.spacedBy(rowHSpacing),
    393                     verticalAlignment = Alignment.CenterVertically,
    394                 ) {
    395                     Text(if (reverseSortOrder) "Since" else "Until")
    396                     Button(onClick = {
    397                         showDatePicker = true
    398                     }) {
    399                         Text(remember(showDatePicker) {
    400                             sinceDatePickerState.selectedDateMillis?.let {
    401                                 DateFormat.getDateInstance().format(it)
    402                             } ?: "Pick a date"
    403                         })
    404                     }
    405                 }
    406             }
    407         }
    408     }
    409 
    410     Column(
    411         modifier = Modifier.fillMaxSize()
    412     ) {
    413         Row(
    414             modifier = Modifier.fillMaxWidth(),
    415             verticalAlignment = Alignment.CenterVertically,
    416         ) {
    417             var showAutoComplete by remember { mutableStateOf(false) }
    418             val showFilterSelection = remember { mutableStateOf(false) }
    419 
    420             ExposedDropdownMenuBox(
    421                 expanded = showAutoComplete,
    422                 onExpandedChange = { showAutoComplete = it },
    423             ) {
    424                 TextField(
    425                     value = filter,
    426                     modifier = Modifier
    427                         .fillMaxWidth()
    428                         .menuAnchor(MenuAnchorType.PrimaryNotEditable)
    429                         .padding(8.dp),
    430                     onValueChange = {
    431                         filter = it
    432                         coroutineScope.launch {
    433                             searchTimeoutJob?.cancel()
    434                             searchTimeoutJob = coroutineScope.launch {
    435                                 delay(200)
    436                                 showAutoComplete = true
    437                                 resetAndLoadLogs()
    438                             }
    439                         }
    440                     },
    441                     placeholder = { Text("Search") },
    442                     colors = TextFieldDefaults.colors(
    443                         focusedContainerColor = Color.Transparent,
    444                         unfocusedContainerColor = Color.Transparent
    445                     ),
    446                     maxLines = 1,
    447                     leadingIcon = {
    448                         IconButton(
    449                             onClick = {
    450                                 showFilterSelection.value = !showFilterSelection.value
    451                             },
    452                             modifier = Modifier
    453                                 .padding(2.dp)
    454                         ) {
    455                             Icon(Icons.Default.FilterList, contentDescription = "Filter")
    456                         }
    457                         FilterSelection(showFilterSelection)
    458                         if (showFilterSelection.value) {
    459                             DisposableEffect(Unit) {
    460                                 onDispose {
    461                                     coroutineScope.launch {
    462                                         resetAndLoadLogs()
    463                                     }
    464                                 }
    465                             }
    466                         }
    467                     },
    468                     trailingIcon = {
    469                         if (filter != "") {
    470                             IconButton(onClick = {
    471                                 filter = ""
    472                                 coroutineScope.launch {
    473                                     resetAndLoadLogs()
    474                                 }
    475                             }) {
    476                                 Icon(Icons.Default.Clear, contentDescription = "Clear")
    477                             }
    478                         }
    479 
    480                         DropdownMenu(
    481                             expanded = showAutoComplete,
    482                             onDismissRequest = {
    483                                 showAutoComplete = false
    484                             },
    485                             properties = PopupProperties(focusable = false),
    486                         ) {
    487                             val suggestedEntries = remember(filter) {
    488                                 mutableStateListOf<String>()
    489                             }
    490 
    491                             LaunchedEffect(filter) {
    492                                 launch(Dispatchers.IO) {
    493                                     suggestedEntries.addAll(when (filterType) {
    494                                         FriendTrackerManagerRoot.FilterType.USERNAME -> context.messageLogger.findUsername(filter)
    495                                         FriendTrackerManagerRoot.FilterType.CONVERSATION -> context.messageLogger.findConversation(filter) + context.messageLogger.findUsername(filter)
    496                                         FriendTrackerManagerRoot.FilterType.EVENT -> TrackerEventType.entries.filter { it.name.contains(filter, ignoreCase = true) }.map { it.key }
    497                                     }.take(5))
    498                                 }
    499                             }
    500 
    501                             suggestedEntries.forEach { entry ->
    502                                 DropdownMenuItem(onClick = {
    503                                     filter = entry
    504                                     coroutineScope.launch {
    505                                         resetAndLoadLogs()
    506                                     }
    507                                     showAutoComplete = false
    508                                 }, text = {
    509                                     Text(entry)
    510                                 })
    511                             }
    512                         }
    513                     },
    514                 )
    515             }
    516         }
    517 
    518         LazyColumn(
    519             modifier = Modifier.weight(1f)
    520         ) {
    521             item {
    522                 Row(
    523                     modifier = Modifier
    524                         .fillMaxWidth(),
    525                     horizontalArrangement = Arrangement.Center
    526                 ) {
    527                     if (logs.isEmpty() && !isLoading) {
    528                         Text("No logs found", modifier = Modifier.padding(16.dp), fontWeight = FontWeight.Light, textAlign = TextAlign.Center)
    529                     }
    530                 }
    531             }
    532             items(logs, key = { it.userId + it.id }) { log ->
    533                 var databaseFriend by remember { mutableStateOf<MessagingFriendInfo?>(null) }
    534                 LaunchedEffect(Unit) {
    535                     launch(Dispatchers.IO) {
    536                         databaseFriend = context.database.getFriendInfo(log.userId)
    537                     }
    538                 }
    539                 ElevatedCard(
    540                     modifier = Modifier
    541                         .fillMaxWidth()
    542                         .padding(3.dp)
    543                 ) {
    544                     Row(
    545                         modifier = Modifier
    546                             .fillMaxWidth()
    547                             .padding(4.dp),
    548                         verticalAlignment = Alignment.CenterVertically
    549                     ) {
    550 
    551                         BitmojiImage(
    552                             modifier = Modifier.padding(5.dp),
    553                             size = 55,
    554                             context = context,
    555                             url = databaseFriend?.takeIf { it.bitmojiId != null }?.let {
    556                                 BitmojiSelfie.getBitmojiSelfie(it.selfieId, it.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D)
    557                             },
    558                         )
    559 
    560                         Column(
    561                             modifier = Modifier
    562                                 .weight(1f),
    563                         ) {
    564                             Text(databaseFriend?.displayName?.let {
    565                                 "$it (${log.username})"
    566                             } ?: log.username, lineHeight = 20.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 14.sp)
    567                             Text("${log.eventType} in ${log.conversationTitle}", fontSize = 10.sp, fontWeight = FontWeight.Light, lineHeight = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
    568                             Text(
    569                                 DateFormat.getDateTimeInstance().format(log.timestamp),
    570                                 fontSize = 10.sp,
    571                                 fontWeight = FontWeight.Light,
    572                                 lineHeight = 15.sp,
    573                             )
    574                         }
    575 
    576                         IconButton(
    577                             onClick = {
    578                                 context.messageLogger.deleteTrackerLog(log.id)
    579                                 logs.remove(log)
    580                             }
    581                         ) {
    582                             Icon(Icons.Default.DeleteOutline, contentDescription = "Delete")
    583                         }
    584                     }
    585                 }
    586             }
    587             item {
    588                 Spacer(modifier = Modifier.height(16.dp))
    589 
    590                 LaunchedEffect(pageIndex) {
    591                     loadNewLogs()
    592                 }
    593             }
    594 
    595             item {
    596                 Spacer(modifier = Modifier.height(100.dp))
    597             }
    598         }
    599     }
    600 }