Flutter Notes | Android 借壳分享微信

4,485 阅读13分钟

每个生命体的存在,其实本质都是一个复杂的过程。很多时候,无需追求完美的理想情况,毕竟,You are just you。

在这里插入图片描述

免责声明

为了避免收费的小哥哥干我,或者出现其它不好的情况,这里特意注明下:

本文如同标题一样,只属于个人笔记,仅限技术分享~ 如出现其他情况,一概与本人无关~

本文如同标题一样,只属于个人笔记,仅限技术分享~ 如出现其他情况,一概与本人无关~

本文如同标题一样,只属于个人笔记,仅限技术分享~ 如出现其他情况,一概与本人无关~

前言

前段时间,公司突然来一需求:

  • 调研某款 App Android 版微信分享来源动态原理以及实现方式

第一时间,当然是看看网上有没有前辈开源,借鉴(CV 大法)一波。

查询结果真的是悲喜交加:

  • 开森的是,有人研究过这个东西,也封装好了对应的 SDK。
  • 悲剧的是收费,目前已了解的情况最低 100。

对于本身在帝都讨生活的落魄小 Android 而言,无疑是一笔巨款 (手动滑稽~勿喷~)

都说穷人家的孩子早当家,不得已开始了逆向、分析之路 😂😂😂

相关代码已上传 GitHub,当然为了不给自己找事儿,本地命中库就不提供了,自己逆向去拿吧,地址如下:

效果图

空谈无用,来个实际效果图最棒,这里就以我梦想殿堂 App 为例进行测试咯。

准备工具

基于个人了解简单概述:

  • ApkTools: 一般就是为了改包、回包,捎带脚拿个资源文件。
  • ClassyShark: 一款贼方便分析 Apk 工具,一般用于看看大厂都玩啥。
  • dex2jar: 将 .dex 文件转换为 .class 文件。
  • JD-GUI: 主要是查看反编译后的源代码。

下面附上相关工具网盘链接:

实战开搞

在正式开始前,先来见识下 ClassyShark 这个神器吧。

一、Hi,ClassyShark

首先进入你下载好的 ClassyShark.jar 目录中,随后执行如下命令即可:

  • java -jar ClassyShark.jar

示意图如下:

随后在打开的可视化工具中将想看的 Apk 直接拖进去即可:

拖进去之后点击包名,会有一个对当前 Apk 的简单概述:

点击 Methods count 可以查看当前 Apk 方法数:

当然你可以继续往下一层级查看,比如我点击 bilibili:

同样也可以导出文件,这里不作为本文重点阐述了,有兴趣的可以自己研究~

二、逆向分析走起

首先,网上下载目标 App,并将后缀名修改为 zip,随后解压进入该目录:

手动进入已下载完成的 dex-tools-2.1-SNAPSHOT 目录中,执行如下命令:

  • sh d2j-dex2jar.sh [目标 dex 文件地址]

例如:

完成之后,将会在 dex-tools-2.1-SNAPSHOT 目录中生成 classes-dex2jar.jar 文件,这里文件就是我们接下来逆向分析的靠山呐。

随后将生成的 jar 文件拖入 JD-GUI 中。

查看 AndroidManifest 获取到当前应用包名,有助于我们一步到位~

由于目标 App 是在文章的详情页中提供分享微信消息回话以及朋友圈,详情一般个人命名为 XxxDetailsActivity,根据这个思路去搜索。

有些尴尬啊,怎么搜索到了腾讯的 SDK 呢?

还是手动人工查找吧,😂😂😂

在这块发现个比较有意思的东西,可能是我比较 low 吧。一般而言,我们都知道混淆实体类是肯定不能被混淆的,不然就会出现找不到的情况。那么奇怪了,昨天逆向 B 站 Apk,我竟然没发现实体类,难道他们的实体类有其他神操作?还是说分包太多我没找到?

终于找到你,文章详情页!!!

操作 App,发现是点击按钮弹出底部分享对话框,原版如下:

随后继续在代码中查看,果然:

这个就很好理解了,自定义一个底部对话框,点击传递分享的 Url 以及分享类型。现在我们去 ShareArticleDialog 这个类中验证一下猜想是否正确?

看,0 应该是代表分享微信消息会话,1 代表分享朋友圈。

经过一番排查,发现最终是通过调用如下方法进行分享微信:

public static int send(Context paramContext, String paramString1, String paramString2, String paramString3, Bundle paramBundle) {
    CURRENT_SHARE_CLIENT = null;
    if (paramContext == null || paramString1 == null || paramString1.length() == 0 || paramString2 == null || paramString2.length() == 0) {
      Log.w("MMessageAct", "send fail, invalid arguments");
      return -1;
    } 
    Intent intent = new Intent();
    intent.setClassName(paramString1, paramString2);
    if (paramBundle != null)
      intent.putExtras(paramBundle); 
    intent.putExtra("_mmessage_sdkVersion", 603979778);
    int i = getPackageSign(paramContext);
    if (i == -1)
      return -1; 
    CURRENT_SHARE_CLIENT = shareClient.get(i);
    intent.putExtra("_mmessage_appPackage", "这里换成要借壳 App 包名");
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.append("weixin://sendreq?appid=");
    stringBuilder.append("这里换成要借壳 AppId");
    intent.putExtra("_mmessage_content", stringBuilder.toString());
    intent.putExtra("_mmessage_checksum", MMessageUtil.signatures(paramString3, paramContext.getPackageName()));
    intent.addFlags(268435456).addFlags(134217728);
    try {
      paramContext.startActivity(intent);
      StringBuilder stringBuilder1 = new StringBuilder();
      this();
      stringBuilder1.append("send mm message, intent=");
      stringBuilder1.append(intent);
      Log.d("MMessageAct", stringBuilder1.toString());
      return i;
    } catch (Exception exception) {
      exception.printStackTrace();
      Log.d("MMessageAct", "send fail, target ActivityNotFound");
      return -1;
    } 
}

在查看微信 SDK 中也发现类似代码,由于掘金这个上传图片宽高我现在还不会调整,暂时防止目录位置,感兴趣的小伙伴自行查看:

其它细节就不一一分析了,直接上代码咯~

三、附上代码~

其实本质借壳分享,个人的理解如下:

  • 第一步:绕过微信检测,例如包名、签名是否和微信开放平台绑定一致;
  • 第二部:组装参数,直接直击深处,分享微信。

由于此次是 Flutter 项目,不得不的面对的是与原生 Android 的交互。由于我是刚刚入坑 Flutter 几周,内心真的是忐忑不安。

不过值得让人赞叹的是,Flutter 的生态,真的贼棒!尤其我鸡老大,神一般存在!默默的感谢我大哥~!

0. 简单聊下 Flutter 与交互

在 Flutter 中文社区中官网对此有这样的一段描述:

Flutter 使用了灵活的系统,它允许你调用相关平台的 API,无论是 Android 中的 Java 或 Kotlin 代码,还是 iOS 中的 Objective-C 或 Swift 代码。

Flutter 内置的平台特定 API 支持不依赖于任何生成代码,而是灵活的依赖于传递消息格式。或者,你也可以使用 Pigeon 这个 > package,通过生成代码来发送结构化类型安全消息。

  • 应用程序中的 Flutter 部分通过平台通道向其宿主(应用程序中的 iOS 或 Android 部分)发送消息。

  • 宿主监听平台通道并接收消息。然后,它使用原生编程语言来调用任意数量的相关平台 API,并将响应发送回客户端(即应用程序中的 Flutter 部分)。

也就是说,Flutter 充分给予我们调用原生 Api 的权利,关键桥梁便是这个通道消息。

下面一起来看下官方的图:

消息和响应以异步的形式进行传递,以确保用户界面能够保持响应。

客户端做方法调用的时候 MethodChannel 会负责响应,从平台一侧来讲,Android 系统上使用 MethodChannelAndroid、 iOS 系统使用 MethodChanneliOS 来接收和返回来自 MethodChannel 的方法调用。

其实对于我一个新手而言,看这些真的似懂非懂,所以过多的等以后掌握了之后再来探讨吧。这块内容将在下面代码部分着重说明。

1. 引入三方库

api 'com.tencent.mm.opensdk:wechat-sdk-android-without-mta:+'
// 主要用于将分享的在线图片转换为 Bitmap
implementation 'com.github.bumptech.glide:glide:4.11.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'
implementation 'com.google.code.gson:gson:2.8.6'

2. 完善混淆文件

# 保护我方输出(保护实体类不被混淆)
-keep public class com.Your Package Name.bean.**{*;}

# Gson
-keepattributes Signature
# Gson specific classes
-keep class sun.misc.Unsafe { *; }
-keep class com.google.gson.** { *; }
# Application classes that will be serialized/deserialized over Gson
-keep class com.google.gson.examples.android.model.** { *; }

3. 编写原生 Android 工具类

这里具体还是需要结合实际项目需求而定,不过通用型的一些东西必须要有:

  • 动态检测宿主,也可以理解为动态检测借壳目标是否存在;

而剩下的则是分享微信了,这里简单放置关键代码,详情可点击文章开始的 GitHub 地址。

package com.hlq.struggle.utils

import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.hlq.struggle.app.appInfoJson
import com.hlq.struggle.bean.AppInfoBean
import com.tencent.mm.opensdk.modelmsg.SendMessageToWX
import com.tencent.mm.opensdk.modelmsg.WXMediaMessage
import com.tencent.mm.opensdk.modelmsg.WXMediaMessage.IMediaObject
import com.tencent.mm.opensdk.modelmsg.WXWebpageObject
import java.io.ByteArrayOutputStream
import java.io.IOException

/**
 * @author:HLQ_Struggle
 * @date:2020/6/27
 * @desc:
 */
@Suppress("SpellCheckingInspection")
class ShareWeChatUtils {

    companion object {

        /**
         * 解析本地缓存 App 信息
         */
        private fun getLocalAppCache(): ArrayList<AppInfoBean> {
            return Gson().fromJson(
                    appInfoJson,
                    object : TypeToken<ArrayList<AppInfoBean>>() {}.type
            )
        }

        /**
         * 检测用户设备安装 App 信息
         */
        fun checkAppInstalled(context: Context): Int {
            var tempCount = -1
            // 获取本地宿主 App 信息
            val appInfoList = getLocalAppCache()
            // 获取用户设备已安装 App 信息
            val packageManager = context.packageManager
            val installPackageList = packageManager.getInstalledPackages(0)
            if (installPackageList.isEmpty()) {
                return 0
            }
            for (packageInfo in installPackageList) {
                for (appInfo in appInfoList) {
                    if (packageInfo.packageName == appInfo.packageName) {
                        tempCount++
                    }
                }
            }
            return tempCount
        }

        /**
         * 命中已安装 App
         */
        private fun hitInstalledApp(context: Context): AppInfoBean? {
            // 获取本地宿主 App 信息
            val appInfoList = getLocalAppCache()
            // 获取用户设备已安装 App 信息
            val packageManager = context.packageManager
            // 能进入方法说明本地已存在命中 App,使用时还需要预防
            val installPackageList = packageManager.getInstalledPackages(0)
            for (packageInfo in installPackageList) {
                for (appInfo in appInfoList) {
                    if (packageInfo.packageName == appInfo.packageName) {
                        return appInfo
                    }
                }
            }
            return null
        }

        /**
         * 分享微信
         */
        fun shareWeChat(
                context: Context,
                shareType: Int,
                url: String,
                title: String,
                text: String,
                paramString4: String?,
                umId: String?
        ) {
            Glide.with(context).asBitmap().load(paramString4)
                    .listener(object : RequestListener<Bitmap?> {
                        override fun onLoadFailed(
                                param1GlideException: GlideException?,
                                param1Object: Any,
                                param1Target: Target<Bitmap?>,
                                param1Boolean: Boolean
                        ): Boolean {
                            LogUtils.logE(" ---> Load Image Failed")
                            return false
                        }

                        override fun onResourceReady(
                                param1Bitmap: Bitmap?,
                                param1Object: Any,
                                param1Target: Target<Bitmap?>,
                                param1DataSource: DataSource,
                                param1Boolean: Boolean
                        ): Boolean {
                            LogUtils.logE(" ---> Load Image Ready")
                            val i =
                                    send(
                                            context,
                                            shareType,
                                            url,
                                            title,
                                            text,
                                            param1Bitmap
                                    )
                            val stringBuilder = StringBuilder()
                            stringBuilder.append("send index: ")
                            stringBuilder.append(i)
                            LogUtils.logE(" ---> Ready stringBuilder.toString() :$stringBuilder")
                            return false
                        }
                    }).preload(200, 200)
        }

        private fun send(
                paramContext: Context,
                paramInt: Int,
                paramString1: String,
                paramString2: String,
                paramString3: String,
                paramBitmap: Bitmap?
        ): Int {
            val stringBuilder = StringBuilder()
            stringBuilder.append("share url: ")
            stringBuilder.append(paramString1)
            LogUtils.logE(" ---> send :$stringBuilder")
            val wXWebpageObject = WXWebpageObject()
            wXWebpageObject.webpageUrl = paramString1
            val wXMediaMessage = WXMediaMessage(wXWebpageObject as IMediaObject)
            wXMediaMessage.title = paramString2
            wXMediaMessage.description = paramString3
            wXMediaMessage.thumbData =
                    bmpToByteArray(
                            paramContext,
                            Bitmap.createScaledBitmap(paramBitmap!!, 150, 150, true),
                            true
                    )
            val req = SendMessageToWX.Req()
            req.transaction =
                    buildTransaction(
                            "webpage"
                    )
            req.message = wXMediaMessage
            req.scene = paramInt
            val bundle = Bundle()
            req.toBundle(bundle)
            return sendToWx(
                    paramContext,
                    "weixin://sendreq?appid=wxd930ea5d5a258f4f",
                    bundle
            )
        }

        private fun buildTransaction(paramString: String): String {
            var paramString: String? = paramString
            paramString = if (paramString == null) {
                System.currentTimeMillis().toString()
            } else {
                val stringBuilder = StringBuilder()
                stringBuilder.append(paramString)
                stringBuilder.append(System.currentTimeMillis())
                stringBuilder.toString()
            }
            return paramString
        }

        private fun bmpToByteArray(
                paramContext: Context?,
                paramBitmap: Bitmap,
                paramBoolean: Boolean
        ): ByteArray? {
            val byteArrayOutputStream =
                    ByteArrayOutputStream()
            try {
                paramBitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
                if (paramBoolean) paramBitmap.recycle()
                val arrayOfByte = byteArrayOutputStream.toByteArray()
                byteArrayOutputStream.close()
                return arrayOfByte
            } catch (iOException: IOException) {
                iOException.printStackTrace()
            }
            return null
        }

        private fun sendToWx(
                paramContext: Context?,
                paramString: String?,
                paramBundle: Bundle?
        ): Int {
            return send(
                    paramContext,
                    "com.tencent.mm",
                    "com.tencent.mm.plugin.base.stub.WXEntryActivity",
                    paramString,
                    paramBundle
            )
        }

        private fun send(
                paramContext: Context?,
                packageName: String?,
                className: String?,
                paramString3: String?,
                paramBundle: Bundle?
        ): Int {
            if (paramContext == null || packageName == null || packageName.isEmpty() || className == null || className.isEmpty()) {
                LogUtils.logE(" ---> send fail, invalid arguments")
                return -1
            }
            val appInfoBean = hitInstalledApp(paramContext)
            val intent = Intent()
            intent.setClassName(packageName, className)
            if (paramBundle != null) intent.putExtras(paramBundle)
            intent.putExtra("_mmessage_sdkVersion", 603979778)
            intent.putExtra("_mmessage_appPackage", appInfoBean?.packageName)
            val stringBuilder = StringBuilder()
            stringBuilder.append("weixin://sendreq?appid=")
            stringBuilder.append(appInfoBean?.packageSign)
            intent.putExtra("_mmessage_content", stringBuilder.toString())
            intent.putExtra(
                    "_mmessage_checksum",
                    MMessageUtils.signatures(paramString3, paramContext.packageName)
            )
            intent.addFlags(268435456).addFlags(134217728)
            return try {
                paramContext.startActivity(intent)
                val sb = StringBuilder()
                sb.append("send mm message, intent=")
                sb.append(intent)
                LogUtils.logE(" ---> sb :$sb")
                0
            } catch (exception: Exception) {
                exception.printStackTrace()
                LogUtils.logE(" --->  send fail, target ActivityNotFound")
                -1
            }
        }
    }
}

4. 对 Flutter 暴露通道

这块需要注意几点,现在你可以理解为你在编写一个 Flutter 的小型插件,那么你需要向外部暴露一些你规定的类型,或者说方法。这个不难理解吧。

好比你去调用某个 SDK,官方一定是告知了一些重要的特性。那么针对我们现在的这个小插件,它比较关键的特性又是什么?

关于这个特性,个人这里分为俩个部分来说:

内部特性:

  • 本地命中宿主缓存 Json。这块主要是需要个人去维护,去抓去目前常用的一个 App 的相关信息,不断完善。

外部特性:

  • 通道名称。这个理解起来比较容易,好比你拿着 A 小区的通行证进入 B 小区,那么 B 小区的保安大叔肯定会给你拦下来,而反之你进入 A 小区则畅行无阻。
  • 对外暴露方法。比如说我现在对外暴露俩个方法,一个为检测命中宿主数量一个为实际的微信分享。
  • 关键参数描述。例如微信分享类型,目前偷个懒,Flutter 调用时只需要传递 bool 类型即可,SDK 内部会自行匹配。

针对以上内容,这里提取配置类:

package com.hlq.struggle.app

/**
 * @author:HLQ_Struggle
 * @date:2020/6/27
 * @desc:
 */

/**
 * 通道名称
 */
const val channelName = "HLQStruggle"

/**
 * 检测命中数量 > 0 代表可采用命中宿主方案借壳分享
 */
const val checkAppInstalledChannel = "checkAppInstalled"

/**
 * 分享微信
 */
const val shareWeChatChannel = "shareWeChat"

/**
 * 分享微信消息会话
 */
const val shareWeChatSession = 0

/**
 * 分享微信朋友圈
 */
const val shareWeChatLine = 1

/**
 * 本地缓存 App 信息
 */
const val appInfoJson =
        "[{\"appName\":\"App Name\",\"downloadUrl\":\"\",\"optional\":1,\"packageName\":\"Package Name\",\"packageSign\":\"App WeChat ID\",\"type\":1}]"

下面则是本地工具类,拼接参数,发送微信:

package com.hlq.struggle

import com.hlq.struggle.app.*
import com.hlq.struggle.utils.ShareWeChatUtils.Companion.checkAppInstalled
import com.hlq.struggle.utils.ShareWeChatUtils.Companion.shareWeChat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant

class MainActivity: FlutterActivity() {

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        GeneratedPluginRegistrant.registerWith(flutterEngine)
        // 处理 Flutter 传递过来的消息
        handleMethodChannel(flutterEngine)
    }

    private fun handleMethodChannel(flutterEngine: FlutterEngine) {
        MethodChannel(flutterEngine.dartExecutor, channelName).setMethodCallHandler { methodCall: MethodCall, result: MethodChannel.Result? ->
            when (methodCall.method) {
                checkAppInstalledChannel -> { // 获取命中 App 数量
                    result?.success(checkAppInstalled(activity))
                }
                shareWeChatChannel -> {  // 分享微信
                    val shareType = if (methodCall.argument<Boolean>("isScene")!!) {
                        shareWeChatSession
                    } else {
                        shareWeChatLine
                    }
                    result?.success(shareWeChat(
                            this, shareType,
                            methodCall.argument<String>("shareUrl")!!,
                            methodCall.argument<String>("shareTitle")!!,
                            methodCall.argument<String>("shareDesc")!!,
                            methodCall.argument<String>("shareThumbnail")!!, ""))
                }
                else -> {
                    result?.notImplemented()
                }
            }
        }
    }

}

5. Flutter 端调用

这里个人习惯,首先定义一个常量类,将 SDK 或者说 Android 端插件暴露参数定义一下,使用时统一调用,方便然后维护。

/// @date 2020-06-27
/// @author HLQ_Struggle
/// @desc 常量类

/// 通道名称
const String channelName = 'HLQStruggle';

/// 检测命中数量 > 0 代表可采用命中宿主方案借壳分享
const String checkAppInstalled = 'checkAppInstalled';

/// 分享微信
const String shareWeChat = 'shareWeChat'; 

而对于 Flutter 调用 Android 原生则比较 easy 了,相关注意的点已在代码中注释,这里直接附上对应的关键代码:

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            GestureDetector(
              onTap: () async {
                _shareWeChatApp(true);
              },
              child: Text(
                '点我分享微信消息会话',
              ),
            ),
            GestureDetector(
              onTap: () async {
                _shareWeChatApp(false);
              },
              child: Padding(
                padding: EdgeInsets.only(top: 30),
                child: Text(
                  '点我分享微信朋友圈',
                ),
              ),
            )
          ],
        ),
      ),
    );
  }

  /// 具体分享微信方式:true:消息会话 false:朋友圈
  /// 提前调取通道验证采用官方 SDK 还是借壳方案
  void _shareWeChatApp(bool isScene) async {
    /// 这里一定注意通道名称俩端一致
    const platform = const MethodChannel(channelName);
    int tempHitNum = 0;
    try {
      tempHitNum = await platform.invokeMethod(checkAppInstalled);
    } catch (e) {
      print(e);
    }
    if (tempHitNum > 0) {
      // 当前设备存在目标宿主 - 开始执行分享
      await platform.invokeMethod(shareWeChat, {
        'isScene': isScene,
        'shareTitle': '我是分享标题',
        'shareDesc': '我是分享内容',
        'shareUrl': 'https://juejin.cn/post/6844904177345232909',

        /// 分享内容在线地址
        'shareThumbnail':
            'https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2018/9/27/16618fef8bbf66fb~tplv-t2oaga2asx-image.image'

        /// 分享图片在线地址
      });
    } else {
      // 当前设备不存在目前宿主
    }
  }
}

好了,整个一个流程完成了。我们看下最后实际分享的效果:

6. 查看效果

  • 分享微信消息会话

分享成功提示,重点在分享来源:

分享微信消息会话,来源成功变成了我梦想殿堂旗下的某个 App 了。

而分享朋友圈则比较简单了:

番外 - 瞎叨叨

说实话,这个东西不难。

但是磕磕巴巴搞了好几天,也被各种催,甚至差点掏钱去买。

当我很开心的和鸡老大去分享这个事儿整个过程,除了鸡老大日常三连夸之外,老大默默说了个思路,问我是不是这样子的。

默默听完,蛋疼了半天,一模一样!

日常吹鸡老大,老大却淡淡的回复,很正常呀,巴拉巴拉~

老大,不愧是老大~

免责声明

为了避免收费的小哥哥干我,或者出现其它不好的情况,这里特意注明下:

本文如同标题一样,只属于个人笔记,仅限技术分享~ 如出现其他情况,一概与本人无关~

本文如同标题一样,只属于个人笔记,仅限技术分享~ 如出现其他情况,一概与本人无关~

本文如同标题一样,只属于个人笔记,仅限技术分享~ 如出现其他情况,一概与本人无关~

Thanks