commit 2a8fcacd2fec0aeb6246b197d6ef758745bb66c3
parent 90d76c6412e994f1b0c7cfafe89270d0ededa7be
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Wed, 27 Dec 2023 17:39:46 +0100

feat(core/ui): conversation toolbox
- add scripting support

Diffstat:
Mapp/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt | 4++--
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/EnumScriptInterface.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt | 93+++++++++++++++++++++++++++++++++++--------------------------------------------
Acore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ConversationToolbox.kt | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt | 1+
5 files changed, 217 insertions(+), 54 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/e2ee/E2EEImplementation.kt @@ -62,7 +62,7 @@ class E2EEImplementation ( override fun acceptPairingRequest(friendId: String, publicKey: ByteArray): ByteArray? { val kemGen = KyberKEMGenerator(secureRandom) - val encapsulatedSecret = runCatching { + val encapsulatedSecret = runCatching { kemGen.generateEncapsulated( KyberPublicKeyParameters( kyberDefaultParameters, @@ -164,7 +164,7 @@ class E2EEImplementation ( cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(encryptionKey, "AES"), IvParameterSpec(iv)) cipher.doFinal(message) }.onFailure { - context.log.error("Failed to decrypt message from $friendId", it) + context.log.warn("Failed to decrypt message for $friendId") return null }.getOrNull() } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/EnumScriptInterface.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ui/EnumScriptInterface.kt @@ -8,4 +8,5 @@ enum class EnumScriptInterface( ) { SETTINGS("settings", BindingSide.MANAGER), FRIEND_FEED_CONTEXT_MENU("friendFeedContextMenu", BindingSide.CORE), + CONVERSATION_TOOLBOX("conversationToolbox", BindingSide.CORE), } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt @@ -7,10 +7,14 @@ import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.Shape import android.view.View import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams -import android.view.ViewGroup.MarginLayoutParams import android.widget.Button -import android.widget.TextView +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.data.MessageState import me.rhunk.snapenhance.common.data.MessagingRuleType @@ -18,14 +22,13 @@ import me.rhunk.snapenhance.common.data.RuleState import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.protobuf.ProtoWriter -import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent import me.rhunk.snapenhance.core.event.events.impl.NativeUnaryCallEvent import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.features.MessagingRuleFeature -import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.features.impl.ui.ConversationToolbox import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper import me.rhunk.snapenhance.core.ui.addForegroundDrawable import me.rhunk.snapenhance.core.ui.removeForegroundDrawable @@ -157,56 +160,34 @@ class EndToEndEncryption : MessagingRuleFeature( } } - private fun openManagementPopup() { - val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: return - val friendId = context.database.getDMOtherParticipant(conversationId) - - if (friendId == null) { - context.shortToast("This menu is only available in direct messages.") - return - } - - val actions = listOf( - "Initiate a new shared secret", - "Show shared key fingerprint" - ) - - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply { - setTitle("End-to-end encryption") - setItems(actions.toTypedArray()) { _, which -> - when (which) { - 0 -> { - warnKeyOverwrite(friendId) { - askForKeys(conversationId) - } - } - 1 -> { - val fingerprint = e2eeInterface.getSecretFingerprint(friendId) - ViewAppearanceHelper.newAlertDialogBuilder(context).apply { - setTitle("End-to-end encryption") - setMessage("Your fingerprint is:\n\n$fingerprint\n\nMake sure to check if it matches your friend's fingerprint!") - setPositiveButton("OK") { _, _ -> } - }.show() - } - } - } - setPositiveButton("OK") { _, _ -> } - }.show() - } - @SuppressLint("SetTextI18n", "DiscouragedApi") override fun onActivityCreate() { if (!isEnabled) return - // add button to input bar - context.event.subscribe(AddViewEvent::class) { param -> - if (param.view.toString().contains("default_input_bar")) { - (param.view as ViewGroup).addView(TextView(param.view.context).apply { - layoutParams = MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT) - setOnClickListener { openManagementPopup() } - setPadding(20, 20, 20, 20) - textSize = 23f - text = "\uD83D\uDD12" - }) + + context.feature(ConversationToolbox::class).addComposable("End-to-end Encryption", filter = { + context.database.getDMOtherParticipant(it) != null + }) { dialog, conversationId -> + val friendId = remember { + context.database.getDMOtherParticipant(conversationId) + } ?: return@addComposable + val fingerprint = remember { + runCatching { + e2eeInterface.getSecretFingerprint(friendId) + }.getOrNull() + } + if (fingerprint != null) { + Text("Your fingerprint is:\n\n$fingerprint\n\nMake sure to check if it matches your friend's fingerprint!") + } else { + Text("You don't have a shared secret with this friend yet. Click below to initiate a new one.") + } + Spacer(modifier = Modifier.height(10.dp)) + Button(onClick = { + dialog.dismiss() + warnKeyOverwrite(friendId) { + askForKeys(conversationId) + } + }) { + Text("Initiate new shared secret") } } @@ -245,6 +226,10 @@ class EndToEndEncryption : MessagingRuleFeature( viewGroup.addView(Button(context.mainActivity!!).apply { text = "Accept secret" tag = receiveSecretTag + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) setOnClickListener { handleSecretResponse(conversationId, secret) } @@ -255,6 +240,10 @@ class EndToEndEncryption : MessagingRuleFeature( viewGroup.addView(Button(context.mainActivity!!).apply { text = "Receive public key" tag = receivePublicKeyTag + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) setOnClickListener { handlePublicKeyRequest(conversationId, publicKey) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ConversationToolbox.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ConversationToolbox.kt @@ -0,0 +1,171 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.view.Gravity +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times +import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface +import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager +import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.util.ktx.getId + + +data class ComposableMenu( + val title: String, + val filter: (conversationId: String) -> Boolean, + val composable: @Composable (alertDialog: AlertDialog, conversationId: String) -> Unit, +) + +class ConversationToolbox : Feature("Conversation Toolbox", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private val composableList = mutableListOf<ComposableMenu>() + private val expandedComposableCache = mutableStateMapOf<String, Boolean>() + + fun addComposable(title: String, filter: (conversationId: String) -> Boolean = { true }, composable: @Composable (alertDialog: AlertDialog, conversationId: String) -> Unit) { + composableList.add( + ComposableMenu(title, filter, composable) + ) + } + + @SuppressLint("SetTextI18n") + override fun onActivityCreate() { + val defaultInputBarId = context.resources.getId("default_input_bar") + + context.event.subscribe(AddViewEvent::class) { event -> + if (event.view.id != defaultInputBarId) return@subscribe + if (composableList.isEmpty()) return@subscribe + + (event.view as ViewGroup).addView(FrameLayout(event.view.context).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + (52 * context.resources.displayMetrics.density).toInt(), + ).apply { + gravity = Gravity.BOTTOM + } + setPadding(25, 0, 25, 0) + + addView(TextView(event.view.context).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + gravity = Gravity.CENTER_VERTICAL + } + setOnClickListener { + openToolbox() + } + textSize = 21f + text = "\uD83E\uDDF0" + }) + }) + } + + context.scriptRuntime.eachModule { + val interfaceManager = getBinding(InterfaceManager::class)?.takeIf { + it.hasInterface(EnumScriptInterface.CONVERSATION_TOOLBOX) + } ?: return@eachModule + addComposable("\uD83D\uDCDC ${moduleInfo.displayName}") { alertDialog, conversationId -> + ScriptInterface(remember { + interfaceManager.buildInterface(EnumScriptInterface.CONVERSATION_TOOLBOX, mapOf( + "alertDialog" to alertDialog, + "conversationId" to conversationId, + )) + } ?: return@addComposable) + } + } + } + + private fun openToolbox() { + val openedConversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: run { + context.shortToast("You must open a conversation first") + return + } + + createComposeAlertDialog(context.mainActivity!!) { alertDialog -> + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn( + min = 100.dp, + max = LocalConfiguration.current.screenHeightDp * 0.8f.dp + ) + .verticalScroll(rememberScrollState()) + ) { + Text("Conversation Toolbox", fontSize = 20.sp, modifier = Modifier + .fillMaxWidth() + .padding(10.dp), textAlign = TextAlign.Center) + Spacer(modifier = Modifier.height(10.dp)) + + composableList.reversed().forEach { (title, filter, composable) -> + if (!filter(openedConversationId)) return@forEach + Card( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + shape = MaterialTheme.shapes.medium + ) { + Row( + modifier = Modifier + .clickable { + expandedComposableCache[title] = !(expandedComposableCache[title] ?: false) + } + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + imageVector = if (expandedComposableCache[title] == true) Icons.Filled.KeyboardArrowDown else Icons.Filled.KeyboardArrowUp, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + ) + Text(title, fontSize = 16.sp, fontStyle = FontStyle.Italic) + } + if (expandedComposableCache[title] == true) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) + ) { + runCatching { + composable(alertDialog, openedConversationId) + }.onFailure { throwable -> + Text("Failed to load composable: ${throwable.message}") + context.log.error("Failed to load composable: ${throwable.message}", throwable) + } + } + } + } + } + } + }.show() + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -118,6 +118,7 @@ class FeatureManager( EditTextOverride::class, PreventForcedLogout::class, SuspendLocationUpdates::class, + ConversationToolbox::class, ) initializeFeatures()