From d391acc6f7992263c261357edc4cb716bd9f052a Mon Sep 17 00:00:00 2001 From: suzhelan Date: Mon, 18 Dec 2023 18:44:50 +0800 Subject: [PATCH] fix : Clean Recent Chat for QQNT --- .../top/linl/hook/FixCleanRecentChat.java | 259 ++++++++++++++++++ .../top/linl/util/reflect/ClassUtils.java | 165 +++++++++++ .../top/linl/util/reflect/MethodTool.java | 3 + .../xyz/nextalone/hook/CleanRecentChat.kt | 74 ++--- 4 files changed, 469 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/top/linl/hook/FixCleanRecentChat.java create mode 100644 app/src/main/java/top/linl/util/reflect/ClassUtils.java diff --git a/app/src/main/java/top/linl/hook/FixCleanRecentChat.java b/app/src/main/java/top/linl/hook/FixCleanRecentChat.java new file mode 100644 index 0000000000..52edf8d8d7 --- /dev/null +++ b/app/src/main/java/top/linl/hook/FixCleanRecentChat.java @@ -0,0 +1,259 @@ +/* + * QAuxiliary - An Xposed module for QQ/TIM + * Copyright (C) 2019-2023 QAuxiliary developers + * https://github.com/cinit/QAuxiliary + * + * This software is non-free but opensource software: you can redistribute it + * and/or modify it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation; either + * version 3 of the License, or any later version and our eula as published + * by QAuxiliary contributors. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * and eula along with this software. If not, see + * + * . + */ + +package top.linl.hook; + +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; +import cc.ioctl.util.HookUtils; +import io.github.qauxv.util.Toasts; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import top.linl.util.reflect.ClassUtils; +import top.linl.util.reflect.FieIdUtils; +import top.linl.util.reflect.MethodTool; +import xyz.nextalone.hook.CleanRecentChat; + +/** + * @author suzhelan + * @CreateDate 2023.12.18 + */ +public class FixCleanRecentChat { + + private static final HashMap viewHolderList = new LinkedHashMap<>(); + private static int deleteTextViewId; + private final CleanRecentChat cleanRecentChat; + + public FixCleanRecentChat(CleanRecentChat cleanRecentChat) { + this.cleanRecentChat = cleanRecentChat; + } + + private void hookGetDeleteViewId() { + Class superClass = ClassUtils.getClass("com.tencent.qqnt.chats.biz.guild.GuildDiscoveryItemBuilder").getSuperclass(); + Class findClass = null; + for (Field field : superClass.getDeclaredFields()) { + field.setAccessible(true); + Class type = field.getType(); + if (type.getName().startsWith("com.tencent.qqnt.chats.core.adapter.")) { + findClass = type; + break; + } + } + Method method = MethodTool.find(findClass).params( + android.view.ViewGroup.class, + java.util.List.class + ).returnType(List.class) + .get(); + HookUtils.hookAfterIfEnabled(cleanRecentChat, method, param -> { + if (deleteTextViewId != 0) { + return; + } + List viewList = (List) param.getResult(); + for (View view : viewList) { + if (view instanceof TextView) { + TextView textView = (TextView) view; + if (textView.getText().toString().equals("删除")) { + deleteTextViewId = textView.getId(); + break; + } + } + } + }); + + } + + public void loadHook() throws Exception { + hookGetDeleteViewId(); + hookOnHolder(); + + //不hook onCreate方法了 那样需要重启才能生效 hook onResume可在界面重新渲染到屏幕时会调用生效 + Method onCreateMethod = MethodTool.find("com.tencent.mobileqq.activity.home.Conversation").name("onResume").params(boolean.class).get(); + HookUtils.hookAfterIfEnabled(cleanRecentChat, onCreateMethod, param -> { + ImageView imageView = FieIdUtils.getFirstField(param.thisObject, ImageView.class); + imageView.setOnLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + new Thread(new DeleteAllItemTask()).start(); + return true; + } + }); + }); + + } + + private void hookOnHolder() { + //find + Class recentContactItemHolderClass = ClassUtils.getClass("com.tencent.qqnt.chats.core.adapter.holder.RecentContactItemHolder"); + Method onHolderBindTimeingCallSetOnClickMethod = null; + for (Method method : recentContactItemHolderClass.getDeclaredMethods()) { + Class[] paramTypes = method.getParameterTypes(); + if (paramTypes.length == 3) { + if (paramTypes[0].getName().startsWith("com.tencent.qqnt.chats.core.adapter.builder.") + && paramTypes[1].getName().startsWith("com.tencent.qqnt.chats.core.adapter.") + && paramTypes[2] == int.class) { + method.setAccessible(true); + onHolderBindTimeingCallSetOnClickMethod = method; + break; + } + } + } + HookUtils.hookBeforeIfEnabled(cleanRecentChat, onHolderBindTimeingCallSetOnClickMethod, param -> { + int adapterIndex = (int) param.args[2]; + Object item = param.args[1]; + //Holder在前 索引在后 因为Holder在复用池中所以引用地址不会变 但是索引在Adapter中是随时变化的 + viewHolderList.put(param.thisObject, adapterIndex); + }); + + } + + private static class DeleteAllItemTask implements Runnable { + + private static final AtomicReference deleteMethod = new AtomicReference<>(); + private static Class utilType; + private static Field itemField; + + private Object findItemField(Object viewHolder) throws IllegalAccessException { + if (itemField != null) { + return itemField.get(viewHolder); + } + for (Field field : viewHolder.getClass().getDeclaredFields()) { + try { + field.setAccessible(true); + Object fieldObj = field.get(viewHolder); + if (fieldObj == null) { + continue; + } + String toStr = fieldObj.toString(); + if (toStr.contains("RecentContactChatItem")) { + field.setAccessible(true); + itemField = field; + break; + } + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return itemField.get(viewHolder); + } + + private Class findUtilClassType(Object viewHolder) { + if (utilType != null) { + return utilType; + } + for (Field field : viewHolder.getClass().getDeclaredFields()) { + try { + field.setAccessible(true); + Object fieldObj = field.get(viewHolder); + if (fieldObj == null) { + continue; + } + if (fieldObj.getClass().getName().startsWith("com.tencent.qqnt.chats.core.ui.ChatsListVB$")) { + utilType = field.getType(); + break; + } + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return utilType; + } + + private Method getDeleteMethod(Object viewHolder) { + if (deleteMethod.get() != null) { + return deleteMethod.get(); + } + Class findClass = findUtilClassType(viewHolder); + if (findClass == null) { + throw new RuntimeException("findClass is null"); + } + Method finalDeleteMethod = MethodTool.find(findClass).params(int.class,//index ? + Object.class,//item + ClassUtils.getClass("com.tencent.qqnt.chats.core.adapter.holder.RecentContactItemBinding"),//view binder + int.class//click view id + ).returnType(void.class) + .get(); + deleteMethod.set(finalDeleteMethod); + return deleteMethod.get(); + } + + @Override + public void run() { + final AtomicBoolean isStop = new AtomicBoolean(false); + TimerTask task = new TimerTask() { + @Override + public void run() { + isStop.set(true); + } + }; + //在2秒内尽量删除 + Timer timer = new Timer(); + timer.schedule(task, 2000); + + Toasts.show("开始清理"); + int deleteQuantity = 0; + while (!isStop.get()) { + int size = viewHolderList.size(); + if (size == 0) { + try { + //停一下等待ItemHolder重新bind到屏幕上 然后继续删除 + TimeUnit.MILLISECONDS.sleep(100); + continue; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + Iterator> iterator = viewHolderList.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry viewHolderEntry = iterator.next(); + try { + Object recentContactItemHolder = viewHolderEntry.getKey(); + //delete util + Object util = FieIdUtils.getFirstField(recentContactItemHolder, findUtilClassType(recentContactItemHolder));//util run time obj + int adapterIndex = viewHolderEntry.getValue();//call param 1 + Object itemInfo = findItemField(recentContactItemHolder);//call param 2 + Object itemBinder = FieIdUtils.getFirstField(recentContactItemHolder, + ClassUtils.getClass("com.tencent.qqnt.chats.core.adapter.holder.RecentContactItemBinding"));//call param 3 + int viewId = deleteTextViewId;//call param 4 + getDeleteMethod(recentContactItemHolder).invoke(util, adapterIndex, itemInfo, itemBinder, viewId); + deleteQuantity++; + } catch (Exception e) { + throw new RuntimeException(e); + } + iterator.remove(); + } + } + Toasts.show("已清理结束 数量" + deleteQuantity + "个"); + } + } + +} diff --git a/app/src/main/java/top/linl/util/reflect/ClassUtils.java b/app/src/main/java/top/linl/util/reflect/ClassUtils.java new file mode 100644 index 0000000000..6ec5211014 --- /dev/null +++ b/app/src/main/java/top/linl/util/reflect/ClassUtils.java @@ -0,0 +1,165 @@ +package top.linl.util.reflect; + + +import io.github.qauxv.util.Initiator; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Array; +import java.net.URL; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +public class ClassUtils { + + private static final Object[][] baseTypes = {{"int", int.class}, {"boolean", boolean.class}, {"byte", byte.class}, {"long", long.class}, + {"char", char.class}, {"double", double.class}, {"float", float.class}, {"short", short.class}, {"void", void.class}}; + private static ClassLoader ModuleLoader;//模块类加载器 + private static ClassLoader HostLoader;//宿主应用类加载器 + + static { + setHostClassLoader(Initiator.getHostClassLoader()); + } + + /** + * 获取基本类型 + */ + private static Class getBaseTypeClass(String baseTypeName) { + for (Object[] baseType : baseTypes) { + if (baseTypeName.equals(baseType[0])) { + return (Class) baseType[1]; + } + } + throw new ReflectException(baseTypeName + " <-不是基本的数据类型"); + } + + /** + * 排除常用类 + */ + public static boolean isCommonlyUsedClass(String name) { + return name.startsWith("androidx.") || name.startsWith("android.") || name.startsWith("kotlin.") || name.startsWith("kotlinx.") || name.startsWith( + "com.tencent.mmkv.") || name.startsWith("com.android.tools.r8.") || name.startsWith("com.google.android.") || name.startsWith( + "com.google.gson.") || name.startsWith("com.google.common.") || name.startsWith("com.microsoft.appcenter.") || name.startsWith( + "org.intellij.lang.annotations.") || name.startsWith("org.jetbrains.annotations."); + } + + /** + * 获取类 + */ + public static Class getClass(String className) { + try { + return HostLoader.loadClass(className); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + + public static void setHostClassLoader(ClassLoader loader) { + if (loader == null) { + throw new ReflectException("类加载器为Null 无法设置"); + } + HostLoader = new XClassLoader(loader); + } + + public static ClassLoader getHostLoader() { + return HostLoader; + } + + public static ClassLoader getModuleLoader() { + return ModuleLoader; + } + + public static void setModuleLoader(ClassLoader loader) { + ModuleLoader = loader; + } + + public static class XClassLoader extends ClassLoader { + + private static final Map> CLASS_CACHE = new HashMap<>(); + private final ClassLoader oldClassLoader; + + private XClassLoader(ClassLoader classLoader) { + super(classLoader); + this.oldClassLoader = classLoader; + } + + + @Override + public Class loadClass(String name) throws ClassNotFoundException { + Class clazz = CLASS_CACHE.get(name); + if (clazz != null) { + return clazz; + } + try { + //可能是数组类型的 + if (name.startsWith("[")) { + int index = name.lastIndexOf('['); + //获取原类型 + try { + clazz = getBaseTypeClass(name.substring(index + 1)); + } catch (Exception e) { + clazz = oldClassLoader.loadClass(name.substring(index + 1)); + } + for (int i = 0; i < name.length(); i++) { + char ch = name.charAt(i); + if (ch == '[') { + clazz = Array.newInstance(clazz, 0).getClass(); + } else { + break; + } + } + CLASS_CACHE.put(name, clazz); + return clazz; + } + //可能是基础类型 + try { + clazz = getBaseTypeClass(name); + } catch (Exception e) { + //因为默认的ClassLoader.load() 不能加载"int"这种类型 + clazz = oldClassLoader.loadClass(name); + } + CLASS_CACHE.put(name, clazz); + return clazz; + } catch (Throwable throwable) { + throw new ReflectException("没有找到类: " + name); + } + } + + @Override + public URL getResource(String name) { + return oldClassLoader.getResource(name); + } + + @Override + public Enumeration getResources(String name) throws IOException { + return oldClassLoader.getResources(name); + } + + + @Override + public InputStream getResourceAsStream(String name) { + return oldClassLoader.getResourceAsStream(name); + } + + + @Override + public void setDefaultAssertionStatus(boolean enabled) { + oldClassLoader.setDefaultAssertionStatus(enabled); + } + + @Override + public void setPackageAssertionStatus(String packageName, boolean enabled) { + oldClassLoader.setPackageAssertionStatus(packageName, enabled); + } + + @Override + public void setClassAssertionStatus(String className, boolean enabled) { + oldClassLoader.setClassAssertionStatus(className, enabled); + } + + @Override + public void clearAssertionStatus() { + oldClassLoader.clearAssertionStatus(); + } + } +} diff --git a/app/src/main/java/top/linl/util/reflect/MethodTool.java b/app/src/main/java/top/linl/util/reflect/MethodTool.java index 5a1a1f6604..cb67a8e694 100644 --- a/app/src/main/java/top/linl/util/reflect/MethodTool.java +++ b/app/src/main/java/top/linl/util/reflect/MethodTool.java @@ -123,6 +123,9 @@ public Method get() { Class[] methodParams = method.getParameterTypes(); if (methodParams.length == target.methodParams.length) { for (int i = 0; i < methodParams.length; i++) { + if (target.methodParams[i] == Object.class) { + continue; + } if (!Objects.equals(methodParams[i], target.methodParams[i])) { continue MethodFor; } diff --git a/app/src/main/java/xyz/nextalone/hook/CleanRecentChat.kt b/app/src/main/java/xyz/nextalone/hook/CleanRecentChat.kt index 295d9c1b10..8bf432d128 100644 --- a/app/src/main/java/xyz/nextalone/hook/CleanRecentChat.kt +++ b/app/src/main/java/xyz/nextalone/hook/CleanRecentChat.kt @@ -25,6 +25,7 @@ package xyz.nextalone.hook import android.content.Context import android.widget.ImageView import android.widget.RelativeLayout +import cc.hicore.QApp.QAppUtils import cc.ioctl.util.ui.FaultyDialog import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.checkbox.checkBoxPrompt @@ -32,6 +33,7 @@ import com.afollestad.materialdialogs.list.listItems import com.github.kyuubiran.ezxhelper.utils.isProtected import io.github.qauxv.base.annotation.FunctionHookEntry import io.github.qauxv.base.annotation.UiItemAgentEntry +import io.github.qauxv.config.ConfigManager.getDefaultConfig import io.github.qauxv.dsl.FunctionEntryRouter import io.github.qauxv.hook.CommonSwitchFunctionHook import io.github.qauxv.ui.CommonContextWrapper @@ -40,7 +42,7 @@ import io.github.qauxv.util.dexkit.DexKit import io.github.qauxv.util.dexkit.NConversation_onCreate import io.github.qauxv.util.dexkit.NFriendsStatusUtil_isChatAtTop import me.ketal.util.ignoreResult -import io.github.qauxv.config.ConfigManager.getDefaultConfig +import top.linl.hook.FixCleanRecentChat import xyz.nextalone.util.clazz import xyz.nextalone.util.findHostView import xyz.nextalone.util.get @@ -66,40 +68,48 @@ object CleanRecentChat : CommonSwitchFunctionHook(arrayOf(NFriendsStatusUtil_isC private var includeTopped = getDefaultConfig().getBooleanOrDefault(INCLUDE_TOPPED, false) override fun initOnce(): Boolean = throwOrTrue { - DexKit.requireMethodFromCache(NConversation_onCreate) - .hookAfter(this) { - val recentAdapter = it.thisObject.get(RecentAdapter.clazz) - val app = it.thisObject.get("mqq.app.AppRuntime".clazz) - val relativeLayout = it.thisObject.get(RelativeLayout::class.java) - val plusView = relativeLayout?.findHostView("ba3") - ?: relativeLayout?.parent?.findHostView("ba3") - plusView?.setOnLongClickListener { view -> - val contextWrapper = CommonContextWrapper.createMaterialDesignContext(view.context) - val list = listOf("清理群消息", "清理其他消息", "清理所有消息") - MaterialDialog(contextWrapper).show { - title(text = "消息清理") - checkBoxPrompt(text = "包含置顶消息", isCheckedDefault = includeTopped) { checked -> - includeTopped = checked - putDefault(INCLUDE_TOPPED, includeTopped) - } - listItems(items = list) { dialog, _, text -> - Toasts.showToast(dialog.context, Toasts.TYPE_INFO, text, Toasts.LENGTH_SHORT) - when (text) { - "清理群消息" -> { - handler(recentAdapter, app, all = false, other = false, includeTopped, contextWrapper) - } - "清理其他消息" -> { - handler(recentAdapter, app, all = false, other = true, includeTopped, contextWrapper) - } - "清理所有消息" -> { - handler(recentAdapter, app, all = true, other = true, includeTopped, contextWrapper) - } + if (QAppUtils.isQQnt()) { + val fix = FixCleanRecentChat(this) + fix.loadHook() + } else { + DexKit.requireMethodFromCache(NConversation_onCreate) + .hookAfter(this) { + val recentAdapter = it.thisObject.get(RecentAdapter.clazz) + val app = it.thisObject.get("mqq.app.AppRuntime".clazz) + val relativeLayout = it.thisObject.get(RelativeLayout::class.java) + val plusView = relativeLayout?.findHostView("ba3") + ?: relativeLayout?.parent?.findHostView("ba3") + plusView?.setOnLongClickListener { view -> + val contextWrapper = CommonContextWrapper.createMaterialDesignContext(view.context) + val list = listOf("清理群消息", "清理其他消息", "清理所有消息") + MaterialDialog(contextWrapper).show { + title(text = "消息清理") + checkBoxPrompt(text = "包含置顶消息", isCheckedDefault = includeTopped) { checked -> + includeTopped = checked + putDefault(INCLUDE_TOPPED, includeTopped) } - }.ignoreResult() + listItems(items = list) { dialog, _, text -> + Toasts.showToast(dialog.context, Toasts.TYPE_INFO, text, Toasts.LENGTH_SHORT) + when (text) { + "清理群消息" -> { + handler(recentAdapter, app, all = false, other = false, includeTopped, contextWrapper) + } + + "清理其他消息" -> { + handler(recentAdapter, app, all = false, other = true, includeTopped, contextWrapper) + } + + "清理所有消息" -> { + handler(recentAdapter, app, all = true, other = true, includeTopped, contextWrapper) + } + } + }.ignoreResult() + } + true } - true } - } + } + } private fun ntVersion() {