Flutter Notes | Android 借壳分享微信

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

在这里插入图片描述

免责声明

为了不收费的小哥哥干我,或者出现其它很差的状况,这里特地注明下:java

本文如同标题同样,只属于我的笔记,仅限技术分享~ 如出现其余状况,一律与本人无关~android

本文如同标题同样,只属于我的笔记,仅限技术分享~ 如出现其余状况,一律与本人无关~git

本文如同标题同样,只属于我的笔记,仅限技术分享~ 如出现其余状况,一律与本人无关~github

前言

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

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

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

查询结果然的是悲喜交加:c#

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

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

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

相关代码已上传 GitHub,固然为了避免给本身找事儿,本地命中库就不提供了,本身逆向去拿吧,地址以下:缓存

效果图

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

准备工具

基于我的了解简单概述:

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

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

实战开搞

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

1、Hi,ClassyShark

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

  • java -jar ClassyShark.jar

示意图以下:

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

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

点击 Methods count 能够查看当前 Apk 方法数:

固然你能够继续往下一层级查看,好比我点击 bilibili:

一样也能够导出文件,这里不做为本文重点阐述了,有兴趣的能够本身研究~

2、逆向分析走起

首先,网上下载目标 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 中也发现相似代码,因为掘金这个上传图片宽高我如今还不会调整,暂时防止目录位置,感兴趣的小伙伴自行查看:

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

3、附上代码~

其实本质借壳分享,我的的理解以下:

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

因为这次是 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.im/post/5eb847e56fb9a0438e239243',

        /// 分享内容在线地址
        'shareThumbnail':
            'https://user-gold-cdn.xitu.io/2018/9/27/16618fef8bbf66fb?imageView2/1/w/180/h/180/q/85/format/webp/interlace/1'

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

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

6. 查看效果

  • 分享微信消息会话

image.png

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

image.png

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

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

image.png

番外 - 瞎叨叨

说实话,这个东西不难。

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

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

默默听完,蛋疼了半天,如出一辙!

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

老大,不愧是老大~

免责声明

为了不收费的小哥哥干我,或者出现其它很差的状况,这里特地注明下:

本文如同标题同样,只属于我的笔记,仅限技术分享~ 如出现其余状况,一律与本人无关~

本文如同标题同样,只属于我的笔记,仅限技术分享~ 如出现其余状况,一律与本人无关~

本文如同标题同样,只属于我的笔记,仅限技术分享~ 如出现其余状况,一律与本人无关~

Thanks

相关文章
相关标签/搜索