From a36151cb10d3542c389813a8c1d8c7de76949173 Mon Sep 17 00:00:00 2001 From: Ushie Date: Thu, 21 May 2026 04:06:35 +0300 Subject: [PATCH] feat: miaaaa0a Co-authored-by: miaaaa0a --- .../com/aliucord/gradle/repo/Authors.kt | 1 + plugins/ImageToClipboard/build.gradle.kts | 20 +++ .../imagetoclipboard/ImageToClipboard.kt | 128 ++++++++++++++++++ .../imagetoclipboard/MimeTypeGetter.kt | 93 +++++++++++++ .../NativeFriendNicknames/build.gradle.kts | 20 +++ .../EditNicknameDialog.kt | 64 +++++++++ .../NativeFriendNicknames.kt | 111 +++++++++++++++ 7 files changed, 437 insertions(+) create mode 100644 plugins/ImageToClipboard/build.gradle.kts create mode 100644 plugins/ImageToClipboard/src/main/java/ing/frolick/imagetoclipboard/ImageToClipboard.kt create mode 100644 plugins/ImageToClipboard/src/main/java/ing/frolick/imagetoclipboard/MimeTypeGetter.kt create mode 100644 plugins/NativeFriendNicknames/build.gradle.kts create mode 100644 plugins/NativeFriendNicknames/src/main/java/ing/frolick/nativefriendnicknames/EditNicknameDialog.kt create mode 100644 plugins/NativeFriendNicknames/src/main/java/ing/frolick/nativefriendnicknames/NativeFriendNicknames.kt diff --git a/build-logic/src/main/kotlin/com/aliucord/gradle/repo/Authors.kt b/build-logic/src/main/kotlin/com/aliucord/gradle/repo/Authors.kt index 9b001f6..cab4c36 100644 --- a/build-logic/src/main/kotlin/com/aliucord/gradle/repo/Authors.kt +++ b/build-logic/src/main/kotlin/com/aliucord/gradle/repo/Authors.kt @@ -44,6 +44,7 @@ object Authors { val Nyako = Author("nyakowint", 118437263754395652L) val Juby210 = Author("Juby210", 324622488644616195L) val LampDelivery = Author("LampDelivery", 650805815623680030L) + val miaaaa0a = Author("miaaaa0a", 435750383491481602L) } /** diff --git a/plugins/ImageToClipboard/build.gradle.kts b/plugins/ImageToClipboard/build.gradle.kts new file mode 100644 index 0000000..786730b --- /dev/null +++ b/plugins/ImageToClipboard/build.gradle.kts @@ -0,0 +1,20 @@ +import com.aliucord.gradle.repo.Authors +import com.aliucord.gradle.repo.author + +plugins { + id("com.aliucord.plugins-repo") +} + +version = "1.2.0" +description = "add an option to copy images to your clipboard" + +aliucord { + author(Authors.miaaaa0a) + + changelog.set( + """ + # 1.2.0 + * completely rewrite mime type detection + """.trimIndent(), + ) +} diff --git a/plugins/ImageToClipboard/src/main/java/ing/frolick/imagetoclipboard/ImageToClipboard.kt b/plugins/ImageToClipboard/src/main/java/ing/frolick/imagetoclipboard/ImageToClipboard.kt new file mode 100644 index 0000000..287e9ef --- /dev/null +++ b/plugins/ImageToClipboard/src/main/java/ing/frolick/imagetoclipboard/ImageToClipboard.kt @@ -0,0 +1,128 @@ +package ing.frolick.imagetoclipboard + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider + +import com.aliucord.Logger +import com.aliucord.Utils +import com.aliucord.Utils.appContext +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.Hook +import com.aliucord.utils.ViewUtils.findViewById +import com.discord.app.AppFragment +import com.discord.databinding.WidgetMediaBinding +import com.discord.utilities.color.ColorCompat +import com.discord.widgets.media.WidgetMedia + +import com.google.android.material.appbar.AppBarLayout +import com.lytefast.flexinput.R +import java.io.File +import java.io.InputStream +import java.net.URL + +@SuppressWarnings("unused") +@AliucordPlugin +class ImageToClipboard : Plugin() { + // https://stackoverflow.com/questions/14473180/regex-to-get-a-filename-from-a-url#comment115606945_26253039 + var urlRegex = Regex("[^/\\\\&?#]+\\.\\w{3,4}(?=([?&#].*$|$))") + + @SuppressLint("RestrictedApi") + override fun start(context: Context) { + patcher.patch( + WidgetMedia::class.java, "onViewBoundOrOnResume", arrayOf(), + Hook { + val widgetMedia = it.thisObject as WidgetMedia + val binding = WidgetMedia.`access$getBinding$p`(widgetMedia) + val field = WidgetMediaBinding::class.java.getDeclaredField("b").apply { isAccessible = true } + val appBarLayout = field.get(binding) as AppBarLayout + val menuMediaGroup = appBarLayout.findViewById("menu_media_download").parent as ViewGroup + + val imageUrl = (widgetMedia as AppFragment).mostRecentIntent + .getStringExtra("INTENT_MEDIA_URL") ?: return@Hook + val fileName = urlRegex.find(imageUrl, 0)?.value + val fileExt = fileName?.split('.')?.last() + var mimeType = MimeType.OTHER + Utils.threadPool.submit { + mimeType = MimeTypeGetter.get(imageUrl) + }.get() + //logger.info("MIME TYPE: ${mimeType.type}") + + //Logger().info("CHILDREN COUNT: ${actionBar.childCount}") + //val menuMediaGroup = actionBar.getChildAt(0) as ViewGroup + + val ctx = menuMediaGroup.context + val copyBtn = TextView(ctx, null, 0, R.i.UiKit_ImageButton).apply { + setCompoundDrawablesWithIntrinsicBounds( + ContextCompat + .getDrawable(ctx, R.e.ic_copy_24dp)!! + .mutate() + .apply { + setTint(ColorCompat.getThemedColor(ctx, R.b.colorTextNormal)) + }, + null, + null, + null, + ) + setOnClickListener { + Utils.showToast("copying...", false) + + Utils.threadPool.execute { + try { + val stream = URL(imageUrl).openStream() + + // context.filesDir is /data/data/com.aliucord/files, we are saving the image there + // in theory it'd be better to use context.cacheDir, but discord doesn't expose that + // in file_paths.xml, so it's not usable + val file = File(context.filesDir, "clipboard_tmp.${mimeType.ext}") + file.outputStream().use { out -> stream.copyTo(out) } + val uri = FileProvider.getUriForFile( + appContext, + "${context.packageName}.file-provider", + file + ) + val clip = ClipData( + ClipDescription("image", arrayOf(mimeType.type)), + ClipData.Item(uri) + ) + (appContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager) + .setPrimaryClip(clip) + + Utils.mainThread.post { Utils.showToast("copied the image!", false) } + } catch (e: Exception) { + logger.error("itc error: ${e.message}", e) + Utils.mainThread.post { Utils.showToast("failed to copy >.<", false) } + } + } + } + } + if (appBarLayout.findViewWithTag("copy_btn") == null) { + // non image begone!!! + if (fileExt == "png" || + fileExt == "jpg" || + fileExt == "jpeg" || + fileExt == "webp" || + fileExt == "gif" || + fileExt == "apng") { + copyBtn.tag = "copy_btn" + menuMediaGroup.addView(copyBtn) + } + } + }, + ) + } + + override fun stop(context: Context) { + patcher.unpatchAll() + } +} diff --git a/plugins/ImageToClipboard/src/main/java/ing/frolick/imagetoclipboard/MimeTypeGetter.kt b/plugins/ImageToClipboard/src/main/java/ing/frolick/imagetoclipboard/MimeTypeGetter.kt new file mode 100644 index 0000000..13adf4d --- /dev/null +++ b/plugins/ImageToClipboard/src/main/java/ing/frolick/imagetoclipboard/MimeTypeGetter.kt @@ -0,0 +1,93 @@ +package ing.frolick.imagetoclipboard + +import android.os.Build +import androidx.annotation.RequiresApi +import java.io.File +import java.io.InputStream +import java.net.URL +import java.nio.file.Files +import java.util.Arrays.asList +import com.aliucord.Logger +import java.net.HttpURLConnection + +class MimeTypeGetter { + companion object { + private fun fromMagicNumber(url: String): MimeType { + val stream = URL(url).openStream() + val header = ByteArray(12).also { buf -> + var pos = 0 + while (pos < buf.size) { + val n = stream.read(buf, pos, buf.size - pos) + if (n == -1) break + pos += n + } + } + stream.close() + fun at(vararg bytes: Int, offset: Int = 0) = + bytes.indices.all { header[offset + it].toInt() and 0xFF == bytes[it] } + + // TODO: FIX APNG DETECTION AND MAKE IT OPTIMAL, THIS ALGO IS REALLY SLOW AND BROKEN + // only real way to detect APNG is to look for the animation control chunk (acTL) + // that comes later in the file, since the format has the same file magic as PNG + /*if (at(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) { + val buf = ArrayDeque() + // read first 4 bytes for sliding window + while (buf.size <= 4) buf.addLast(stream.read().also { if (it == -1) return MimeType.PNG }) + Logger("piglin").info("CHUNK: ${ArrayDeque(listOf(0x49, 0x44, 0x41, 0x54)).joinToString(" ") { "%02x".format(it.toByte()) }}") + + while (buf.intersect(listOf(0x61, 0x63, 0x54, 0x4C)).isNotEmpty()) { + // acTL always comes before the IDAT chunk. IDAT chunk before acTL == PNG + if (buf.intersect(listOf(0x49, 0x44, 0x41, 0x54)).isEmpty()) return MimeType.PNG + + buf.removeFirst() + val n = stream.read() + //Logger("piglin").info("CHUNK: ${buf.joinToString(" ") { "%02x".format(it.toByte()) }}") + if (n == -1) return MimeType.PNG + buf.addLast(n) + } + return MimeType.APNG + }*/ + + return when { + at(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) -> MimeType.PNG + at(0xFF, 0xD8, 0xFF, 0xDB) -> MimeType.JPEG + at(0xFF, 0xD8, 0xFF, 0xE0) -> MimeType.JPEG + at(0xFF, 0xD8, 0xFF, 0xEE) -> MimeType.JPEG + at(0xFF, 0xD8, 0xFF, 0xE1) -> MimeType.JPEG + at(0x47, 0x49, 0x46, 0x38, 0x37, 0x61) -> MimeType.GIF + at(0x47, 0x49, 0x46, 0x38, 0x39, 0x61) -> MimeType.GIF + at(0x42, 0x4D) -> MimeType.BMP + // webp: RIFF????WEBP + at(0x52, 0x49, 0x46, 0x46) && + at(0x57, 0x45, 0x42, 0x50, offset = 8) -> MimeType.WEBP + else -> MimeType.OTHER + } + } + + private fun fromHeaders(url: String): MimeType? { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + this.instanceFollowRedirects = true + this.requestMethod = "HEAD" + } + connection.connect() + connection.headerFields["Content-Type"]?.let { + return MimeType.entries.firstOrNull { e -> e.type == it.first() } ?: MimeType.OTHER + } + return null + } + + fun get(url: String): MimeType { + return fromHeaders(url) ?: fromMagicNumber(url) + } + } +} + +enum class MimeType(val type: String, val ext: String) { + PNG("image/png", "png"), + APNG("image/apng", "png"), + JPEG("image/jpeg", "jpg"), + GIF("image/gif", "gif"), + BMP("image/x-ms-bmp", "bmp"), + WEBP("image/webp", "webp"), + OTHER("", "bin") +} diff --git a/plugins/NativeFriendNicknames/build.gradle.kts b/plugins/NativeFriendNicknames/build.gradle.kts new file mode 100644 index 0000000..f18eed3 --- /dev/null +++ b/plugins/NativeFriendNicknames/build.gradle.kts @@ -0,0 +1,20 @@ +import com.aliucord.gradle.repo.Authors +import com.aliucord.gradle.repo.author + +plugins { + id("com.aliucord.plugins-repo") +} + +version = "1.1.6" +description = "a plugin that backports the friend nickname feature" + +aliucord { + author(Authors.miaaaa0a) + + changelog.set( + """ + # 1.1.6 + * don't display friend nickname in servers + """.trimIndent(), + ) +} diff --git a/plugins/NativeFriendNicknames/src/main/java/ing/frolick/nativefriendnicknames/EditNicknameDialog.kt b/plugins/NativeFriendNicknames/src/main/java/ing/frolick/nativefriendnicknames/EditNicknameDialog.kt new file mode 100644 index 0000000..063b99e --- /dev/null +++ b/plugins/NativeFriendNicknames/src/main/java/ing/frolick/nativefriendnicknames/EditNicknameDialog.kt @@ -0,0 +1,64 @@ +package ing.frolick.nativefriendnicknames + +import android.view.View + +import com.aliucord.Http +import com.aliucord.Utils +import com.aliucord.fragments.InputDialog +import com.aliucord.wrappers.users.globalName +import com.discord.models.user.User +import com.discord.stores.StoreStream +import com.discord.stores.updates.ObservationDeck + +data class UpdateRelationshipRequest( + val nickname: String? +) + +class EditNicknameDialog(private val user: User) : InputDialog() { + override fun onViewBound(view: View) { + setTitle("Edit Friend Nickname") + setDescription("Enter a new nickname for your friend:") + setPlaceholderText("Nickname") + + setOnDialogShownListener { + // only replace input if a friend nickname already exists + if (Stores.friendNicknames.getNickname(user.id) != null) + inputLayout.editText?.setText(user.globalName) + } + + setOnOkListener { + val inputName = input.trim() + + if (inputName != user.globalName) { + Utils.threadPool.execute { + val req = UpdateRelationshipRequest(inputName) + val res = Http.Request + .newDiscordRNRequest("/users/@me/relationships/${user.id}", "PATCH") + .executeWithJson(req) + // why does this not throw the usual 200 success code dieee discord + if (res.statusCode == 204) { + // this is supposed to instantly update everything that uses User.globalName + // but it doesn't :( + /*StoreStream.`access$getDispatcher$p`(StoreStream.getPresences().stream).schedule { + val storeUsers = StoreStream.Companion!!.users + val getUsersUpdate = + storeUsers::class.java.getDeclaredMethod("access\$getUsersUpdate\$cp").apply { + isAccessible = true + } + val usersUpdate = getUsersUpdate.invoke(null) + storeUsers.markChanged(usersUpdate as ObservationDeck.UpdateSource?) + }*/ + } else { + Utils.mainThread.post { + Utils.showToast("Error ${res.statusCode} while changing ${user.username}'s nickname!", true) + } + } + } + } + + dismiss() + } + + super.onViewBound(view) + } +} diff --git a/plugins/NativeFriendNicknames/src/main/java/ing/frolick/nativefriendnicknames/NativeFriendNicknames.kt b/plugins/NativeFriendNicknames/src/main/java/ing/frolick/nativefriendnicknames/NativeFriendNicknames.kt new file mode 100644 index 0000000..8356432 --- /dev/null +++ b/plugins/NativeFriendNicknames/src/main/java/ing/frolick/nativefriendnicknames/NativeFriendNicknames.kt @@ -0,0 +1,111 @@ +package ing.frolick.nativefriendnicknames + +import android.content.Context +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.lytefast.flexinput.R + +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.api.GatewayAPI +import com.aliucord.entities.Plugin +import com.aliucord.patcher.* +import com.aliucord.wrappers.users.globalName +import com.discord.api.channel.`ChannelUtils$getDisplayName$1` +import com.discord.models.user.CoreUser +import com.discord.api.user.User +import com.discord.utilities.color.ColorCompat +import b.a.a.d.a as UserActionsDialog +import com.discord.stores.StoreStream +import com.aliucord.Utils +import com.aliucord.wrappers.ChannelWrapper.Companion.isDM + +internal class Relationship(val id: Long, val nickname: String?) +internal class Ready(val relationships: List) +object Stores { + val friendNicknames = StoreRelationshipNick() +} + +@SuppressWarnings("unused") +@AliucordPlugin(requiresRestart = true) +class NativeFriendNicknames : Plugin() { + override fun start(context: Context) { + Stores.friendNicknames.setup() + + patcher.before("getGlobalName") { + if (!StoreStream.getChannelsSelected().selectedChannel.isDM()) return@before + val nickname = Stores.friendNicknames.getNickname(this.id) ?: this.globalName + it.result = nickname + } + + // this gets globalName from a different api, so I have to patch it out manually + patcher.before<`ChannelUtils$getDisplayName$1`>("invoke", Any::class.java) { + val nickname = Stores.friendNicknames.getNickname((it.args[0] as User).id) ?: (it.args[0] as User).globalName ?: (it.args[0] as User).username + it.result = nickname + } + //Logger().info("METHODS: ${`ChannelUtils$getDisplayName$1`::class.java.declaredMethods.joinToString(",")}") + + patcher.patch( + UserActionsDialog::class.java, + "onViewBound", + arrayOf(View::class.java), + PreHook { param -> + val dialog = param.thisObject as UserActionsDialog + val root = param.args[0] as LinearLayout + + val userId = dialog.argumentsOrDefault + .getLong("com.discord.intent.extra.EXTRA_USER_ID", 0L) + val user = StoreStream.Companion!!.users.getUsers(listOf(userId), false)[userId] ?: return@PreHook + // no friend = no option + if (!Stores.friendNicknames.data.containsKey(userId)) return@PreHook + + val ctx = root.context + val newItem = TextView(ctx, null, 0, R.i.UiKit_ListItem_Icon).apply { + text = "Edit Friend Nickname" + setOnClickListener { + dialog.dismiss() + EditNicknameDialog(user).show(dialog.parentFragmentManager, "EditNickname") + } + setCompoundDrawablesWithIntrinsicBounds( + ContextCompat + .getDrawable(ctx, R.e.ic_edit_24dp)!! + .mutate() + .apply { + setTint(ColorCompat.getThemedColor(ctx, R.b.colorChannelIcon)) + }, + null, + null, + null + ) + } + + root.addView(newItem) + } + ) + } + + override fun stop(context: Context) { + patcher.unpatchAll() + } +} + +class StoreRelationshipNick(var data: MutableMap = mutableMapOf()) { + fun setup() { + GatewayAPI.onEvent("READY") { event -> + data = event.relationships.associate { relationship -> + relationship.id to relationship.nickname + } as MutableMap + } + GatewayAPI.onEvent("RELATIONSHIP_ADD") { event -> + data[event.id] = event.nickname + } + GatewayAPI.onEvent("RELATIONSHIP_UPDATE") { event -> + data[event.id] = event.nickname + } + GatewayAPI.onEvent("RELATIONSHIP_REMOVE") { event -> + data.remove(event.id) + } + } + fun getNickname(id: Long?): String? = data[id] +}