LoggerHistoryRoot.kt (19817B) - raw
1 package me.rhunk.snapenhance.ui.manager.pages 2 3 import androidx.compose.foundation.gestures.detectTapGestures 4 import androidx.compose.foundation.layout.* 5 import androidx.compose.foundation.lazy.LazyColumn 6 import androidx.compose.foundation.lazy.items 7 import androidx.compose.foundation.text.KeyboardActions 8 import androidx.compose.material.icons.Icons 9 import androidx.compose.material.icons.filled.Close 10 import androidx.compose.material.icons.filled.Download 11 import androidx.compose.material.icons.filled.Search 12 import androidx.compose.material3.* 13 import androidx.compose.runtime.* 14 import androidx.compose.ui.Alignment 15 import androidx.compose.ui.Modifier 16 import androidx.compose.ui.focus.FocusRequester 17 import androidx.compose.ui.focus.focusRequester 18 import androidx.compose.ui.input.pointer.pointerInput 19 import androidx.compose.ui.text.font.FontStyle 20 import androidx.compose.ui.text.font.FontWeight 21 import androidx.compose.ui.text.style.TextAlign 22 import androidx.compose.ui.text.style.TextOverflow 23 import androidx.compose.ui.unit.dp 24 import androidx.compose.ui.unit.sp 25 import androidx.navigation.NavBackStackEntry 26 import com.google.gson.JsonParser 27 import kotlinx.coroutines.Dispatchers 28 import kotlinx.coroutines.launch 29 import kotlinx.coroutines.withContext 30 import me.rhunk.snapenhance.bridge.DownloadCallback 31 import me.rhunk.snapenhance.common.bridge.wrapper.ConversationInfo 32 import me.rhunk.snapenhance.common.bridge.wrapper.LoggedMessage 33 import me.rhunk.snapenhance.common.bridge.wrapper.LoggerWrapper 34 import me.rhunk.snapenhance.common.data.ContentType 35 import me.rhunk.snapenhance.common.data.download.DownloadMetadata 36 import me.rhunk.snapenhance.common.data.download.DownloadRequest 37 import me.rhunk.snapenhance.common.data.download.MediaDownloadSource 38 import me.rhunk.snapenhance.common.data.download.createNewFilePath 39 import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState 40 import me.rhunk.snapenhance.common.ui.transparentTextFieldColors 41 import me.rhunk.snapenhance.common.util.ktx.copyToClipboard 42 import me.rhunk.snapenhance.common.util.ktx.longHashCode 43 import me.rhunk.snapenhance.common.util.protobuf.ProtoReader 44 import me.rhunk.snapenhance.core.features.impl.downloader.decoder.DecodedAttachment 45 import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder 46 import me.rhunk.snapenhance.download.DownloadProcessor 47 import me.rhunk.snapenhance.storage.findFriend 48 import me.rhunk.snapenhance.ui.manager.Routes 49 import java.text.DateFormat 50 import java.util.concurrent.ConcurrentHashMap 51 import kotlin.math.absoluteValue 52 53 54 class LoggerHistoryRoot : Routes.Route() { 55 private lateinit var loggerWrapper: LoggerWrapper 56 private var selectedConversation by mutableStateOf<String?>(null) 57 private var stringFilter by mutableStateOf("") 58 private var reverseOrder by mutableStateOf(true) 59 60 private inline fun decodeMessage(message: LoggedMessage, result: (contentType: ContentType, messageReader: ProtoReader, attachments: List<DecodedAttachment>) -> Unit) { 61 runCatching { 62 val messageObject = JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject 63 val messageContent = messageObject.getAsJsonObject("mMessageContent") 64 val messageReader = messageContent.getAsJsonArray("mContent").map { it.asByte }.toByteArray().let { ProtoReader(it) } 65 result(ContentType.fromMessageContainer(messageReader) ?: ContentType.UNKNOWN, messageReader, MessageDecoder.decode(messageContent)) 66 }.onFailure { 67 context.log.error("Failed to decode message", it) 68 } 69 } 70 71 private fun downloadAttachment(creationTimestamp: Long, attachment: DecodedAttachment) { 72 context.shortToast("Download started!") 73 val attachmentHash = attachment.mediaUniqueId!!.longHashCode().absoluteValue.toString() 74 75 DownloadProcessor( 76 remoteSideContext = context, 77 callback = object: DownloadCallback.Default() { 78 override fun onSuccess(outputPath: String?) { 79 context.shortToast("Downloaded to $outputPath") 80 } 81 82 override fun onFailure(message: String?, throwable: String?) { 83 context.shortToast("Failed to download $message") 84 } 85 } 86 ).enqueue( 87 DownloadRequest( 88 inputMedias = arrayOf(attachment.createInputMedia()!!) 89 ), 90 DownloadMetadata( 91 mediaIdentifier = attachmentHash, 92 outputPath = createNewFilePath( 93 context.config.root, 94 attachment.mediaUniqueId!!, 95 MediaDownloadSource.MESSAGE_LOGGER, 96 attachmentHash, 97 creationTimestamp 98 ), 99 iconUrl = null, 100 mediaAuthor = null, 101 downloadSource = MediaDownloadSource.MESSAGE_LOGGER.translate(context.translation), 102 ) 103 ) 104 } 105 106 @OptIn(ExperimentalLayoutApi::class) 107 @Composable 108 private fun MessageView(message: LoggedMessage) { 109 var contentView by remember { mutableStateOf<@Composable () -> Unit>({ 110 Spacer(modifier = Modifier.height(30.dp)) 111 }) } 112 113 OutlinedCard( 114 modifier = Modifier 115 .padding(2.dp) 116 .fillMaxWidth() 117 ) { 118 Row( 119 modifier = Modifier 120 .padding(8.dp) 121 .fillMaxWidth(), 122 verticalAlignment = Alignment.CenterVertically 123 ) { 124 contentView() 125 126 LaunchedEffect(Unit, message) { 127 runCatching { 128 decodeMessage(message) { contentType, messageReader, attachments -> 129 @Composable 130 fun ContentHeader() { 131 Text("${message.username} (${contentType.toString().lowercase()}) - ${DateFormat.getDateTimeInstance().format(message.sendTimestamp)}", modifier = Modifier.padding(end = 4.dp), fontWeight = FontWeight.ExtraLight) 132 } 133 134 if (contentType == ContentType.CHAT) { 135 val content = messageReader.getString(2, 1) ?: "[${translation["empty_message"]}]" 136 contentView = { 137 Column { 138 Text(content, modifier = Modifier 139 .fillMaxWidth() 140 .pointerInput(Unit) { 141 detectTapGestures(onLongPress = { 142 context.androidContext.copyToClipboard(content) 143 }) 144 }) 145 146 val edits by rememberAsyncMutableState(defaultValue = emptyList()) { 147 loggerWrapper.getChatEdits(selectedConversation!!, message.messageId) 148 } 149 edits.forEach { messageEdit -> 150 val date = remember { 151 DateFormat.getDateTimeInstance().format(messageEdit.timestamp) 152 } 153 Text( 154 modifier = Modifier.pointerInput(Unit) { 155 detectTapGestures(onLongPress = { 156 context.androidContext.copyToClipboard(messageEdit.message) 157 }) 158 }.fillMaxWidth().padding(start = 4.dp), 159 text = messageEdit.message + " (edited at $date)", 160 fontWeight = FontWeight.Light, 161 fontStyle = FontStyle.Italic, 162 fontSize = 12.sp 163 ) 164 } 165 ContentHeader() 166 } 167 } 168 return@runCatching 169 } 170 contentView = { 171 Column column@{ 172 if (attachments.isEmpty()) return@column 173 174 FlowRow( 175 modifier = Modifier 176 .fillMaxWidth() 177 .padding(2.dp), 178 horizontalArrangement = Arrangement.spacedBy(4.dp), 179 ) { 180 attachments.forEachIndexed { index, attachment -> 181 ElevatedButton(onClick = { 182 context.coroutineScope.launch { 183 runCatching { 184 downloadAttachment(message.sendTimestamp, attachment) 185 }.onFailure { 186 context.log.error("Failed to download attachment", it) 187 context.shortToast(translation["download_attachment_failed_toast"]) 188 } 189 } 190 }) { 191 Icon( 192 imageVector = Icons.Default.Download, 193 contentDescription = "Download", 194 modifier = Modifier.padding(end = 4.dp) 195 ) 196 Text(translation.format("chat_attachment", "index" to (index + 1).toString())) 197 } 198 } 199 } 200 ContentHeader() 201 } 202 } 203 } 204 }.onFailure { 205 context.log.error("Failed to parse message", it) 206 contentView = { 207 Text("[${translation["message_parse_failed"]}]") 208 } 209 } 210 } 211 } 212 } 213 } 214 215 216 @OptIn(ExperimentalMaterial3Api::class) 217 override val content: @Composable (NavBackStackEntry) -> Unit = { 218 LaunchedEffect(Unit) { 219 loggerWrapper = LoggerWrapper(context.androidContext) 220 } 221 222 val conversationInfoCache = remember { ConcurrentHashMap<String, String?>() } 223 224 Column { 225 var expanded by remember { mutableStateOf(false) } 226 227 ExposedDropdownMenuBox( 228 expanded = expanded, 229 onExpandedChange = { expanded = it }, 230 ) { 231 fun formatConversationInfo(conversationInfo: ConversationInfo?): String? { 232 if (conversationInfo == null) return null 233 234 return conversationInfo.groupTitle?.let { 235 translation.format("list_group_format", "name" to it) 236 } ?: conversationInfo.usernames.takeIf { it.size > 1 }?.let { 237 translation.format("list_friend_format", "name" to ("(" + it.joinToString(", ") + ")")) 238 } ?: context.database.findFriend(conversationInfo.conversationId)?.let { 239 translation.format("list_friend_format", "name" to "(" + (conversationInfo.usernames + listOf(it.mutableUsername)).toSet().joinToString(", ") + ")") 240 } ?: conversationInfo.usernames.firstOrNull()?.let { 241 translation.format("list_friend_format", "name" to "($it)") 242 } 243 } 244 245 val selectedConversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(selectedConversation)) { 246 selectedConversation?.let { 247 conversationInfoCache.getOrPut(it) { 248 formatConversationInfo(loggerWrapper.getConversationInfo(it)) 249 } 250 } 251 } 252 253 OutlinedTextField( 254 value = selectedConversationInfo ?: "Select a conversation", 255 onValueChange = {}, 256 readOnly = true, 257 modifier = Modifier 258 .menuAnchor(MenuAnchorType.PrimaryNotEditable) 259 .fillMaxWidth() 260 ) 261 262 val conversations by rememberAsyncMutableState(defaultValue = emptyList()) { 263 loggerWrapper.getAllConversations().toMutableList() 264 } 265 266 ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { 267 conversations.forEach { conversationId -> 268 DropdownMenuItem(onClick = { 269 selectedConversation = conversationId 270 expanded = false 271 }, text = { 272 val conversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(conversationId)) { 273 conversationInfoCache.getOrPut(conversationId) { 274 formatConversationInfo(loggerWrapper.getConversationInfo(conversationId)) 275 } 276 } 277 278 Text( 279 text = remember(conversationInfo) { conversationInfo ?: conversationId }, 280 fontWeight = if (conversationId == selectedConversation) FontWeight.Bold else FontWeight.Normal, 281 overflow = TextOverflow.Ellipsis 282 ) 283 }) 284 } 285 } 286 } 287 288 Row( 289 modifier = Modifier 290 .fillMaxWidth() 291 .padding(2.dp), 292 horizontalArrangement = Arrangement.End 293 ) { 294 Row( 295 verticalAlignment = Alignment.CenterVertically, 296 horizontalArrangement = Arrangement.spacedBy(2.dp), 297 ) { 298 Text(translation["reverse_order_checkbox"]) 299 Checkbox(checked = reverseOrder, onCheckedChange = { 300 reverseOrder = it 301 }) 302 } 303 } 304 305 var hasReachedEnd by remember(selectedConversation, stringFilter, reverseOrder) { mutableStateOf(false) } 306 var lastFetchMessageTimestamp by remember(selectedConversation, stringFilter, reverseOrder) { mutableLongStateOf(if (reverseOrder) Long.MAX_VALUE else Long.MIN_VALUE) } 307 val messages = remember(selectedConversation, stringFilter, reverseOrder) { mutableStateListOf<LoggedMessage>() } 308 309 LazyColumn { 310 items(messages) { message -> 311 MessageView(message) 312 } 313 item { 314 if (selectedConversation != null) { 315 if (hasReachedEnd) { 316 Text(translation["no_more_messages"], modifier = Modifier 317 .padding(8.dp) 318 .fillMaxWidth(), textAlign = TextAlign.Center) 319 } else { 320 Row( 321 horizontalArrangement = Arrangement.Center, 322 modifier = Modifier.fillMaxWidth() 323 ) { 324 CircularProgressIndicator( 325 modifier = Modifier 326 .height(20.dp) 327 .padding(8.dp) 328 ) 329 } 330 } 331 } 332 LaunchedEffect(Unit, selectedConversation, stringFilter, reverseOrder) { 333 withContext(Dispatchers.IO) { 334 val newMessages = loggerWrapper.fetchMessages( 335 selectedConversation ?: return@withContext, 336 lastFetchMessageTimestamp, 337 30, 338 reverseOrder 339 ) { messageData -> 340 if (stringFilter.isEmpty()) return@fetchMessages true 341 var isMatch = false 342 decodeMessage(messageData) { contentType, messageReader, _ -> 343 if (contentType == ContentType.CHAT) { 344 val content = messageReader.getString(2, 1) ?: return@decodeMessage 345 isMatch = content.contains(stringFilter, ignoreCase = true) 346 } 347 } 348 isMatch 349 } 350 if (newMessages.isEmpty()) { 351 hasReachedEnd = true 352 return@withContext 353 } 354 lastFetchMessageTimestamp = newMessages.lastOrNull()?.sendTimestamp ?: return@withContext 355 withContext(Dispatchers.Main) { 356 messages.addAll(newMessages) 357 } 358 } 359 } 360 } 361 } 362 } 363 } 364 365 override val topBarActions: @Composable (RowScope.() -> Unit) = { 366 val focusRequester = remember { FocusRequester() } 367 var showSearchTextField by remember { mutableStateOf(false) } 368 369 if (showSearchTextField) { 370 var searchValue by remember { mutableStateOf("") } 371 372 TextField( 373 value = searchValue, 374 onValueChange = { keyword -> 375 searchValue = keyword 376 stringFilter = keyword 377 }, 378 keyboardActions = KeyboardActions(onDone = { focusRequester.freeFocus() }), 379 modifier = Modifier 380 .focusRequester(focusRequester) 381 .weight(1f, fill = true) 382 .padding(end = 10.dp) 383 .height(70.dp), 384 singleLine = true, 385 colors = transparentTextFieldColors() 386 ) 387 388 LaunchedEffect(Unit) { 389 focusRequester.requestFocus() 390 } 391 } 392 393 IconButton(onClick = { 394 showSearchTextField = !showSearchTextField 395 stringFilter = "" 396 }) { 397 Icon( 398 imageVector = if (showSearchTextField) Icons.Filled.Close 399 else Icons.Filled.Search, 400 contentDescription = null 401 ) 402 } 403 } 404 }