MessagingPreview.kt (22841B) - raw


      1 package me.rhunk.snapenhance.ui.manager.pages.social
      2 
      3 import android.content.Intent
      4 import androidx.compose.foundation.background
      5 import androidx.compose.foundation.border
      6 import androidx.compose.foundation.gestures.detectTapGestures
      7 import androidx.compose.foundation.layout.*
      8 import androidx.compose.foundation.lazy.LazyColumn
      9 import androidx.compose.foundation.lazy.LazyListState
     10 import androidx.compose.foundation.lazy.items
     11 import androidx.compose.foundation.lazy.rememberLazyListState
     12 import androidx.compose.foundation.shape.RoundedCornerShape
     13 import androidx.compose.material.icons.Icons
     14 import androidx.compose.material.icons.filled.Close
     15 import androidx.compose.material.icons.filled.MoreVert
     16 import androidx.compose.material.icons.rounded.BookmarkAdded
     17 import androidx.compose.material.icons.rounded.BookmarkBorder
     18 import androidx.compose.material.icons.rounded.DeleteForever
     19 import androidx.compose.material.icons.rounded.RemoveRedEye
     20 import androidx.compose.material3.*
     21 import androidx.compose.runtime.*
     22 import androidx.compose.ui.Alignment
     23 import androidx.compose.ui.Modifier
     24 import androidx.compose.ui.graphics.vector.ImageVector
     25 import androidx.compose.ui.input.pointer.pointerInput
     26 import androidx.compose.ui.unit.dp
     27 import androidx.navigation.NavBackStackEntry
     28 import kotlinx.coroutines.*
     29 import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge
     30 import me.rhunk.snapenhance.bridge.snapclient.SessionStartListener
     31 import me.rhunk.snapenhance.bridge.snapclient.types.Message
     32 import me.rhunk.snapenhance.common.Constants
     33 import me.rhunk.snapenhance.common.ReceiversConfig
     34 import me.rhunk.snapenhance.common.data.ContentType
     35 import me.rhunk.snapenhance.common.data.SocialScope
     36 import me.rhunk.snapenhance.common.messaging.MessagingConstraints
     37 import me.rhunk.snapenhance.common.messaging.MessagingTask
     38 import me.rhunk.snapenhance.common.messaging.MessagingTaskConstraint
     39 import me.rhunk.snapenhance.common.messaging.MessagingTaskType
     40 import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
     41 import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper
     42 import me.rhunk.snapenhance.ui.manager.Routes
     43 import me.rhunk.snapenhance.ui.util.Dialog
     44 
     45 class MessagingPreview: Routes.Route() {
     46     private lateinit var coroutineScope: CoroutineScope
     47     private lateinit var previewScrollState: LazyListState
     48 
     49     private val contentTypeTranslation by lazy { context.translation.getCategory("content_type") }
     50     private val messagingBridge: MessagingBridge? get() = context.bridgeService?.messagingBridge
     51 
     52     private var messages = mutableStateListOf<Message>()
     53     private var conversationId by mutableStateOf<String?>(null)
     54     private val selectedMessages = mutableStateListOf<Long>() // client message id
     55 
     56     private fun toggleSelectedMessage(messageId: Long) {
     57         if (selectedMessages.contains(messageId)) selectedMessages.remove(messageId)
     58         else selectedMessages.add(messageId)
     59     }
     60 
     61     @Composable
     62     private fun ActionButton(
     63         text: String,
     64         icon: ImageVector,
     65         onClick: () -> Unit,
     66     ) {
     67         DropdownMenuItem(
     68             onClick = onClick,
     69             text = {
     70                 Row(
     71                     modifier = Modifier.padding(5.dp),
     72                     horizontalArrangement = Arrangement.spacedBy(8.dp),
     73                     verticalAlignment = Alignment.CenterVertically
     74                 ) {
     75                     Icon(
     76                         imageVector = icon,
     77                         contentDescription = null
     78                     )
     79                     Text(text = text)
     80                 }
     81             }
     82         )
     83     }
     84 
     85     @Composable
     86     private fun ConstraintsSelectionDialog(
     87         onChoose: (Array<ContentType>) -> Unit,
     88         onDismiss: () -> Unit
     89     ) {
     90         val selectedTypes = remember { mutableStateListOf<ContentType>() }
     91         var selectAllState by remember { mutableStateOf(false) }
     92         val availableTypes = remember { arrayOf(
     93             ContentType.CHAT,
     94             ContentType.NOTE,
     95             ContentType.SNAP,
     96             ContentType.STICKER,
     97             ContentType.EXTERNAL_MEDIA
     98         ) }
     99 
    100         fun toggleContentType(contentType: ContentType) {
    101             if (selectAllState) return
    102             if (selectedTypes.contains(contentType)) {
    103                 selectedTypes.remove(contentType)
    104             } else {
    105                 selectedTypes.add(contentType)
    106             }
    107         }
    108 
    109         Surface(
    110             modifier = Modifier
    111                 .fillMaxWidth()
    112                 .background(MaterialTheme.colorScheme.surface)
    113         ) {
    114             Column(
    115                 modifier = Modifier.padding(15.dp),
    116                 horizontalAlignment = Alignment.CenterHorizontally,
    117                 verticalArrangement = Arrangement.spacedBy(5.dp)
    118             ) {
    119                 Text(context.translation["manager.dialogs.messaging_action.title"])
    120                 Spacer(modifier = Modifier.height(5.dp))
    121                 availableTypes.forEach { contentType ->
    122                     Row(
    123                         modifier = Modifier
    124                             .fillMaxWidth()
    125                             .padding(2.dp)
    126                             .pointerInput(Unit) {
    127                                 detectTapGestures(onTap = { toggleContentType(contentType) })
    128                             },
    129                         horizontalArrangement = Arrangement.spacedBy(8.dp),
    130                         verticalAlignment = Alignment.CenterVertically
    131                     ) {
    132                         Checkbox(
    133                             checked = selectedTypes.contains(contentType),
    134                             enabled = !selectAllState,
    135                             onCheckedChange = { toggleContentType(contentType) }
    136                         )
    137                         Text(text = contentTypeTranslation[contentType.name])
    138                     }
    139                 }
    140                 Row(
    141                     modifier = Modifier
    142                         .fillMaxWidth()
    143                         .padding(5.dp),
    144                     horizontalArrangement = Arrangement.spacedBy(10.dp),
    145                     verticalAlignment = Alignment.CenterVertically
    146                 ) {
    147                     Switch(checked = selectAllState, onCheckedChange = {
    148                         selectAllState = it
    149                     })
    150                     Text(text = context.translation["manager.dialogs.messaging_action.select_all_button"])
    151                 }
    152                 Row(
    153                     modifier = Modifier
    154                         .fillMaxWidth(),
    155                     horizontalArrangement = Arrangement.SpaceEvenly,
    156                 ) {
    157                     Button(onClick = { onDismiss() }) {
    158                         Text(context.translation["button.cancel"])
    159                     }
    160                     Button(onClick = {
    161                         onChoose(if (selectAllState) ContentType.entries.toTypedArray()
    162                          else selectedTypes.toTypedArray())
    163                     }) {
    164                         Text(context.translation["button.ok"])
    165                     }
    166                 }
    167             }
    168         }
    169     }
    170 
    171     override val topBarActions: @Composable (RowScope.() -> Unit) = {
    172         var taskSelectionDropdown by remember { mutableStateOf(false) }
    173         var selectConstraintsDialog by remember { mutableStateOf(false) }
    174         var activeTask by remember { mutableStateOf(null as MessagingTask?) }
    175         var activeJob by remember { mutableStateOf(null as Job?) }
    176         val processMessageCount = remember { mutableIntStateOf(0) }
    177 
    178         fun runCurrentTask() {
    179             activeJob = coroutineScope.launch(Dispatchers.IO) {
    180                 activeTask?.run()
    181                 withContext(Dispatchers.Main) {
    182                     activeTask = null
    183                     activeJob = null
    184                 }
    185             }.also { job ->
    186                 job.invokeOnCompletion {
    187                     if (it != null) {
    188                         context.log.verbose("Failed to process messages: ${it.message}")
    189                         return@invokeOnCompletion
    190                     }
    191                     context.longToast("Processed ${processMessageCount.intValue} messages")
    192                 }
    193             }
    194         }
    195 
    196         fun launchMessagingTask(taskType: MessagingTaskType, constraints: List<MessagingTaskConstraint> = listOf(), onSuccess: (Message) -> Unit = {}) {
    197             if (messagingBridge == null) {
    198                 context.longToast(translation["bridge_connection_failed"])
    199                 return
    200             }
    201             taskSelectionDropdown = false
    202             processMessageCount.intValue = 0
    203             activeTask = MessagingTask(
    204                 messagingBridge!!, conversationId!!, taskType, constraints,
    205                 overrideClientMessageIds = selectedMessages.takeIf { it.isNotEmpty() }?.toList(),
    206                 processedMessageCount = processMessageCount,
    207                 onSuccess = onSuccess,
    208                 onFailure = { message, reason ->
    209                     context.log.verbose("Failed to process message ${message.clientMessageId}: $reason")
    210                 }
    211             )
    212             selectedMessages.clear()
    213         }
    214 
    215         if (selectConstraintsDialog && activeTask != null) {
    216             Dialog(onDismissRequest = {
    217                 selectConstraintsDialog = false
    218                 activeTask = null
    219             }) {
    220                 ConstraintsSelectionDialog(
    221                     onChoose = { contentTypes ->
    222                         launchMessagingTask(
    223                             taskType = activeTask!!.taskType,
    224                             constraints = activeTask!!.constraints + MessagingConstraints.CONTENT_TYPE(contentTypes),
    225                             onSuccess = activeTask!!.onSuccess
    226                         )
    227                         runCurrentTask()
    228                         selectConstraintsDialog = false
    229                     },
    230                     onDismiss = {
    231                         selectConstraintsDialog = false
    232                         activeTask = null
    233                     }
    234                 )
    235             }
    236         }
    237 
    238         if (activeJob != null) {
    239             Dialog(onDismissRequest = {
    240                 activeJob?.cancel()
    241                 activeJob = null
    242                 activeTask = null
    243             }) {
    244                 Column(modifier = Modifier
    245                     .fillMaxWidth()
    246                     .background(MaterialTheme.colorScheme.surface)
    247                     .padding(15.dp)
    248                     .border(1.dp, MaterialTheme.colorScheme.onSurface, RoundedCornerShape(20.dp)),
    249                     horizontalAlignment = Alignment.CenterHorizontally,
    250                     verticalArrangement = Arrangement.spacedBy(5.dp))
    251                 {
    252                     Text("Processed ${processMessageCount.intValue} messages")
    253                     if (activeTask?.hasFixedGoal() == true) {
    254                         LinearProgressIndicator(
    255                             progress = { processMessageCount.intValue.toFloat() / selectedMessages.size.toFloat() },
    256                             modifier = Modifier
    257                                 .fillMaxWidth()
    258                                 .padding(5.dp),
    259                             color = MaterialTheme.colorScheme.primary,
    260                         )
    261                     } else {
    262                         CircularProgressIndicator(
    263                             modifier = Modifier
    264                                 .padding()
    265                                 .size(30.dp),
    266                             strokeWidth = 3.dp,
    267                             color = MaterialTheme.colorScheme.primary
    268                         )
    269                     }
    270                 }
    271             }
    272         }
    273 
    274         IconButton(onClick = { taskSelectionDropdown = !taskSelectionDropdown }) {
    275             Icon(imageVector = Icons.Filled.MoreVert, contentDescription = null)
    276         }
    277 
    278         if (selectedMessages.isNotEmpty()) {
    279             IconButton(onClick = { selectedMessages.clear() }) {
    280                 Icon(imageVector = Icons.Filled.Close, contentDescription = "Close")
    281             }
    282         }
    283 
    284         MaterialTheme(
    285             colorScheme = MaterialTheme.colorScheme.copy(
    286                 surface = MaterialTheme.colorScheme.inverseSurface,
    287                 onSurface = MaterialTheme.colorScheme.inverseOnSurface
    288             ),
    289             shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(50.dp))
    290         ) {
    291             DropdownMenu(
    292                 expanded = taskSelectionDropdown && messages.isNotEmpty(), onDismissRequest = { taskSelectionDropdown = false }
    293             ) {
    294                 val hasSelection = selectedMessages.isNotEmpty()
    295                 ActionButton(text = translation[if (hasSelection) "save_selection_option" else "save_all_option"], icon = Icons.Rounded.BookmarkAdded) {
    296                     launchMessagingTask(MessagingTaskType.SAVE)
    297                     if (hasSelection) runCurrentTask()
    298                     else selectConstraintsDialog = true
    299                 }
    300                 ActionButton(text = translation[if (hasSelection) "unsave_selection_option" else "unsave_all_option"], icon = Icons.Rounded.BookmarkBorder) {
    301                     launchMessagingTask(MessagingTaskType.UNSAVE)
    302                     if (hasSelection) runCurrentTask()
    303                     else selectConstraintsDialog = true
    304                 }
    305                 ActionButton(text = translation[if (hasSelection) "mark_selection_as_seen_option" else "mark_all_as_seen_option"], icon = Icons.Rounded.RemoveRedEye) {
    306                     if (messagingBridge == null) {
    307                         context.longToast(translation["bridge_connection_failed"])
    308                         return@ActionButton
    309                     }
    310                     launchMessagingTask(
    311                         MessagingTaskType.READ, listOf(
    312                         MessagingConstraints.NO_USER_ID(messagingBridge!!.myUserId),
    313                         MessagingConstraints.CONTENT_TYPE(arrayOf(ContentType.SNAP))
    314                     ))
    315                     runCurrentTask()
    316                 }
    317                 ActionButton(text = translation[if (hasSelection) "delete_selection_option" else "delete_all_option"], icon = Icons.Rounded.DeleteForever) {
    318                     if (messagingBridge == null) {
    319                         context.longToast(translation["bridge_connection_failed"])
    320                         return@ActionButton
    321                     }
    322                     launchMessagingTask(MessagingTaskType.DELETE, listOf(MessagingConstraints.USER_ID(messagingBridge!!.myUserId), {
    323                         contentType != ContentType.STATUS.id
    324                     })) { message ->
    325                         coroutineScope.launch {
    326                             message.contentType = ContentType.STATUS.id
    327                         }
    328                     }
    329                     if (hasSelection) runCurrentTask()
    330                     else selectConstraintsDialog = true
    331                 }
    332             }
    333         }
    334     }
    335 
    336     @Composable
    337     private fun ConversationPreview(
    338         messages: List<Message>,
    339         fetchNewMessages: () -> Unit
    340     ) {
    341         DisposableEffect(Unit) {
    342             onDispose {
    343                 selectedMessages.clear()
    344             }
    345         }
    346 
    347         LazyColumn(
    348             reverseLayout = true,
    349             modifier = Modifier
    350                 .fillMaxWidth(),
    351             state = previewScrollState,
    352         ) {
    353             items(messages, key = { it.serverMessageId }) {message ->
    354                 val messageReader = remember(message.contentType) { ProtoReader(message.content) }
    355                 val contentType = ContentType.fromMessageContainer(messageReader)
    356 
    357                 Card(
    358                     modifier = Modifier
    359                         .padding(5.dp)
    360                         .pointerInput(Unit) {
    361                             if (contentType == ContentType.STATUS) return@pointerInput
    362                             detectTapGestures(
    363                                 onLongPress = {
    364                                     toggleSelectedMessage(message.clientMessageId)
    365                                 },
    366                                 onTap = {
    367                                     if (selectedMessages.isNotEmpty()) {
    368                                         toggleSelectedMessage(message.clientMessageId)
    369                                     }
    370                                 }
    371                             )
    372                         },
    373                     colors = CardDefaults.cardColors(
    374                         containerColor = if (selectedMessages.contains(message.clientMessageId)) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
    375                     ),
    376                 ) {
    377                     val contentMessage = remember(message.contentType) { "[${contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } }] ${messageReader.getString(2, 1) ?: "" }" }
    378                     Row(
    379                         modifier = Modifier
    380                             .padding(5.dp)
    381                     ) {
    382                         Text(contentMessage)
    383                     }
    384                 }
    385             }
    386             item {
    387                 if (messages.isEmpty()) {
    388                     Row(
    389                         modifier = Modifier
    390                             .fillMaxWidth()
    391                             .padding(40.dp),
    392                         horizontalArrangement = Arrangement.Center
    393                     ) {
    394                         Text(translation["no_message_hint"])
    395                     }
    396                 }
    397                 Spacer(modifier = Modifier.height(20.dp))
    398 
    399                 LaunchedEffect(Unit) {
    400                     if (messages.isNotEmpty()) {
    401                         fetchNewMessages()
    402                     }
    403                 }
    404             }
    405         }
    406     }
    407 
    408 
    409     @Composable
    410     private fun LoadingRow() {
    411         Row(
    412             modifier = Modifier
    413                 .fillMaxWidth()
    414                 .padding(40.dp),
    415             horizontalArrangement = Arrangement.Center
    416         ) {
    417             CircularProgressIndicator(
    418                 modifier = Modifier
    419                     .padding()
    420                     .size(30.dp),
    421                 strokeWidth = 3.dp,
    422                 color = MaterialTheme.colorScheme.primary
    423             )
    424         }
    425     }
    426 
    427     override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry ->
    428         val scope = remember { SocialScope.getByName(navBackStackEntry.arguments?.getString("scope")!!) }
    429         val id = remember { navBackStackEntry.arguments?.getString("id")!! }
    430 
    431         previewScrollState = rememberLazyListState()
    432         coroutineScope = rememberCoroutineScope()
    433 
    434         var lastMessageId by remember { mutableLongStateOf(Long.MAX_VALUE) }
    435         var isBridgeConnected by remember { mutableStateOf(false) }
    436         var hasBridgeError by remember { mutableStateOf(false) }
    437 
    438         fun fetchNewMessages() {
    439             coroutineScope.launch(Dispatchers.IO) cs@{
    440                 runCatching {
    441                     val queriedMessages = messagingBridge!!.fetchConversationWithMessagesPaginated(
    442                         conversationId!!,
    443                         20,
    444                         lastMessageId
    445                     )?.reversed() ?: throw IllegalStateException("Failed to fetch messages. Bridge returned null")
    446 
    447                     withContext(Dispatchers.Main) {
    448                         messages.addAll(queriedMessages)
    449                         lastMessageId = queriedMessages.lastOrNull()?.clientMessageId ?: lastMessageId
    450                     }
    451                 }.onFailure {
    452                     context.log.error("Failed to fetch messages", it)
    453                     context.shortToast(translation["message_fetch_failed"])
    454                 }
    455             }
    456         }
    457 
    458         fun onMessagingBridgeReady(scope: SocialScope, scopeId: String) {
    459             context.log.verbose("onMessagingBridgeReady: $scope $scopeId")
    460 
    461             runCatching {
    462                 conversationId = (if (scope == SocialScope.FRIEND) messagingBridge!!.getOneToOneConversationId(scopeId) else scopeId) ?: throw IllegalStateException("Failed to get conversation id")
    463                 if (runCatching { !messagingBridge!!.isSessionStarted }.getOrDefault(true)) {
    464                     context.androidContext.packageManager.getLaunchIntentForPackage(
    465                         Constants.SNAPCHAT_PACKAGE_NAME
    466                     )?.let {
    467                         val mainIntent = Intent.makeMainActivity(it.component).apply {
    468                             putExtra(ReceiversConfig.MESSAGING_PREVIEW_EXTRA, true)
    469                             addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    470                         }
    471                         context.androidContext.startActivity(mainIntent)
    472                     }
    473                     messagingBridge!!.registerSessionStartListener(object: SessionStartListener.Stub() {
    474                         override fun onConnected() {
    475                             fetchNewMessages()
    476                         }
    477                     })
    478                     return
    479                 }
    480                 fetchNewMessages()
    481             }.onFailure {
    482                 context.longToast(translation["bridge_init_failed"])
    483                 context.log.error("Failed to initialize messaging bridge", it)
    484             }
    485         }
    486 
    487         LaunchedEffect(Unit) {
    488             messages.clear()
    489             conversationId = null
    490 
    491             isBridgeConnected = context.hasMessagingBridge()
    492             if (isBridgeConnected) {
    493                 withContext(Dispatchers.IO) {
    494                     onMessagingBridgeReady(scope, id)
    495                 }
    496             } else {
    497                 coroutineScope.launch(Dispatchers.IO) {
    498                     SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also {
    499                         context.androidContext.sendBroadcast(it)
    500                     }
    501                     withTimeout(10000) {
    502                         while (!context.hasMessagingBridge()) {
    503                             delay(100)
    504                         }
    505                         isBridgeConnected = true
    506                         onMessagingBridgeReady(scope, id)
    507                     }
    508                 }.invokeOnCompletion {
    509                     if (it != null) {
    510                         hasBridgeError = true
    511                     }
    512                 }
    513             }
    514         }
    515 
    516         Column(
    517             modifier = Modifier
    518                 .fillMaxSize()
    519         ) {
    520             if (hasBridgeError) {
    521                 Text(translation["bridge_connection_failed"])
    522             }
    523 
    524             if (!isBridgeConnected && !hasBridgeError) {
    525                 LoadingRow()
    526             }
    527 
    528             if (isBridgeConnected && !hasBridgeError) {
    529                 ConversationPreview(messages, ::fetchNewMessages)
    530             }
    531         }
    532     }
    533 }