diff --git a/app/src/main/java/cc/ioctl/fragment/ExfriendListFragment.java b/app/src/main/java/cc/ioctl/fragment/ExfriendListFragment.java index 2f3f9f3fb1..85dda41461 100644 --- a/app/src/main/java/cc/ioctl/fragment/ExfriendListFragment.java +++ b/app/src/main/java/cc/ioctl/fragment/ExfriendListFragment.java @@ -211,7 +211,19 @@ public View getView(int position, View convertView, ViewGroup parent) { stat.setText("已恢复"); } else { stat.setTextColor(Color.argb(255, 220, 50, 50)); - stat.setText("已删除"); + String reasonText = "已删除"; + try { + if (exm.isShowDeleteReason()) { + int reason = ev.getDeleteReason(); + if (reason == EventRecord.DELETE_REASON_ACTIVE) { + reasonText = "主动删除"; + } else if (reason == EventRecord.DELETE_REASON_PASSIVE) { + reasonText = "被删除"; + } + } + } catch (Throwable ignored) { + } + stat.setText(reasonText); } TextView subtitle = convertView.findViewById(R_ID_EXL_SUBTITLE); subtitle.setText(getIntervalDspMs(ev.timeRangeBegin * 1000, ev.timeRangeEnd * 1000)); @@ -376,10 +388,85 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { .setCancelable(true) .show(); return true; + } else if (id == R.id.menu_item_misc_config) { + showMiscConfigDialog(); + return true; } return super.onOptionsItemSelected(item); } + @UiThread + public void showMiscConfigDialog() { + Context ctx = requireContext(); + LinearLayout layout = new LinearLayout(ctx); + layout.setOrientation(LinearLayout.VERTICAL); + layout.setPadding(24, 16, 24, 16); + + androidx.appcompat.widget.SwitchCompat swReason = new androidx.appcompat.widget.SwitchCompat(ctx); + swReason.setText("显示删除原因(主动删除/被删除)"); + swReason.setChecked(exm.isShowDeleteReason()); + layout.addView(swReason); + + androidx.appcompat.widget.SwitchCompat swAutoDetect = new androidx.appcompat.widget.SwitchCompat(ctx); + swAutoDetect.setText("开启自动检测"); + swAutoDetect.setChecked(exm.isAutoDetectEnabled()); + layout.addView(swAutoDetect); + + androidx.appcompat.widget.SwitchCompat swBlock = new androidx.appcompat.widget.SwitchCompat(ctx); + swBlock.setText("检测到被删后直接拉黑"); + swBlock.setChecked(exm.isAutoBlockEnabled()); + layout.addView(swBlock); + + androidx.appcompat.widget.SwitchCompat swBlockActive = new androidx.appcompat.widget.SwitchCompat(ctx); + swBlockActive.setText("主动删除也拉黑对方"); + swBlockActive.setChecked(exm.isAutoBlockActiveEnabled()); + layout.addView(swBlockActive); + + TextView tvInterval = new TextView(ctx); + tvInterval.setText("自动检测间隔(分钟,1-60)"); + tvInterval.setTextSize(14); + tvInterval.setTextColor(ResourcesCompat.getColor(ctx.getResources(), R.color.secondTextColor, ctx.getTheme())); + layout.addView(tvInterval); + EditText etInterval = new EditText(ctx); + etInterval.setInputType(InputType.TYPE_CLASS_NUMBER); + etInterval.setSingleLine(true); + etInterval.setText(String.valueOf(exm.getDetectIntervalMin())); + layout.addView(etInterval); + + AlertDialog.Builder builder = new AlertDialog.Builder(ctx); + builder.setTitle("杂项配置"); + builder.setView(layout); + builder.setPositiveButton("立即检测", (dialog, which) -> { + dialog.dismiss(); + try { + exm.doRequestFlRefresh(); + Toasts.success(ctx, "已触发手动检测"); + reload(); + adapter.notifyDataSetChanged(); + } catch (Throwable e) { + FaultyDialog.show(ctx, e); + } + }); + builder.setNeutralButton("保存", (dialog, which) -> { + dialog.dismiss(); + exm.setShowDeleteReason(swReason.isChecked()); + exm.setAutoDetectEnabled(swAutoDetect.isChecked()); + exm.setAutoBlockEnabled(swBlock.isChecked()); + exm.setAutoBlockActiveEnabled(swBlockActive.isChecked()); + try { + int min = Integer.parseInt(etInterval.getText().toString().trim()); + exm.setDetectIntervalMin(min); + } catch (NumberFormatException ignored) { + } + Toasts.success(ctx, "已保存"); + reload(); + adapter.notifyDataSetChanged(); + }); + builder.setNegativeButton(android.R.string.cancel, null); + builder.setCancelable(true); + builder.show(); + } + @UiThread public void showManualAddRecordDialog() { Context ctx = requireContext(); diff --git a/app/src/main/java/cc/ioctl/hook/DeletionObserver.java b/app/src/main/java/cc/ioctl/hook/DeletionObserver.java index e7f73a4d84..7e203e7743 100644 --- a/app/src/main/java/cc/ioctl/hook/DeletionObserver.java +++ b/app/src/main/java/cc/ioctl/hook/DeletionObserver.java @@ -52,7 +52,12 @@ import io.github.qauxv.util.CliOper; import io.github.qauxv.util.LicenseStatus; import io.github.qauxv.util.Log; +import io.github.qauxv.step.DexDeobfStep; +import io.github.qauxv.step.Step; +import io.github.qauxv.util.dexkit.DexKit; +import io.github.qauxv.util.dexkit.FriendDeleteCallbackClass; import java.lang.ref.WeakReference; +import java.util.ArrayList; import java.util.HashSet; import me.singleneuron.hook.AppCenterHookKt; @@ -110,7 +115,7 @@ protected void beforeHookedMethod(MethodHookParam param) throws Throwable { try { Reflex.invokeStatic(load("com/tencent/widget/CustomWidgetUtil"), "a", redDot, 3, 1, 0, TextView.class, int.class, int.class, int.class, void.class); - } catch (NullPointerException e) { + } catch (NullPointerException | NoSuchMethodError | IllegalArgumentException e) { redDot.setTextColor(Color.RED); } ExfriendManager.get(AppRuntimeHelper.getLongAccountUin()).setRedDot(); @@ -149,63 +154,317 @@ protected void beforeHookedMethod(MethodHookParam param) throws Throwable { @Override protected boolean initOnce() throws Exception { - findAndHookMethod(load("com/tencent/widget/PinnedHeaderExpandableListView"), - "setAdapter", ExpandableListAdapter.class, exfriendEntryHook); + hookDelFriendNt(); + hookGatheredContactsResp(); + hookSplashActivity(); + hookFriendListEntry(); AppCenterHookKt.initAppCenterHook(); - XposedHelpers.findAndHookMethod(load("com/tencent/mobileqq/activity/SplashActivity"), "doOnResume", - new XC_MethodHook(700) { - boolean z = false; + startPeriodicFlRefresh(); + return true; + } + /** + * QQ 9.3.30: 定时拉取好友列表 + */ + private void startPeriodicFlRefresh() { + try { + final android.os.Handler h = new android.os.Handler(android.os.Looper.getMainLooper()); + h.postDelayed(new Runnable() { @Override - protected void afterHookedMethod(MethodHookParam param) throws Throwable { + public void run() { try { - if (AppRuntimeHelper.getLongAccountUin() > 10000) { - ExfriendManager ex = ExfriendManager.getCurrent(); - ex.timeToUpdateFl(); + ExfriendManager ex = ExfriendManager.getCurrent(); + if (ex.isAutoDetectEnabled() && AppRuntimeHelper.getLongAccountUin() > 10000) { + ex.doRequestFlRefresh(); } } catch (Throwable e) { traceError(e); - throw e; } - if (z) { - return; - } - z = true; - CliOper.onLoad(); - } - }); - findAndHookMethod(load("friendlist/GetFriendListResp"), "readFrom", - load("com/qq/taf/jce/JceInputStream"), new XC_MethodHook(200) { - @Override - protected void afterHookedMethod(MethodHookParam param) throws Throwable { + long intervalMs = PERIODIC_FL_REFRESH_DEFAULT_MS; try { - FriendChunk fc = new FriendChunk(param.thisObject); - ExfriendManager.onGetFriendListResp(fc); - } catch (Throwable e) { - traceError(e); - throw e; + intervalMs = ExfriendManager.getCurrent().getDetectIntervalMin() * 60 * 1000L; + } catch (Throwable ignored) { } + h.postDelayed(this, intervalMs); } - }); + }, PERIODIC_FL_REFRESH_DEFAULT_MS); + Log.i("QAuxv-Del: " + "定时拉取已启动,间隔=" + (PERIODIC_FL_REFRESH_DEFAULT_MS / 60000) + "分钟(可配置)"); + } catch (Throwable e) { + traceError(e); + } + } - findAndHookMethod(load("friendlist/DelFriendResp"), "readFrom", - load("com/qq/taf/jce/JceInputStream"), new XC_MethodHook(200) { - @Override - protected void afterHookedMethod(MethodHookParam param) throws Throwable { - try { - long uin = (Long) Reflex.getInstanceObjectOrNull(param.thisObject, "uin"); - long deluin = (Long) Reflex.getInstanceObjectOrNull(param.thisObject, "deluin"); - int result = (Integer) Reflex.getInstanceObjectOrNull(param.thisObject, "result"); - short errorCode = (Short) Reflex.getInstanceObjectOrNull(param.thisObject, "errorCode"); - if (result == 0 && errorCode == 0) { - ExfriendManager.get(uin).markActiveDelete(deluin); + private static final long PERIODIC_FL_REFRESH_DEFAULT_MS = 5 * 60 * 1000L; + + /** + * QQ 9.3.30 NT 主动删除入口:FriendListHandler.delFriend(String source, String uin, byte delType, int notShield) + */ + private void hookDelFriendNt() { + try { + findAndHookMethod(load("com/tencent/mobileqq/app/FriendListHandler"), + "delFriend", String.class, String.class, byte.class, int.class, new XC_MethodHook(200) { + @Override + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { + try { + // uin 在 args[1] + String uinStr = String.valueOf(param.args[1]); + long deluin = 0; + try { + deluin = Long.parseLong(uinStr); + } catch (NumberFormatException e) { + Log.i("QAuxv-Del: " + "delFriend uin 解析失败: " + uinStr); + } + if (deluin > 10000) { + long uin = AppRuntimeHelper.getLongAccountUin(); + Log.i("QAuxv-Del: " + "主动删除请求(handler): deluin=" + deluin); + ExfriendManager.get(uin).markActiveDelete(deluin); + } + } catch (Throwable e) { + Log.i("QAuxv-Del: " + "hookDelFriendHandler 异常: " + e); + traceError(e); } - } catch (Throwable e) { - traceError(e); - throw e; } + }); + Log.i("QAuxv-Del: " + "hookDelFriendHandler(4参) 注册成功"); + } catch (Throwable e) { + // 兼容性降级(目标方法签名变化等)不记为功能错误 + Log.i("QAuxv-Del: " + "hookDelFriendHandler(4参) 注册跳过: " + e); + } + // 新路径(9.3.60+ NT):DelFriendServiceImpl.delFriend(String source, String uin, byte, int) + try { + findAndHookMethod(load("com.tencent.mobileqq.addfriend.api.impl.DelFriendServiceImpl"), + "delFriend", String.class, String.class, byte.class, int.class, new XC_MethodHook(200) { + @Override + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { + try { + String uinStr = String.valueOf(param.args[1]); + long deluin = 0; + try { + deluin = Long.parseLong(uinStr); + } catch (NumberFormatException e) { + Log.i("QAuxv-Del: " + "DelFriendServiceImpl uin 解析失败: " + uinStr); + } + if (deluin > 10000) { + long uin = AppRuntimeHelper.getLongAccountUin(); + Log.i("QAuxv-Del: " + "主动删除请求(delservice): deluin=" + deluin); + ExfriendManager.get(uin).markActiveDelete(deluin); + } + } catch (Throwable e) { + Log.i("QAuxv-Del: " + "hookDelFriendServiceImpl 异常: " + e); + } + } + }); + Log.i("QAuxv-Del: " + "hookDelFriendServiceImpl 注册成功"); + } catch (Throwable e) { + // 兼容性降级不记为功能错误 + Log.i("QAuxv-Del: " + "hookDelFriendServiceImpl 注册跳过: " + e); + } + // 删除回调类名随版本变化,优先用 DexKit 特征定位,未命中时按已知类名探测 + Class cbCls = null; + String source = "DexKit"; + try { + cbCls = DexKit.loadClassFromCache(FriendDeleteCallbackClass.INSTANCE); + if (cbCls != null && !hasDelFriendCallback(cbCls)) { + Log.i("QAuxv-Del: DexKit 缓存类无 onUpdateDelFriend,改用探测"); + cbCls = null; + } + } catch (Throwable e) { + Log.i("QAuxv-Del: DexKit 缓存读取失败: " + e); + } + if (cbCls == null) { + cbCls = findDelFriendCallbackClass(); + source = "探测"; + } + if (cbCls == null) { + Log.i("QAuxv-Del: 未找到 onUpdateDelFriend 删除回调,被动回调跳过"); + return; + } + Log.i("QAuxv-Del: 删除回调类(" + source + ")=" + cbCls.getName()); + hookOnUpdateDelFriend(cbCls); + } + + private void hookOnUpdateDelFriend(Class cbCls) { + try { + findAndHookMethod(cbCls, + "onUpdateDelFriend", boolean.class, Object.class, new XC_MethodHook(200) { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + try { + boolean success = (Boolean) param.args[0]; + Object data = param.args[1]; + if (success && data != null) { + long deluin = extractDelUin(data); + if (deluin > 10000) { + // 观察者回调同一删除会触发多次,5 秒内同一 uin 只处理一次 + long now = System.currentTimeMillis(); + if (deluin == sLastDelUin && now - sLastDelTime < 5000) { + return; + } + sLastDelUin = deluin; + sLastDelTime = now; + long uin = AppRuntimeHelper.getLongAccountUin(); + Log.i("QAuxv-Del: " + "删除响应确认(被动): deluin=" + deluin); + ExfriendManager.get(uin).markPassiveDelete(deluin); + } + } + } catch (Throwable e) { + traceError(e); + } + } + }); + Log.i("QAuxv-Del: " + "hookDelFriendNt(回调) 注册成功"); + } catch (Throwable e) { + // 兼容性降级(目标方法缺失等)不记为功能错误 + Log.i("QAuxv-Del: " + "hookDelFriendNt(回调) 注册跳过: " + e); + } + } + + /** + * DexKit 查找需在 deobfuscation section 内完成,查找成功后返回 false 才会正常初始化 + */ + @Override + public boolean isPreparationRequired() { + try { + return DexKit.isRunDexDeobfuscationRequired(FriendDeleteCallbackClass.INSTANCE); + } catch (Throwable e) { + return false; + } + } + + @Override + public Step[] makePreparationSteps() { + return new Step[]{new DexDeobfStep(FriendDeleteCallbackClass.INSTANCE)}; + } + + private boolean hasDelFriendCallback(Class cls) { + for (java.lang.reflect.Method mm : cls.getDeclaredMethods()) { + if ("onUpdateDelFriend".equals(mm.getName()) && mm.getParameterCount() == 2) { + return true; + } + } + return false; + } + + private Class findDelFriendCallbackClass() { + for (String clsName : new String[]{ + "com.tencent.mobileqq.app.bb", + "com.tencent.mobileqq.app.bc", + "com.tencent.mobileqq.friend.observer.a"}) { + Class c = load(clsName); + if (c != null && hasDelFriendCallback(c)) { + return c; + } + } + return null; + } + + private static long sLastDelUin = 0; + private static long sLastDelTime = 0; + + /** + * 从删除响应数据中提取被删好友 uin + */ + private long extractDelUin(Object data) { + try { + if (data instanceof Long) { + return (Long) data; + } + if (data instanceof String) { + return Long.parseLong((String) data); + } + for (String f : new String[]{"deluin", "uin"}) { + try { + java.lang.reflect.Field fd = data.getClass().getField(f); + fd.setAccessible(true); + Object v = fd.get(data); + if (v instanceof Number) { + return ((Number) v).longValue(); + } + if (v instanceof String) { + return Long.parseLong((String) v); + } + } catch (Throwable ignored) { } - }); - return true; + } + } catch (Throwable ignored) { + } + return 0; + } + + /** + * QQ 9.3.30: OidbSvc.0x7c4 响应处理 + */ + private void hookGatheredContactsResp() { + try { + findAndHookMethod(load("com/tencent/mobileqq/app/FriendListHandler"), + "handleGetGatheredContactsList", + load("com/tencent/qphone/base/remote/ToServiceMsg"), + load("com/tencent/qphone/base/remote/FromServiceMsg"), + Object.class, + new XC_MethodHook(200) { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + try { + if (LicenseStatus.sDisableCommonHooks) { + return; + } + if (param.args.length < 3 || !(param.args[2] instanceof byte[])) { + return; + } + byte[] oidb = (byte[]) param.args[2]; + ExfriendManager.onGatheredContactsResp(oidb); + } catch (Throwable e) { + traceError(e); + throw e; + } + } + }); + Log.i("QAuxv-Del: " + "hookGatheredContactsResp 注册成功"); + } catch (Throwable e) { + // 兼容性降级(目标方法缺失等)不记为功能错误 + Log.i("QAuxv-Del: " + "hookGatheredContactsResp 注册跳过: " + e); + } + } + + private void hookSplashActivity() { + try { + XposedHelpers.findAndHookMethod(load("com/tencent/mobileqq/activity/SplashActivity"), "doOnResume", + new XC_MethodHook(700) { + boolean z = false; + + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + try { + ExfriendManager ex = ExfriendManager.getCurrent(); + // 延迟拉取,等待登录态就绪;未开启自动检测时跳过 + if (ex.isAutoDetectEnabled() && AppRuntimeHelper.getLongAccountUin() > 10000) { + android.os.Handler h = new android.os.Handler(android.os.Looper.getMainLooper()); + h.postDelayed(() -> ex.doRequestFlRefresh(), 8000); + } + } catch (Throwable e) { + traceError(e); + throw e; + } + if (z) { + return; + } + z = true; + CliOper.onLoad(); + } + }); + } catch (Throwable e) { + // 兼容性降级不记为功能错误 + Log.i("QAuxv-Del: " + "hookSplashActivity 注册跳过: " + e); + } + } + + private void hookFriendListEntry() { + try { + findAndHookMethod(load("com/tencent/widget/PinnedHeaderExpandableListView"), + "setAdapter", ExpandableListAdapter.class, exfriendEntryHook); + } catch (Throwable e) { + // 兼容性降级不记为功能错误 + Log.i("QAuxv-Del: " + "hookFriendListEntry 注册跳过: " + e); + } } } diff --git a/app/src/main/java/cc/ioctl/util/ExfriendManager.java b/app/src/main/java/cc/ioctl/util/ExfriendManager.java index dde3fa1fef..720bfbafbe 100644 --- a/app/src/main/java/cc/ioctl/util/ExfriendManager.java +++ b/app/src/main/java/cc/ioctl/util/ExfriendManager.java @@ -61,7 +61,10 @@ import java.lang.reflect.Field; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; @@ -91,6 +94,8 @@ public class ExfriendManager { private ConcurrentHashMap mStdRemarks; private ArrayList cachedFriendChunks; private boolean dirtySerializedFlag = true; + /** 0x7c4 分页会话缓存(uin -> 备注) */ + private final LinkedHashMap gatheredSession = new LinkedHashMap<>(); private static final long[] ROBOT_ENTERPRISE_UIN_ARRAY = new long[]{ // 查询ROBOT信息 https://qun.qq.com/qunpro/robot/qunshare?robot_uin=66600000 @@ -178,7 +183,10 @@ public static ExfriendManager getOrNull(long uin) { public static Object getFriendsManager() throws Exception { Object qqAppInterface = AppRuntimeHelper.getAppRuntime(); - return Reflex.invokeVirtual(qqAppInterface, "getManager", 50, int.class); + // QQ 9.3.30: manager 索引由 QQManagerFactory 运行时分配(FRIENDS_MANAGER 已不是 50),反射读取避免写死 + int friendsManagerId = Initiator.load("com.tencent.mobileqq.app.QQManagerFactory") + .getField("FRIENDS_MANAGER").getInt(null); + return Reflex.invokeVirtual(qqAppInterface, "getManager", friendsManagerId, int.class); } public static ConcurrentHashMap getFriendsConcurrentHashMap(Object friendsManager) @@ -188,8 +196,10 @@ public static ConcurrentHashMap getFriendsConcurrentHashMap(Object friendsManage field.setAccessible(true); ConcurrentHashMap concurrentHashMap = (ConcurrentHashMap) field.get(friendsManager); if (concurrentHashMap != null && concurrentHashMap.size() > 0) { - if (concurrentHashMap.get(concurrentHashMap.keySet().toArray()[0]).getClass() - == Initiator.load("com.tencent.mobileqq.data.Friends")) { + // QQ 9.3.65 起 data.Friends 已移除,改用类名特征匹配(Friends / friendsinfo) + String valueCls = concurrentHashMap.get(concurrentHashMap.keySet().toArray()[0]) + .getClass().getName(); + if (valueCls.contains("Friends") || valueCls.contains("friendsinfo")) { return concurrentHashMap; } } @@ -202,6 +212,175 @@ public static void onGetFriendListResp(FriendChunk fc) { get(fc.uin).recordFriendChunk(fc); } + private static boolean gatheredProtocolChecked = false; + private static boolean gatheredProtocolAvailable = true; + + /** + * QQ 9.3.30: OidbSvc.0x7c4 响应入口 + */ + public static void onGatheredContactsResp(byte[] oidbBytes) { + try { + // 部分 QQ 版本(如 9.1.67)无 cmd0x7c4 协议类,只检查一次避免每次响应刷堆栈 + if (!gatheredProtocolChecked) { + gatheredProtocolChecked = true; + try { + Initiator.load("tencent/im/oidb/cmd0x7c4/cmd0x7c4$RspBody"); + } catch (Throwable e) { + gatheredProtocolAvailable = false; + Log.i("QAuxv-Del: 当前 QQ 版本无 cmd0x7c4 协议类,0x7c4 响应解析停用"); + } + } + if (!gatheredProtocolAvailable) { + return; + } + long uin = AppRuntimeHelper.getLongAccountUin(); + if (uin < 10000) { + return; + } + get(uin).parseGatheredContactsResp(oidbBytes); + } catch (Throwable e) { + Log.e(e); + } + } + + /** + * QQ 9.3.30: 解析 0x7c4 响应 + */ + private void parseGatheredContactsResp(byte[] oidbBytes) { + try { + Class clzPkg = Initiator.load("tencent/im/oidb/oidb_sso$OIDBSSOPkg"); + Object pkg = clzPkg.getDeclaredConstructor().newInstance(); + clzPkg.getMethod("mergeFrom", byte[].class).invoke(pkg, (Object) oidbBytes); + com.tencent.mobileqq.pb.PBUInt32Field resultField = + (com.tencent.mobileqq.pb.PBUInt32Field) clzPkg.getField("uint32_result").get(pkg); + if (resultField.has() && resultField.get() != 0) { + Log.i("QAuxv-Del: 0x7c4 OIDB result=" + resultField.get() + ",响应 hex=" + toHex(oidbBytes)); + scheduleRetryFlRefresh(15000); + return; + } + resetGatheredRetry(); + com.tencent.mobileqq.pb.PBBytesField bodyField = + (com.tencent.mobileqq.pb.PBBytesField) clzPkg.getField("bytes_bodybuffer").get(pkg); + if (!bodyField.has()) { + return; + } + byte[] body = bodyField.get().toByteArray(); + Class clzRsp = Initiator.load("tencent/im/oidb/cmd0x7c4/cmd0x7c4$RspBody"); + Object rspBody = clzRsp.getDeclaredConstructor().newInstance(); + clzRsp.getMethod("mergeFrom", byte[].class).invoke(rspBody, (Object) body); + Object snRsp = clzRsp.getField("msg_get_sn_frd_list_rsp").get(rspBody); + if (snRsp == null) { + return; + } + Class clzSn = snRsp.getClass(); + long seq = ((com.tencent.mobileqq.pb.PBUInt32Field) clzSn.getField("uint32_sequence").get(snRsp)).get(); + boolean over = ((com.tencent.mobileqq.pb.PBUInt32Field) clzSn.getField("uint32_over").get(snRsp)).get() != 0; + java.util.List frds = (java.util.List) ((com.tencent.mobileqq.pb.PBRepeatMessageField) clzSn + .getField("rpt_msg_one_frd_data").get(snRsp)).get(); + LinkedHashMap uins = new LinkedHashMap<>(frds.size()); + for (Object o : frds) { + long id = ((com.tencent.mobileqq.pb.PBUInt64Field) o.getClass().getField("uint64_frd_id").get(o)).get(); + String smartRemark = null; + try { + com.tencent.mobileqq.pb.PBBytesField remarkField = (com.tencent.mobileqq.pb.PBBytesField) o + .getClass().getField("bytes_smart_remark").get(o); + if (remarkField.has()) { + smartRemark = new String(remarkField.get().toByteArray(), + java.nio.charset.StandardCharsets.UTF_8); + } + } catch (Throwable ignored) { + } + uins.put(id, smartRemark); + } + onGatheredContactsChunk(uins, seq, over); + } catch (Throwable e) { + Log.e(e); + } + } + + /** + * 0x7c4 分片收集,finished 时执行对比 + */ + public synchronized void onGatheredContactsChunk(Map uinsWithRemark, long seq, boolean finished) { + if (seq == 0) { + gatheredSession.clear(); + } + gatheredSession.putAll(uinsWithRemark); + if (finished && !gatheredSession.isEmpty()) { + LinkedHashMap snapshot = new LinkedHashMap<>(gatheredSession); + gatheredSession.clear(); + if (tp != null) { + tp.execute(() -> asyncCompareGatheredContacts(snapshot)); + } else { + asyncCompareGatheredContacts(snapshot); + } + } + } + + /** + * 对比好友列表与历史记录,检测被删好友 + */ + private void asyncCompareGatheredContacts(Map uinsWithRemark) { + Object[] ptr = new Object[4]; + synchronized (this) { + HashSet present = new HashSet<>(uinsWithRemark.keySet()); + long now = System.currentTimeMillis() / 1000L; + // 先更新/新增本次列表中的好友,再检测消失的好友 + for (Map.Entry ent : uinsWithRemark.entrySet()) { + long u = ent.getKey(); + FriendRecord fr = persons.get(u); + if (fr == null) { + fr = new FriendRecord(); + fr.uin = u; + fr.friendStatus = FriendRecord.STATUS_FRIEND_MUTUAL; + fr.serverTime = now; + persons.put(u, fr); + dirtySerializedFlag = true; + } else { + fr.friendStatus = FriendRecord.STATUS_FRIEND_MUTUAL; + } + String smartRemark = ent.getValue(); + if (smartRemark != null && !smartRemark.isEmpty()) { + fr.remark = smartRemark; + dirtySerializedFlag = true; + } + } + Iterator> it = persons.entrySet().iterator(); + ptr[0] = 0; + int deletedCount = 0; + while (it.hasNext()) { + Map.Entry ent = it.next(); + FriendRecord fr = ent.getValue(); + if (!present.contains(ent.getKey()) && !hasDeleteRecord(fr.uin)) { + if (shouldIgnoreDeletionEvent(fr.uin)) { + fr.friendStatus = FriendRecord.STATUS_EXFRIEND; + continue; + } + EventRecord ev = new EventRecord(); + ev._friendStatus = fr.friendStatus; + ev._nick = fr.nick; + ev._remark = fr.remark; + ev.event = EventRecord.EVENT_FRIEND_DELETE; + ev.operand = fr.uin; + ev.executor = -1; + ev.timeRangeBegin = fr.serverTime; + ev.timeRangeEnd = now; + ev.setDeleteReason(EventRecord.DELETE_REASON_PASSIVE); + reportEventWithoutSave(ev, ptr); + fr.friendStatus = FriendRecord.STATUS_EXFRIEND; + deletedCount++; + Log.i("QAuxv-Del: " + "检测到被删好友: uin=" + fr.uin + " remark=" + fr.remark + " nick=" + fr.nick); + if (isAutoBlockEnabled()) { + blockFriend(fr.uin); + } + } + } + Log.i("QAuxv-Del: " + "0x7c4 对比结束: 历史=" + persons.size() + " 检测到删除=" + deletedCount); + } + lastUpdateTimeSec = System.currentTimeMillis() / 1000L; + doNotifyDelFlAndSave(ptr); + } + public long getUin() { return mUin; } @@ -244,6 +423,10 @@ private void initForUin(long uin) { Object fr; Field fuin, fremark, fnick; Class clz_fr = Initiator.load("com/tencent/mobileqq/data/Friends"); + if (clz_fr == null) { + // QQ 9.3.65 起 data.Friends 已移除,跳过内部复制(好友列表由 0x7c4 拉取填充) + throw new ClassNotFoundException("data.Friends not found"); + } fuin = clz_fr.getField("uin");//long!!! fuin.setAccessible(true); fremark = clz_fr.getField("remark"); @@ -486,6 +669,22 @@ private void loadEventsData() { Log.e(e); } } + // 清理重复记录:同一好友同一事件的重复记录只保留最早一条 + try { + java.util.HashSet seenDelete = new java.util.HashSet<>(); + java.util.Iterator> it2 = events.entrySet().iterator(); + while (it2.hasNext()) { + EventRecord ev2 = it2.next().getValue(); + if (ev2.event == EventRecord.EVENT_FRIEND_DELETE) { + if (!seenDelete.add(ev2.operand)) { + it2.remove(); + dirtySerializedFlag = true; + } + } + } + } catch (Throwable e) { + Log.e(e); + } } private void saveEventsData() { @@ -667,13 +866,24 @@ public void reportEventWithoutSave(EventRecord ev, Object[] out) { tag = "" + ev.operand; } out[0] = unread; - ticker = "检测到" + unread + "位好友删除了你"; - if (unread > 1) { - title = "你被" + unread + "位好友删除"; - c = tag + "等" + unread + "位好友"; + if (ev.getDeleteReason() == EventRecord.DELETE_REASON_ACTIVE) { + ticker = "已删除 " + unread + " 位好友"; + if (unread > 1) { + title = "你删除了" + unread + "位好友"; + c = tag + "等" + unread + "位好友"; + } else { + title = tag; + c = "在约 " + getRelTimeStrSec(ev.timeRangeBegin) + " 被你删除"; + } } else { - title = tag; - c = "在约 " + getRelTimeStrSec(ev.timeRangeBegin) + " 删除了你"; + ticker = "检测到" + unread + "位好友删除了你"; + if (unread > 1) { + title = "你被" + unread + "位好友删除"; + c = tag + "等" + unread + "位好友"; + } else { + title = tag; + c = "在约 " + getRelTimeStrSec(ev.timeRangeBegin) + " 删除了你"; + } } out[1] = ticker; out[2] = title; @@ -766,8 +976,53 @@ private void asyncUpdateFriendListTask(FriendChunk[] fcs) { doNotifyDelFlAndSave(ptr); } + /** + * 被动删除(对方删除自己),记录 PASSIVE 原因,按开关自动拉黑 + */ + public void markPassiveDelete(long uin) { + synchronized (this) { + if (hasDeleteRecord(uin)) { + return; + } + FriendRecord fr = persons.get(uin); + if (fr == null) { + Log.i("QAuxv-Del: " + "被动删除但好友不在记录中: uin=" + uin); + return; + } + EventRecord ev = new EventRecord(); + ev._friendStatus = fr.friendStatus; + ev._nick = fr.nick; + ev._remark = fr.remark; + ev.timeRangeBegin = fr.serverTime; + ev.timeRangeEnd = System.currentTimeMillis() / 1000; + fr.friendStatus = FriendRecord.STATUS_EXFRIEND; + ev.executor = -1; + ev.operand = uin; + ev.event = EventRecord.EVENT_FRIEND_DELETE; + ev.setDeleteReason(EventRecord.DELETE_REASON_PASSIVE); + Object[] out = new Object[4]; + reportEventWithoutSave(ev, out); + saveConfigure(); + // 被动删除发通知(开关控制) + try { + if (isNotifyWhenDeleted() && ((Number) out[0]).intValue() > 0) { + doNotifyDelFlAndSave(out); + } + } catch (Throwable e) { + Log.e(e); + } + if (isAutoBlockEnabled()) { + Log.i("QAuxv-Del: " + "被动删除触发拉黑: uin=" + uin); + blockFriend(uin); + } + } + } + public void markActiveDelete(long uin) { synchronized (this) { + if (hasDeleteRecord(uin)) { + return; + } FriendRecord fr = persons.get(uin); if (fr == null) { Toasts.error(null, "onActDelResp: get(" + uin + ")==null"); @@ -783,8 +1038,13 @@ public void markActiveDelete(long uin) { ev.executor = this.getUin(); ev.operand = uin; ev.event = EventRecord.EVENT_FRIEND_DELETE; + ev.setDeleteReason(EventRecord.DELETE_REASON_ACTIVE); reportEventWithoutSave(ev, null); saveConfigure(); + if (isAutoBlockActiveEnabled()) { + Log.i("QAuxv-Del: " + "主动删除触发拉黑: uin=" + uin); + blockFriend(uin); + } } } @@ -853,17 +1113,249 @@ public void doRequestFlRefresh() { boolean inLogin; inLogin = (AppRuntimeHelper.getLongAccountUin() == mUin); if (!inLogin) { - Log.i("doRequestFlRefresh but uin(" + mUin + ") isn't logged in."); + Log.i("QAuxv-Del: 启动拉取被跳过: uin(" + mUin + ") 未登录, 5 秒后重试"); + scheduleRetryFlRefresh(5000); return; } try { - Reflex.invokeVirtualAny(ManagerHelper.getFriendListHandler(), true, true, boolean.class, - boolean.class, void.class); - } catch (Exception e) { + // 直接读 FriendsManager 的好友列表(按返回类型匹配,不依赖方法名) + Object fm = ManagerHelper.getFriendsManager(); + java.util.List list = getFriendsList(fm); + if (list == null) { + Log.i("QAuxv-Del: FriendsManager 好友列表方法均不可用"); + return; + } + LinkedHashMap uins = parseFriendList(list); + Log.i("QAuxv-Del: FriendsManager 好友列表大小=" + uins.size()); + // 直接以全量快照对比历史,检测被删好友 + onGatheredContactsChunk(uins, 0, true); + } catch (Throwable e) { + Log.i("QAuxv-Del: 拉取调用异常: " + e); + Log.e(e); + } + } + + /** + * 解析好友列表(uin → 备注/昵称),供实时检测与定时拉取复用。 + */ + public static LinkedHashMap parseFriendList(java.util.List list) { + LinkedHashMap uins = new LinkedHashMap<>(); + for (Object o : list) { + if (o == null) { + continue; + } + String uinStr = extractString(o, new String[]{"getUin"}, new String[]{"uin"}); + if (uinStr == null || uinStr.isEmpty()) { + continue; + } + String remark = extractString(o, new String[]{"getRemark"}, new String[]{"remark"}); + if (remark == null || remark.isEmpty()) { + remark = extractString(o, new String[]{"getAlias"}, new String[]{"alias"}); + } + if (remark == null || remark.isEmpty()) { + remark = extractString(o, new String[]{"getNick", "getFriendNick"}, new String[]{"name", "nick"}); + } + try { + uins.put(Long.parseLong(uinStr), remark); + } catch (NumberFormatException ignored) { + } + } + return uins; + } + + /** + * 获取好友列表(不依赖方法名):扫描 FriendsManager 中无参且返回 List 的方法, + * 泛型元素为 QQ 好友模型(data.Friends 或 NT friendsinfo bean)即命中; + * 老版本存在双方法(List + ArrayList),取运行时元素验证通过且数量最大的。 + */ + private static java.util.List getFriendsList(Object fm) { + java.util.List best = null; + for (java.lang.reflect.Method mm : fm.getClass().getDeclaredMethods()) { + try { + if (mm.getParameterCount() != 0 || !java.util.List.class.isAssignableFrom(mm.getReturnType())) { + continue; + } + String elemName = null; + java.lang.reflect.Type gt = mm.getGenericReturnType(); + if (gt instanceof java.lang.reflect.ParameterizedType) { + java.lang.reflect.Type[] args = ((java.lang.reflect.ParameterizedType) gt).getActualTypeArguments(); + if (args.length == 1) { + elemName = args[0].getTypeName(); + } + } + if (elemName == null || (!elemName.contains("Friends") && !elemName.contains("friendsinfo"))) { + continue; + } + if (!mm.isAccessible()) { + mm.setAccessible(true); + } + Object r = mm.invoke(fm); + if (!(r instanceof java.util.List)) { + continue; + } + java.util.List list = (java.util.List) r; + if (!list.isEmpty()) { + // 运行时元素验证:防止泛型签名被混淆工具擦除 + String cn = list.get(0).getClass().getName(); + if (!cn.contains("Friends") && !cn.contains("friendsinfo")) { + continue; + } + } + if (best == null || list.size() > best.size()) { + best = list; + } + } catch (Throwable ignored) { + } + } + return best; + } + + /** + * 提取字符串成员:先 getter 后 public 字段(兼容 data.Friends 等仅有字段的老模型) + */ + private static String extractString(Object o, String[] getters, String[] fields) { + for (String g : getters) { + try { + Object r = Reflex.invokeVirtual(o, g, String.class); + if (r instanceof String) { + return (String) r; + } + } catch (Throwable ignored) { + } + } + for (String f : fields) { + try { + java.lang.reflect.Field fd = o.getClass().getField(f); + Object r = fd.get(o); + if (r instanceof String) { + return (String) r; + } + } catch (Throwable ignored) { + } + } + return null; + } + + private static final int MAX_GATHERED_RETRY = 3; + private static int gatheredRetryCount = 0; + + private void scheduleRetryFlRefresh(long delayMs) { + if (gatheredRetryCount >= MAX_GATHERED_RETRY) { + Log.i("QAuxv-Del: 重试次数已达上限(" + MAX_GATHERED_RETRY + "),放弃"); + return; + } + gatheredRetryCount++; + try { + android.os.Handler h = new android.os.Handler(android.os.Looper.getMainLooper()); + h.postDelayed(() -> doRequestFlRefresh(), delayMs); + } catch (Throwable e) { Log.e(e); } } + private static String toHex(byte[] data) { + return toHex(data, data.length); + } + + private static String toHex(byte[] data, int maxLen) { + int n = Math.min(data.length, maxLen); + StringBuilder sb = new StringBuilder(n * 2); + for (int i = 0; i < n; i++) { + byte b = data[i]; + sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } + + public void resetGatheredRetry() { + gatheredRetryCount = 0; + } + + // 好友删除检测配置 + + public boolean isShowDeleteReason() { + return mConfig.getBoolean("del_show_reason", true); + } + + public void setShowDeleteReason(boolean z) { + mConfig.putBoolean("del_show_reason", z); + saveConfigure(); + } + + public boolean isAutoBlockEnabled() { + return mConfig.getBoolean("del_auto_block", false); + } + + public void setAutoBlockEnabled(boolean z) { + mConfig.putBoolean("del_auto_block", z); + saveConfigure(); + } + + public boolean isAutoBlockActiveEnabled() { + return mConfig.getBoolean("del_auto_block_active", false); + } + + public void setAutoBlockActiveEnabled(boolean z) { + mConfig.putBoolean("del_auto_block_active", z); + saveConfigure(); + } + + public boolean isAutoDetectEnabled() { + return mConfig.getBoolean("del_auto_detect", true); + } + + public void setAutoDetectEnabled(boolean z) { + mConfig.putBoolean("del_auto_detect", z); + saveConfigure(); + } + + public int getDetectIntervalMin() { + int v = mConfig.getInt("del_detect_interval_min", 5); + return Math.max(1, Math.min(v, 60)); + } + + public void setDetectIntervalMin(int min) { + mConfig.putInt("del_detect_interval_min", Math.max(1, Math.min(min, 60))); + saveConfigure(); + } + + /** + * 拉黑好友:优先 IRelationBlacklistApi.sendAddBlacklistRequest,失败时回退 changeFriendShieldFlag + */ + public void blockFriend(long uin) { + boolean done = false; + try { + Class clzApi = io.github.qauxv.util.Initiator + .load("com/tencent/mobileqq/profilecard/api/IRelationBlacklistApi"); + Class clzQRoute = io.github.qauxv.util.Initiator.load("com/tencent/mobileqq/qroute/QRoute"); + Object api = clzQRoute.getMethod("api", Class.class).invoke(null, clzApi); + Class clzListener = io.github.qauxv.util.Initiator + .load("com/tencent/mobileqq/profilecard/listener/RelationBlacklistListener"); + // sendAddBlacklistRequest(String uin, RelationBlacklistListener) + Reflex.invokeVirtual(api, "sendAddBlacklistRequest", String.valueOf(uin), null, + String.class, clzListener, void.class); + Log.i("QAuxv-Del: " + "拉黑请求已发送(sendAddBlacklistRequest): uin=" + uin); + done = true; + } catch (Throwable e) { + Log.i("QAuxv-Del: " + "拉黑(sendAddBlacklistRequest)失败: " + e); + } + if (!done) { + // 保底:屏蔽好友(拦截消息) + try { + Object handler = ManagerHelper.getFriendListHandler(); + Reflex.invokeVirtual(handler, "changeFriendShieldFlag", uin, Boolean.TRUE, + long.class, boolean.class, void.class); + Log.i("QAuxv-Del: " + "保底屏蔽好友成功: uin=" + uin); + done = true; + } catch (Throwable e) { + Log.i("QAuxv-Del: " + "保底屏蔽失败: " + e); + } + } + if (!done) { + Log.i("QAuxv-Del: " + "拉黑失败(所有接口不可用): uin=" + uin); + } + } + public long getLastUpdateTimeSec() { return lastUpdateTimeSec; } @@ -894,6 +1386,18 @@ public void setDeletionDetectionExclusionList(String[] uinList) { // no need to call saveConfigure() because it's MMKV } + private boolean hasDeleteRecord(long uin) { + if (events == null) { + return false; + } + for (EventRecord ev : events.values()) { + if (ev.event == EventRecord.EVENT_FRIEND_DELETE && ev.operand == uin) { + return true; + } + } + return false; + } + private boolean shouldIgnoreDeletionEvent(long uin) { if (ArraysKt.contains(ROBOT_ENTERPRISE_UIN_ARRAY, uin)) { return true; diff --git a/app/src/main/java/cc/ioctl/util/data/EventRecord.java b/app/src/main/java/cc/ioctl/util/data/EventRecord.java index 0f1a8f6acd..ae1fa3ac38 100644 --- a/app/src/main/java/cc/ioctl/util/data/EventRecord.java +++ b/app/src/main/java/cc/ioctl/util/data/EventRecord.java @@ -35,6 +35,10 @@ public class EventRecord implements Serializable, Comparable { public static final int EVENT_REMARK_CHANGE = 6; public static final int EVENT_LOCAL_REMARK_CHANGE = 7; public static final int EVENT_ACCOUNT_DESTRUCTION = 8; + // 删除原因:1=主动删除 2=被动删除 + public static final int DELETE_REASON_UNKNOWN = 0; + public static final int DELETE_REASON_ACTIVE = 1; + public static final int DELETE_REASON_PASSIVE = 2; private static final long serialVersionUID = 1L; public long timeRangeBegin; public long timeRangeEnd; @@ -69,6 +73,20 @@ public String getShowStrWithUin() { } } + public int getDeleteReason() { + if (extra != null && extra.startsWith("dr:")) { + try { + return Integer.parseInt(extra.substring(3)); + } catch (NumberFormatException ignored) { + } + } + return DELETE_REASON_UNKNOWN; + } + + public void setDeleteReason(int reason) { + extra = "dr:" + reason; + } + @Override public int compareTo(Object obj) { EventRecord ev = (EventRecord) obj; diff --git a/app/src/main/java/io/github/qauxv/bridge/ManagerHelper.java b/app/src/main/java/io/github/qauxv/bridge/ManagerHelper.java index 5afacd0260..aacad91ad9 100644 --- a/app/src/main/java/io/github/qauxv/bridge/ManagerHelper.java +++ b/app/src/main/java/io/github/qauxv/bridge/ManagerHelper.java @@ -113,6 +113,21 @@ public static Object getFriendListHandler() { } } + public static Object getFriendsManager() { + try { + Class cl = load("com.tencent.mobileqq.app.QQManagerFactory"); + int id = cl.getField("FRIENDS_MANAGER").getInt(null); + return getManager(id); + } catch (Throwable e) { + // 运行时索引 fallback(9.3.30 实测为 28) + try { + return getManager(28); + } catch (Throwable e2) { + return null; + } + } + } + @Deprecated public static Object getBusinessHandler(int type) { try { diff --git a/app/src/main/java/io/github/qauxv/util/dexkit/DexKitTarget.kt b/app/src/main/java/io/github/qauxv/util/dexkit/DexKitTarget.kt index 3e94cbe26a..4f39f6ebf2 100644 --- a/app/src/main/java/io/github/qauxv/util/dexkit/DexKitTarget.kt +++ b/app/src/main/java/io/github/qauxv/util/dexkit/DexKitTarget.kt @@ -1336,3 +1336,19 @@ data object BlockPicByMd5_EmotionPreviewDataV2 : DexKitTarget.UsingStr() { } } } + +data object FriendDeleteCallbackClass : DexKitTarget.UsingDexKitBridge() { + // 被动删除回调类,混淆名随版本变化;onUpdateBatch8 唯一命中该类 + override val declaringClass = "" + override val finder: DexKitBridgeFinder = { bridge -> + bridge.findClass { + searchPackages("com.tencent.mobileqq.app") + matcher { + methods { + add { name = "onUpdateBatch8" } + add { name = "onUpdateDelFriend" } + } + } + }.single().methods.first { it.name == "onUpdateBatch8" } + } +} diff --git a/app/src/main/res/menu/exfriend_list_fragment_options.xml b/app/src/main/res/menu/exfriend_list_fragment_options.xml index d75ed849f3..6d1215df70 100644 --- a/app/src/main/res/menu/exfriend_list_fragment_options.xml +++ b/app/src/main/res/menu/exfriend_list_fragment_options.xml @@ -13,4 +13,8 @@ android:id="@+id/menu_item_edit_exclusion_list" android:title="配置排除列表" app:showAsAction="never" /> +