给微信聊天记录添加截图功能

8,986 阅读8分钟

有时候,知识小集群里讨论的技术问题,比较有价值,我们会把有价值的内容整理出来供大家查阅。但为了保护群友隐私,需要把昵称和头像都打码,如果碰到几百条聊天记录,这样做简直要吐血。而且也不能截一张长图,只能一张一张截取,然后拼接起来。群聊记录只能在微信内分享,这也限制了传播的渠道。为了提高小集成员工作效率,想着能不能给微信做个插件,解决这些问题。我们一直在追求如何更有效率开展我们的工作,比如使用脚本自动整理每周小集内容,使用微信小程序给读者更好阅读体验。(呀,还有脚本,如果你还不知道,那肯定没有点 star 吧,传送门

提出问题

通过以上痛点,可以确定我们要解决的问题主要有:

  • 截图:把所有的聊天记录截一张长图并保存到相册;
  • 截图(马赛克):把所有的聊天记录截一张长图并保存到相册,需要把群友的头像和昵称打码;
  • 预览:预览打码后的效果。

如何解决

下面这张图是聊天记录页面,点击导航右边按钮,会弹出 ActionSheet。从图中可以看出,添加截图功能,在 ActionSheet 上添加是不错的选择。

61522546968_.pic.jpg

我们下面主要的工作是:

  • 1.拿到 ActionSheet 并添加3个菜单(截图,截图(马赛克),预览)
  • 2.找到聊天记录所在的页面,找到展示消息的视图;
  • 3.获取 ActionSheet 点击菜单对应的事件;
  • 4.实现截取长图并保存到相册功能;
  • 5.对头像和昵称打马赛克;
  • 6.添加版权信息。

实现

本文使用 MonkeyDev 工具开发(无需越狱),重点是教你如何开发一个微信插件,并不打算介绍工具。关注我们的朋友应该都知道,以往的 #iOS知识小集 中我已经介绍了三个工具的使用。这三个工具在下面会用到。

给 ActionSheet 添加3个菜单

使用 Reveal 工具查看聊天记录页面对应的 VC,ActionSheet 对应的类名。如何使用 Reveal 调试第三方 APP,网上有很多教程。使用MonkeyDev无需越狱。

reveal.png

通过上图可以看到聊天记录所对应的VC是 MsgRecordDetailViewController,使用 MMTableView 展示聊天内容。弹出的 ActionSheet 对应的类为 WCActionSheet, 可以发现它是一个 UIWindow 。 那么我们看看这几个类中的内容吧。

使用 class-dump 查看第三方 APP 的头文件。在 #iOS知识小集 中已经介绍过这个工具的使用。

MsgRecordDetailViewController的头文件中发现有一个 WCActionSheet *favImgLongPressAction; 我们可以断定出 WCActionSheet 就是我们要找的 ActionSheet。好了,接下来主要就是看 WCActionSheet 的头文件,挖掘有用的信息。

WCActionSheet头文件

@property(strong, nonatomic) NSMutableArray *buttonTitleList;
- (void)showInView:(id)arg1;
- (long long)addButtonWithItem:(id)arg1 atIndex:(unsigned long long)arg2;
- (long long)addButtonWithTitle:(id)arg1 atIndex:(unsigned long long)arg2;
- (long long)addButtonWithTitle:(id)arg1;

我们的目标是给 WCActionSheet 添加3个菜单。下面这些方法似乎对我们有用。目前想到有两种方案:

1.在 buttonTitleList 中添加一个对象

我们所关心的最主要的问题是 buttonTitleList 中存放的的对象是什么?需要使用Cycript工具,这个工具在以往的 #iOS知识小集 介绍过,想了解的朋友可以在知识小集小程序中搜索 Cycript调试第三方APP

cycript.png

通过 Cycript 可以看到 buttonTitleList 中存放的对象是 WCActionSheetItem。我们看看 WCActionSheetItem 的头文件,发现其实就是一个 Model 对象,用来表示菜单的标题,颜色等等。

@interface WCActionSheetItem : NSObject 
@property(copy, nonatomic) NSString *titleColor;
@property(copy, nonatomic) NSString *title; 
- (id)initWithTitle:(id)arg1 fontSize:(long long)arg2 fontColor:(id)arg3 WithDesc:(id)arg4 descFontSize:(long long)arg5 descFontColor:(id)arg6 enable:(_Bool)arg7;
- (id)initWithTitle:(id)arg1;

看到这里,我们可以直接在 buttonTitleList 中添加 WCActionSheetItem 实例即可。

2.直接调用 addButtonWithTitle: 方法

从上图可以看出直接调用 addButtonWithTitle: 这个方法,返回一个 Index 为 3,说明可以直接调用这个方法。

接下来主要的问题是,找到添加菜单的时机。第一想到的是在 WCActionSheetDelegate 的代理中添加菜单,果断在 MsgRecordDetailViewController 中 Hook 下面这3个代理方法,但是通过实验证明,发现最后两个方法并没有被调用,因为 MsgRecordDetailViewController 并没有实现这两个代理,只好放弃了这种思路。

- (void)actionSheet:(id)arg1 clickedButtonAtIndex:(long long)arg2;
- (void)didPresentActionSheet:(WCActionSheet *)arg1;
- (void)willPresentActionSheet:(WCActionSheet *)arg1;

无奈之下看到 WCActionSheet 中有个showInView:方法, 可以直接 Hook 这个方法。但这样导致所有的 WCActionSheet 都会被添加了额外的菜单。而我们的目的只是在聊天记录页中的 WCActionSheet 显示截图菜单。所以用 [WeChatSaveData defaultSaveData].isNeedAddMenu 加了一个判断,isNeedAddMenu 在 MsgRecordDetailViewController 页面将要出现的时候,设置成 YES,在页面将要消失的时候,设置成 NO。所以需要 Hook MsgRecordDetailViewControllerviewWillAppear:viewWillDisappear:方法。

CHOptimizedMethod1(self, void, WCActionSheet, showInView, UIView *, view){
    if ([WeChatSaveData defaultSaveData].isNeedAddMenu) {
        // 方案一
        [self addButtonWithTitle:@""]; // 填坑
        [self addButtonWithTitle:kScreenshotTitle];
        [self addButtonWithTitle:kScreenshotTitleMask];
        
        // 方案二
       WCActionSheetItem *shotItem = [[objc_getClass("WCActionSheetItem") alloc] initWithTitle:kScreenshotTitle];
        WCActionSheetItem *shotItem2 = [[objc_getClass("WCActionSheetItem") alloc] initWithTitle:kScreenshotTitleMask];
        [self.buttonTitleList addObject:shotItem];
        [self.buttonTitleList addObject:shotItem2];
    }
    CHSuper1(WCActionSheet, showInView, view);
}

执行结果如下图:

21523364398_.pic_hd.jpg

找到聊天记录页面和展示消息的视图

MsgRecordDetailViewController头文件

@interface MsgRecordDetailViewController: UIViewController
{
    MMTableView *m_tableView;
}
- (void)viewWillAppear:(_Bool)arg1;
- (void)viewWillDisappear:(_Bool)arg1;
- (void)actionSheet:(id)arg1 clickedButtonAtIndex:(long long)arg2;
- (UITableViewCell *)tableView:(id)arg1 cellForRowAtIndexPath:(id)arg2;
@end

通过对 MsgRecordDetailViewController头文件分析,可以达到截图功能,只需要截取 TableView 为一张长图即可。

获取到 MsgRecordDetailViewController 实例,使用 KVC 的方式即可获取到 MMTableView

MMTableView *tableView = [viewController valueForKeyPath:@"m_tableView"];

实现截取长图的功能,保存到相册

首先需要 Hook actionSheet: clickedButtonAtIndex: 捕获菜单的点击事件,做截图功能。

CHOptimizedMethod2(self, void, MsgRecordDetailViewController, actionSheet, WCActionSheet*, sheet, clickedButtonAtIndex, int, index){
    CHSuper2(MsgRecordDetailViewController, actionSheet, sheet, clickedButtonAtIndex, index);

    [WeChatCapture saveCaptureImageWithSheet:sheet index:index viewController:self];
}

saveCaptureImageWithSheet 这个方法中主要获取到 MMTableView 并截图保存到相册。有兴趣可以看源码。

对头像和昵称打马赛克

为了保护用户的隐私,需要对用户的头像和昵称做保护,那么我们可以在 TableView 的代理中获取头像和昵称对应的 View,然后替换 View 的内容即可。需要 Hook cellForRowAtIndexPath 这个方法。

CHOptimizedMethod2(self, UITableViewCell *, MsgRecordDetailViewController, tableView, MMTableView *, tableViewArg, cellForRowAtIndexPath, NSIndexPath, *indexPath){
    UITableViewCell *cell = CHSuper2(MsgRecordDetailViewController, tableView, tableViewArg, cellForRowAtIndexPath, indexPath);
    [WeChatCapture updateCellDataWithCell:cell indexPath:indexPath];
    return cell;
}

获取到 Cell 如果当前是要打码截图,需要对头像和昵称的内容做处理。这里做一个特殊的处理,头像和昵称我们换成三国人物的头像的名字。

+ (void)updateCellDataWithCell:(UITableViewCell *)cell indexPath:(NSIndexPath *)indexPath
{
    if ([WeChatSaveData defaultSaveData].maskType == WeChatSaveDataMaskTypeMast || [WeChatSaveData defaultSaveData].maskType == WeChatSaveDataMaskTypePreview) {
        NSArray *subviews = [cell.contentView subviews];
        FavRecordBaseNodeView *nodeView = [subviews lastObject];
        if ([NSStringFromClass([nodeView class]) hasSuffix:@"NodeView"]) {
            UILabel *nickNameLabel = [nodeView valueForKey:@"m_srcTitleLabel"];
            if (nickNameLabel) {
                CGRect tempFrame = nickNameLabel.frame;
                tempFrame.size.width = 120;
                nickNameLabel.frame = tempFrame;
            }
            
            MMHeadImageView *imageView = [nodeView valueForKeyPath:@"m_headImg"];
            NSString *nickName = [imageView valueForKey:@"_nsUsrName"];
            WeChatUser *aUser = [[WeChatSaveData defaultSaveData].userNameDict objectForKey:nickName?:@""];
            if (!aUser) {
                aUser = [WeChatUser user];
                [[WeChatSaveData defaultSaveData].userNameDict setObject:aUser forKey:nickName?:@""];
            }
            nickNameLabel.text = aUser.nickname ?: @"";
            if (imageView) {
                [imageView updateUsrName:aUser.nickname withHeadImgUrl:aUser.icon];
            }
        }
    }
}

添加版权信息

给绘制的长图添加一个小集的版权 by公众号 知识小集

最终效果(伴随着咔嚓一声,一张被打码的照片保存到了相册,你可以在任意的渠道分享了):

11523363542_.pic_hd.jpg

踩坑

WCActionSheet的代理方法Index不对

添加额外的菜单后,WCActionSheet 的代理方法 actionSheet: clickedButtonAtIndex: 中的 Index 点击取消或空白区域总为 2,也就是我添加菜单第一个的 Index。导致每次点击取消或空白区域时都会听到咔嚓一声截图。解决方法,就是加一个没有标题的菜单,并且高度为 0。

收藏的聊天记录也需要有截图功能

按着这个思路给收藏中的聊天记录添加截图功能,这就是你为什么会在源码中看到 FavRecordDetailViewController 的 Hook。

总结

逆向的整个流程可以归结为:

  • 第一,灵感,有一个好的想法,然后去实现它;
  • 第二,工具,做逆向必须借助强大的工具;
  • 第三,查找,找出你所关心的视图,导出头文件,看看从头文件中能得到什么;
  • 第四,思路,想想实现思路是什么,从什么地方入手比较好;
  • 最后,实现,所有的思路想好后,开始编写代码。

而本文也是按这几个步骤逐渐实现的:

  • 第一,灵感,我们遇到的痛点;
  • 第二,工具,做这个时 Lefe_x 已经有一些逆向基础,会基本的工具使用;
  • 第三,查找,通过 Revel 和 Cycript 工具找出所关注的视图,着手查看头文件;
  • 第四,思路,梳理思路,Hook 哪些方法;
  • 最后,实现,编码,允许,查看效果。

本插件以开源 前往查看 ,如果想安装在自己的手机上,需要安装 MonkeyDev,把图中的文件导入即可

demo.png

知识小集公众号

知识小集是一个团队公众号,每周都会有原创文章分享,我们的文章都会在公众号首发。知识小集微信群,短短几周时间,目前群友已经300+人,很快就要达到上限(抓住机会哦),关注公众号获取加群方式。