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 }