feat: message logger history viewer
This commit is contained in:
@@ -15,6 +15,7 @@ import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.ui.manager.pages.LoggerHistoryRoot
|
||||
import me.rhunk.snapenhance.ui.manager.pages.TasksRoot
|
||||
import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot
|
||||
import me.rhunk.snapenhance.ui.manager.pages.home.HomeLogs
|
||||
@@ -51,6 +52,7 @@ class Routes(
|
||||
val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true), HomeRoot())
|
||||
val settings = route(RouteInfo("home_settings"), HomeSettings()).parent(home)
|
||||
val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home)
|
||||
val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).parent(home)
|
||||
|
||||
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot())
|
||||
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package me.rhunk.snapenhance.ui.manager.pages
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.rhunk.snapenhance.bridge.DownloadCallback
|
||||
import me.rhunk.snapenhance.common.bridge.wrapper.LoggedMessage
|
||||
import me.rhunk.snapenhance.common.bridge.wrapper.MessageLoggerWrapper
|
||||
import me.rhunk.snapenhance.common.data.ContentType
|
||||
import me.rhunk.snapenhance.common.data.download.*
|
||||
import me.rhunk.snapenhance.common.util.ktx.copyToClipboard
|
||||
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
||||
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
|
||||
import me.rhunk.snapenhance.core.features.impl.downloader.decoder.DecodedAttachment
|
||||
import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder
|
||||
import me.rhunk.snapenhance.download.DownloadProcessor
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
|
||||
class LoggerHistoryRoot : Routes.Route() {
|
||||
private lateinit var messageLoggerWrapper: MessageLoggerWrapper
|
||||
private var selectedConversation by mutableStateOf<String?>(null)
|
||||
private var stringFilter by mutableStateOf("")
|
||||
private var reverseOrder by mutableStateOf(true)
|
||||
|
||||
private inline fun decodeMessage(message: LoggedMessage, result: (contentType: ContentType, messageReader: ProtoReader, attachments: List<DecodedAttachment>) -> Unit) {
|
||||
runCatching {
|
||||
val messageObject = JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject
|
||||
val messageContent = messageObject.getAsJsonObject("mMessageContent")
|
||||
val messageReader = messageContent.getAsJsonArray("mContent").map { it.asByte }.toByteArray().let { ProtoReader(it) }
|
||||
result(ContentType.fromMessageContainer(messageReader) ?: ContentType.UNKNOWN, messageReader, MessageDecoder.decode(messageContent))
|
||||
}.onFailure {
|
||||
context.log.error("Failed to decode message", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadAttachment(creationTimestamp: Long, attachment: DecodedAttachment) {
|
||||
context.shortToast("Download started!")
|
||||
val attachmentHash = attachment.mediaUniqueId!!.longHashCode().absoluteValue.toString()
|
||||
|
||||
DownloadProcessor(
|
||||
remoteSideContext = context,
|
||||
callback = object: DownloadCallback.Default() {
|
||||
override fun onSuccess(outputPath: String?) {
|
||||
context.shortToast("Downloaded to $outputPath")
|
||||
}
|
||||
|
||||
override fun onFailure(message: String?, throwable: String?) {
|
||||
context.shortToast("Failed to download $message")
|
||||
}
|
||||
}
|
||||
).enqueue(
|
||||
DownloadRequest(
|
||||
inputMedias = arrayOf(
|
||||
InputMedia(
|
||||
content = attachment.mediaUrlKey!!,
|
||||
type = DownloadMediaType.PROTO_MEDIA,
|
||||
encryption = attachment.attachmentInfo?.encryption,
|
||||
)
|
||||
)
|
||||
),
|
||||
DownloadMetadata(
|
||||
mediaIdentifier = attachmentHash,
|
||||
outputPath = createNewFilePath(
|
||||
context.config.root,
|
||||
attachment.mediaUniqueId!!,
|
||||
MediaDownloadSource.MESSAGE_LOGGER,
|
||||
attachmentHash,
|
||||
creationTimestamp
|
||||
),
|
||||
iconUrl = null,
|
||||
mediaAuthor = null,
|
||||
downloadSource = MediaDownloadSource.MESSAGE_LOGGER.translate(context.translation),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun MessageView(message: LoggedMessage) {
|
||||
var contentView by remember { mutableStateOf<@Composable () -> Unit>({
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
}) }
|
||||
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
contentView()
|
||||
|
||||
LaunchedEffect(Unit, message) {
|
||||
runCatching {
|
||||
decodeMessage(message) { contentType, messageReader, attachments ->
|
||||
if (contentType == ContentType.CHAT) {
|
||||
val content = messageReader.getString(2, 1) ?: "[empty chat message]"
|
||||
contentView = {
|
||||
Text(content, modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(onLongPress = {
|
||||
context.androidContext.copyToClipboard(content)
|
||||
})
|
||||
})
|
||||
}
|
||||
return@runCatching
|
||||
}
|
||||
contentView = {
|
||||
Column column@{
|
||||
Text("[$contentType]")
|
||||
if (attachments.isEmpty()) return@column
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
attachments.forEachIndexed { index, attachment ->
|
||||
ElevatedButton(onClick = {
|
||||
context.coroutineScope.launch {
|
||||
runCatching {
|
||||
downloadAttachment(message.timestamp, attachment)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to download attachment", it)
|
||||
context.shortToast("Failed to download attachment")
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = "Download",
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
Text("Attachment ${index + 1}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to parse message", it)
|
||||
contentView = {
|
||||
Text("[Failed to parse message]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
LaunchedEffect(Unit) {
|
||||
messageLoggerWrapper = MessageLoggerWrapper(
|
||||
context.androidContext.getDatabasePath("message_logger.db")
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedConversation ?: "Select a conversation",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
val conversations = remember { mutableStateListOf<String>() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
conversations.clear()
|
||||
withContext(Dispatchers.IO) {
|
||||
conversations.addAll(messageLoggerWrapper.getAllConversations())
|
||||
}
|
||||
}
|
||||
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
conversations.forEach { conversation ->
|
||||
DropdownMenuItem(onClick = {
|
||||
selectedConversation = conversation
|
||||
expanded = false
|
||||
}, text = {
|
||||
Text(conversation)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(2.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text("Reverse order")
|
||||
Checkbox(checked = reverseOrder, onCheckedChange = {
|
||||
reverseOrder = it
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var hasReachedEnd by remember(selectedConversation, stringFilter, reverseOrder) { mutableStateOf(false) }
|
||||
var lastFetchMessageTimestamp by remember(selectedConversation, stringFilter, reverseOrder) { mutableLongStateOf(if (reverseOrder) Long.MAX_VALUE else Long.MIN_VALUE) }
|
||||
val messages = remember(selectedConversation, stringFilter, reverseOrder) { mutableStateListOf<LoggedMessage>() }
|
||||
|
||||
LazyColumn {
|
||||
items(messages) { message ->
|
||||
MessageView(message)
|
||||
}
|
||||
item {
|
||||
if (selectedConversation != null) {
|
||||
if (hasReachedEnd) {
|
||||
Text("No more messages", modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
} else {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.height(20.dp)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit, selectedConversation, stringFilter, reverseOrder) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val newMessages = messageLoggerWrapper.fetchMessages(
|
||||
selectedConversation ?: return@withContext,
|
||||
lastFetchMessageTimestamp,
|
||||
30,
|
||||
reverseOrder
|
||||
) { messageData ->
|
||||
if (stringFilter.isEmpty()) return@fetchMessages true
|
||||
var isMatch = false
|
||||
decodeMessage(messageData) { contentType, messageReader, _ ->
|
||||
if (contentType == ContentType.CHAT) {
|
||||
val content = messageReader.getString(2, 1) ?: return@decodeMessage
|
||||
isMatch = content.contains(stringFilter, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
isMatch
|
||||
}
|
||||
if (newMessages.isEmpty()) {
|
||||
hasReachedEnd = true
|
||||
return@withContext
|
||||
}
|
||||
lastFetchMessageTimestamp = newMessages.lastOrNull()?.timestamp ?: return@withContext
|
||||
withContext(Dispatchers.Main) {
|
||||
messages.addAll(newMessages)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val topBarActions: @Composable (RowScope.() -> Unit) = {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var showSearchTextField by remember { mutableStateOf(false) }
|
||||
|
||||
if (showSearchTextField) {
|
||||
var searchValue by remember { mutableStateOf("") }
|
||||
|
||||
TextField(
|
||||
value = searchValue,
|
||||
onValueChange = { keyword ->
|
||||
searchValue = keyword
|
||||
},
|
||||
keyboardActions = KeyboardActions(onDone = { focusRequester.freeFocus() }),
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.weight(1f, fill = true)
|
||||
.padding(end = 10.dp)
|
||||
.height(70.dp),
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
ElevatedButton(onClick = {
|
||||
stringFilter = searchValue
|
||||
}) {
|
||||
Text("Search")
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
showSearchTextField = !showSearchTextField
|
||||
stringFilter = ""
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = if (showSearchTextField) Icons.Filled.Close
|
||||
else Icons.Filled.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,14 @@ class HomeSettings : Routes.Route() {
|
||||
Text(text = "Clear")
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
onClick = {
|
||||
routes.loggerHistory.navigate()
|
||||
}
|
||||
) {
|
||||
Text(text = "View Message History")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"home": "Home",
|
||||
"home_settings": "Settings",
|
||||
"home_logs": "Logs",
|
||||
"logger_history": "Logger History",
|
||||
"logged_stories": "Logged Stories",
|
||||
"social": "Social",
|
||||
"manage_scope": "Manage Scope",
|
||||
"messaging_preview": "Preview",
|
||||
@@ -940,6 +942,7 @@
|
||||
"spotlight": "Spotlight",
|
||||
"profile_picture": "Profile Picture",
|
||||
"story_logger": "Story Logger",
|
||||
"message_logger": "Message Logger",
|
||||
"merged": "Merged"
|
||||
},
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@ import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
class LoggedMessage(
|
||||
val messageId: Long,
|
||||
val timestamp: Long,
|
||||
val messageData: ByteArray
|
||||
)
|
||||
|
||||
class MessageLoggerWrapper(
|
||||
val databaseFile: File
|
||||
): MessageLoggerInterface.Stub() {
|
||||
@@ -60,29 +67,24 @@ class MessageLoggerWrapper(
|
||||
runCatching { UUID.fromString(it) }.isFailure
|
||||
}) return longArrayOf()
|
||||
|
||||
val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id IN (${
|
||||
conversationId.joinToString(
|
||||
","
|
||||
) { "'$it'" }
|
||||
}) ORDER BY message_id DESC LIMIT $limit", null)
|
||||
|
||||
val ids = mutableListOf<Long>()
|
||||
while (cursor.moveToNext()) {
|
||||
ids.add(cursor.getLong(0))
|
||||
return database.rawQuery("SELECT message_id FROM messages WHERE conversation_id IN (${
|
||||
conversationId.joinToString(",") { "'$it'" }
|
||||
}) ORDER BY message_id DESC LIMIT $limit", null).use {
|
||||
val ids = mutableListOf<Long>()
|
||||
while (it.moveToNext()) {
|
||||
ids.add(it.getLong(0))
|
||||
}
|
||||
ids.toLongArray()
|
||||
}
|
||||
cursor.close()
|
||||
return ids.toLongArray()
|
||||
}
|
||||
|
||||
override fun getMessage(conversationId: String?, id: Long): ByteArray? {
|
||||
val cursor = database.rawQuery("SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, id.toString()))
|
||||
val message: ByteArray? = if (cursor.moveToFirst()) {
|
||||
cursor.getBlob(0)
|
||||
} else {
|
||||
null
|
||||
return database.rawQuery(
|
||||
"SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?",
|
||||
arrayOf(conversationId, id.toString())
|
||||
).use {
|
||||
if (it.moveToFirst()) it.getBlob(0) else null
|
||||
}
|
||||
cursor.close()
|
||||
return message
|
||||
}
|
||||
|
||||
override fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray) {
|
||||
@@ -116,19 +118,17 @@ class MessageLoggerWrapper(
|
||||
}
|
||||
|
||||
fun getStoredMessageCount(): Int {
|
||||
val cursor = database.rawQuery("SELECT COUNT(*) FROM messages", null)
|
||||
cursor.moveToFirst()
|
||||
val count = cursor.getInt(0)
|
||||
cursor.close()
|
||||
return count
|
||||
return database.rawQuery("SELECT COUNT(*) FROM messages", null).use {
|
||||
it.moveToFirst()
|
||||
it.getInt(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun getStoredStoriesCount(): Int {
|
||||
val cursor = database.rawQuery("SELECT COUNT(*) FROM stories", null)
|
||||
cursor.moveToFirst()
|
||||
val count = cursor.getInt(0)
|
||||
cursor.close()
|
||||
return count
|
||||
return database.rawQuery("SELECT COUNT(*) FROM stories", null).use {
|
||||
it.moveToFirst()
|
||||
it.getInt(0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteMessage(conversationId: String, messageId: Long) {
|
||||
@@ -174,4 +174,39 @@ class MessageLoggerWrapper(
|
||||
}
|
||||
return stories
|
||||
}
|
||||
|
||||
fun getAllConversations(): List<String> {
|
||||
return database.rawQuery("SELECT DISTINCT conversation_id FROM messages", null).use {
|
||||
val conversations = mutableListOf<String>()
|
||||
while (it.moveToNext()) {
|
||||
conversations.add(it.getString(0))
|
||||
}
|
||||
conversations
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchMessages(
|
||||
conversationId: String,
|
||||
fromTimestamp: Long,
|
||||
limit: Int,
|
||||
reverseOrder: Boolean = true,
|
||||
filter: ((LoggedMessage) -> Boolean)? = null
|
||||
): List<LoggedMessage> {
|
||||
val messages = mutableListOf<LoggedMessage>()
|
||||
database.rawQuery(
|
||||
"SELECT * FROM messages WHERE conversation_id = ? AND added_timestamp ${if (reverseOrder) "<" else ">"} ? ORDER BY added_timestamp ${if (reverseOrder) "DESC" else "ASC"}",
|
||||
arrayOf(conversationId, fromTimestamp.toString())
|
||||
).use {
|
||||
while (it.moveToNext() && messages.size < limit) {
|
||||
val message = LoggedMessage(
|
||||
messageId = it.getLongOrNull("message_id") ?: continue,
|
||||
timestamp = it.getLongOrNull("added_timestamp") ?: continue,
|
||||
messageData = it.getBlobOrNull("message_data") ?: continue
|
||||
)
|
||||
if (filter != null && !filter(message)) continue
|
||||
messages.add(message)
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ enum class MediaDownloadSource(
|
||||
SPOTLIGHT("spotlight", "spotlight"),
|
||||
PROFILE_PICTURE("profile_picture", "profile_picture"),
|
||||
STORY_LOGGER("story_logger", "story_logger"),
|
||||
MESSAGE_LOGGER("message_logger", "message_logger"),
|
||||
MERGED("merged", "merged");
|
||||
|
||||
fun matches(source: String?): Boolean {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package me.rhunk.snapenhance.common.util.ktx
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.ApplicationInfoFlags
|
||||
import android.os.Build
|
||||
@@ -11,3 +13,8 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) =
|
||||
@Suppress("DEPRECATION")
|
||||
getApplicationInfo(packageName, flags)
|
||||
}
|
||||
|
||||
fun Context.copyToClipboard(data: String, label: String = "Copied Text") {
|
||||
getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(
|
||||
ClipData.newPlainText(label, data))
|
||||
}
|
||||
@@ -158,8 +158,4 @@ class ModContext(
|
||||
fun getConfigLocale(): String {
|
||||
return _config.locale
|
||||
}
|
||||
|
||||
fun copyToClipboard(data: String, label: String = "Copied Text") {
|
||||
androidContext.getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText(label, data))
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import me.rhunk.snapenhance.common.data.MessagingRuleType
|
||||
import me.rhunk.snapenhance.common.data.download.*
|
||||
import me.rhunk.snapenhance.common.database.impl.ConversationMessage
|
||||
import me.rhunk.snapenhance.common.database.impl.FriendInfo
|
||||
import me.rhunk.snapenhance.common.util.ktx.copyToClipboard
|
||||
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
||||
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
|
||||
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
|
||||
@@ -161,7 +162,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
setTitle("Debug Media Info")
|
||||
setView(debugEditText(context, mediaInfoText))
|
||||
setNeutralButton("Copy") { _, _ ->
|
||||
this@MediaDownloader.context.copyToClipboard(mediaInfoText)
|
||||
context.copyToClipboard(mediaInfoText)
|
||||
}
|
||||
setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() }
|
||||
}.show()
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.rhunk.snapenhance.common.data.ContentType
|
||||
import me.rhunk.snapenhance.common.ui.createComposeView
|
||||
import me.rhunk.snapenhance.common.util.ktx.copyToClipboard
|
||||
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
|
||||
import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver
|
||||
import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader
|
||||
@@ -74,7 +75,7 @@ class ChatActionMenu : AbstractMenu() {
|
||||
setView(debugEditText(context, text))
|
||||
setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
|
||||
setNegativeButton("Copy") { _, _ ->
|
||||
this@ChatActionMenu.context.copyToClipboard(text, title)
|
||||
context.copyToClipboard(text, title)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user