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 }