检测zip文件完整(进阶:APK文件渠道号)

朋友聊天讨论到一个问题,怎么检测zip的完整性。zip是我们常用的压缩格式,不管是Win/Mac/Linux下都很常用,我们做文件的下载也会经常用到,网络充满不确定性,对于多个小文件(比如配置文件)的下载,我们希望只发起一次连接,因为建立连接是很耗费资源的,即使现在http2.0可以对一条TCP连接进行复用,我们还是希望网络请求的次数越少越好,不管是对于稳定性还是成功失败的逻辑判断,都会有益处。

这个时候我们常用的其实就是把他们压缩成一个zip文件,下载下来之后解压就好了。

但很多时候zip会解压失败,如果我们的zip已经下载下来了,其实不存在没有访问权限的问题了,那原因除了空间不够之外,就是zip格式有问题了,zip文件为空或者只下载了一半。
这个时候就需要检测一下我们下载下来的zip是不是合法有效的zip了。
有这么几种思路:

  1. 直接解压,抛异常表明zip有问题
  2. 下载前得到zip文件的length,下载后检测文件大小
  3. 使用md5或sha1等摘要算法,下载下来后做md5,然后比对合法性
  4. 检测zip文件结尾的特殊编码格式,检测是否zip合法

这几种做法有利有弊,这里我们只看第4种。
我们讨论之前,可以大致了解一下zip的格式ZIP文件格式分析,我们关注的是End of central directory record,核心目录结束标记,每个zip只会出现一次。

Offset Bytes Description
0 4 End of central directory signature = 0x06054b50 核心目录结束标记(0x06054b50)
4 2 Number of this disk 当前磁盘编号
6 2 number of the disk with the start of the central directory 核心目录开始位置的磁盘编号
8 2 total number of entries in the central directory on this disk 该磁盘上所记录的核心目录数量
10 2 total number of entries in the central directory 核心目录结构总数
12 4 Size of central directory (bytes) 核心目录的大小
16 4 offset of start of central directory with respect to the starting disk number 核心目录开始位置相对于archive开始的位移
20 2 .ZIP file comment length(n) 注释长度
22 n .ZIP Comment 注释内容

我们可以看到,0x06054b50所在的位置其实是在zip.length减去22个字节,所以我们只需要seek到需要的位置,然后读4个字节看是否是0x06054b50,就可以确定zip是否完整。
下面是一个判断的代码

    //没有zip文件注释时候的目录结束符的偏移量
    private static final int RawEndOffset = 22;
    //0x06054b50占4个字节
    private static final int endOfDirLength = 4;
    //目录结束标识0x06054b50 的小端读取方式。
    private static final byte[] endOfDir = new byte[]{0x50, 0x4B, 0x05, 0x06};

    private boolean isZipFile(File file) throws IOException {
        if (file.exists() && file.isFile()) {
            if (file.length() <= RawEndOffset + endOfDirLength) {
                return false;
            }
            long fileLength = file.length();
            RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
            //seek到结束标记所在的位置
            randomAccessFile.seek(fileLength - RawEndOffset);
            byte[] end = new byte[endOfDirLength];
            //读取4个字节
            randomAccessFile.read(end);
            //关掉文件
            randomAccessFile.close();
            return isEndOfDir(end);
        } else {
            return false;
        }
    }

    /**
     * 是否符合文件夹结束标记
     */
    private boolean isEndOfDir(byte[] src) {
        if (src.length != endOfDirLength) {
            return false;
        }
        for (int i = 0; i < src.length; i++) {
            if (src[i] != endOfDir[i]) {
                return false;
            }
        }
        return true;
    }

有人可能注意到了,你上面写的结束标识明明是0x06054b50,为什么检测的时候是反着写的。这里就涉及到一个大端小端的问题,录音的时候也能会遇到大小端顺序的问题,反过来读就好了。

涉及到二进制的查看和编辑,我们可以使用010editor这个软件来查看文件的十六进制或者二进制,并且可以手动修改某个位置的二进制。


他的界面大致长这样子,小端显示的,我们可以看到我们要得到的06 05 4b 50


我们看上面的表格里面最后一个表格里的 .ZIP file comment length(n).ZIP Comment ,意思是描述长度是两个字节,描述长度是n,表示这个长度是可变的。这个有啥作用呢?
其实就是给了一个可以写额外的描述数据的地方(.ZIP Comment),他的长度由前面的.ZIP file comment length(n)来控制。也就是zip允许你在它的文件结尾后面额外的追加内容,而不会影响前面的数据。描述文件的长度是两个字节,也就是一个short的长度,所以理论上可以寻址216个位置。
举个例子:

修改之前

修改之后

看上面两个文件,修改之前长度为0,我们把它改成2(注意大小端),我们改成2,然后随便在后面追加两个byte,保存,打开修改之后的zip,发现是可以正常运行的,甚至我们可以在长度是2的基础上追加多个byte,其实还是可以打开的。
所以回到标题内容,其实apk就是zip,我们同样可以在apk的Comment后面追加内容,比如可以当做渠道来源,或者完成这样的需求:h5网页A上下载的需要打开某个ActivityA,h5网页B上下载的需要打开某个ActivityB。

原理还是上面的原理,写入渠道或者配置,读取apk渠道或者配置,做相应统计或者操作。

    //magic -> yocn
    private static final byte[] MAGIC = new byte[]{0x79, 0x6F, 0x63, 0x6E};
    //没有zip文件注释时候的目录结束符的偏移量
    private static final int RawEndOffset = 22;
    //0x06054b50占4个字节
    private static final int endOfDirLength = 4;
    //目录结束标识0x06054b50 的小端读取方式。
    private static final byte[] endOfDir = new byte[]{0x50, 0x4B, 0x05, 0x06};
    //注释长度占两个字节,所以理论上可以支持 2^16 个字节。
    private static final int commentLengthBytes = 2;
    //注释长度
    private static final int commentLength = 8;

    private boolean isZipFile(File file) throws IOException {
        if (file.exists() && file.isFile()) {
            if (file.length() <= RawEndOffset + endOfDirLength) {
                return false;
            }
            long fileLength = file.length();
            RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r");
            //seek到结束标记所在的位置
            randomAccessFile.seek(fileLength - RawEndOffset);
            byte[] end = new byte[endOfDirLength];
            //读取4个字节
            randomAccessFile.read(end);
            //关掉文件
            randomAccessFile.close();
            return isEndOfDir(end);
        } else {
            return false;
        }
    }

    /**
     * 是否符合文件夹结束标记
     */
    private boolean isEndOfDir(byte[] src) {
        if (src.length != endOfDirLength) {
            return false;
        }
        for (int i = 0; i < src.length; i++) {
            if (src[i] != endOfDir[i]) {
                return false;
            }
        }
        return true;
    }

    /**
     * zip(apk)尾追加渠道信息
     */
    private void write2Zip(File file, String channelInfo) throws IOException {
        if (isZipFile(file)) {
            long fileLength = file.length();
            RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
            //seek到结束标记所在的位置
            randomAccessFile.seek(fileLength - commentLengthBytes);
            byte[] lengthBytes = new byte[2];
            lengthBytes[0] = commentLength;
            lengthBytes[1] = 0;
            randomAccessFile.write(lengthBytes);
            randomAccessFile.write(getChannel(channelInfo));
            randomAccessFile.close();
        }
    }

    /**
     * 获取zip(apk)文件结尾
     *
     * @param file 目标哦文件
     */
    private String getZipTail(File file) throws IOException {
        long fileLength = file.length();
        RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r");
        //seek到magic的位置
        randomAccessFile.seek(fileLength - MAGIC.length);
        byte[] magicBytes = new byte[MAGIC.length];
        //读取magic
        randomAccessFile.read(magicBytes);
        //如果不是magic结尾,返回空
        if (!isMagicEnd(magicBytes)) return "";
        //seek到读到信息的offest
        randomAccessFile.seek(fileLength - commentLength);
        byte[] lengthBytes = new byte[commentLength];
        //读取渠道
        randomAccessFile.read(lengthBytes);
        randomAccessFile.close();
        char[] lengthChars = new char[commentLength];
        for (int i = 0; i < commentLength; i++) {
            lengthChars[i] = (char) lengthBytes[i];
        }
        return String.valueOf(lengthChars);
    }

    /**
     * 是否以魔数结尾
     *
     * @param end 检测的byte数组
     * @return 是否结尾
     */
    private boolean isMagicEnd(byte[] end) {
        for (int i = 0; i < end.length; i++) {
            if (MAGIC[i] != end[i]) {
                return false;
            }
        }
        return true;
    }

    /**
     * 生成渠道byte数组
     */
    private byte[] getChannel(String s) {
        byte[] src = s.getBytes();
        byte[] channelBytes = new byte[commentLength];
        System.arraycopy(src, 0, channelBytes, 0, commentLength);
        return channelBytes;
    }

  //读取源apk的路径
  public static String getSourceApkPath(Context context, String packageName) {
    if (TextUtils.isEmpty(packageName))
        return null;
    try {
        ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo(packageName, 0);
            return appInfo.sourceDir;
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
    return null;
}

这里使用了一个魔数的概念,表明是否是写入了我们特定的渠道,只有写了我们特定渠道的基础上才会去读取,防止读到了没有写过的文件。
读取渠道的时候首先获取安装包的绝对路径。Android系统在用户安装app时,会把用户安装的apk拷贝一份到/data/apk/路径下,通过getSourceApkPath 可以获取该apk的绝对路径。如果使用rw可能会有权限问题,所以读取的时候只使用r就可以了。

参考:
ZIP文件格式分析
全民K歌增量升级方案

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,640评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,254评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,011评论 0 355
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,755评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,774评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,610评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,352评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,257评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,717评论 1 315
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,894评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,021评论 1 350
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,735评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,354评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,936评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,054评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,224评论 3 371
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,974评论 2 355