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 }