Java获取apk / ipa应用信息的思考与实践

读完这篇文章,你可能会了解到以下几点:

1. 蒲公英为什么只上传 ipa 文件,就可以下载 app
2. Java 解析 ipa 文件 (iOS 应用包)
3. Java 解析 apk 文件 (Android 应用包)
4. 自己上传 app 到服务器,模拟蒲公英的效果

************************************* 我是一条朴素的分割线 *************************************

20200512更新日志

问题

有些apk包,用之前的jar包解析,报错:Expected chunk of type 0x80003, read 0x80001.

解决办法

使用aapt命令解析apk文件

关于aapt

一、aapt定义:

aapt即Android Asset Packaging Tool, 在SDK的build-tools目录下。该工具可以查看、创建、更新ZIP格式的文档附件(zip,jar,apk)。也可将资源文件编译成二进制文件,尽管你可能没有直接使用aapt工具,但是build scripts和IDE插件会使用这个工具打包apk文件构成一个Android应用程序。在使用aapt之前需要在环境变量里面配置SDK-tools路径,或者是路径+aapt的方式进入aapt。

二、aapt文件位置

安卓sdk目录下:
/Users/yong.chen/Library/Android/sdk/build-tools/28.0.3/aapt

三、常用命令

AAPT常用命令
AAPT用法

选项 说明 例如
badging 查看apk包的packageName、versionCode、applicationLabel、launcherActivity、permission等各种详细信息 aapt dump badging <file_path.apk>
permissions 查看权限 aapt dump resources <file_path.apk>
resources 查看资源列表 aapt dump resources <file_path.apk> > sodino.txt
configurations 查看apk配置信息 aapt dump configurations <file_path.apk>
xmltree 以树形结构输出的xml信息。 aapt dump xmltree <file_path.apk> res/***.xml
xmlstrings 查看指定apk的指定xml文件。 aapt dump xmlstrings <file_path.apk> res/***.xml

TIP:由于我们工作中需要使用badging参数来查看versioncode,因此可以使用命令aapt dump badging <file_path.apk> | findstr “versionCode”来查看

上代码

    private ProcessBuilder mBuilder;
    private static final String SPLIT_REGEX = "(: )|(=')|(' )|'";

    public AppAnalyzeUtils() {
        mBuilder = new ProcessBuilder();
        mBuilder.redirectErrorStream(true);
    }
    private Map<String, Object> analyzeApk(String filePath, String aaptPath) {
        Map<String, Object> apkInfoMap = new HashMap<>();
        Process process = null;
        BufferedReader br = null;
        String tmp;
        InputStream is = null;
        try {
            process = mBuilder.command(aaptPath, "d", "badging", filePath).start();
            is = process.getInputStream();
            br = new BufferedReader(new InputStreamReader(is, "utf8"));
            tmp = br.readLine();
            if (tmp == null || !tmp.startsWith("package"))
                throw new Exception("参数不正确,无法正常解析APK包。输出结果为:\n" + tmp);
            do {
                if (tmp.startsWith("package")) splitPackageInfo(apkInfoMap, tmp);
            } while ((tmp = br.readLine()) != null);
            return apkInfoMap;
        } catch (Exception e) {
            logger.error("[AppAnalyzeUtils analyzeApk]" + e.getMessage());
        } finally {
            if (process != null) {
                process.destroy();
            }
            closeIO(is);
            closeIO(br);
            return apkInfoMap;
        }
    }
    private void splitPackageInfo(Map<String, Object> apkInfoMap, String packageSource) {
        String[] packageInfo = packageSource.split(SPLIT_REGEX);
        apkInfoMap.put("package", packageInfo[2]) ;
        apkInfoMap.put("buildVersion", packageInfo[4]) ;
        apkInfoMap.put("versionName", packageInfo[6]) ;
    }
    private void closeIO(Closeable io) {
        if (io != null) {
            try {
                io.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

存在的问题

本地启动服务,可以正常执行aapt命令,并解析apk文件。上传服务器后,因aapt文件没有执行权限,故报错,需要运维工程师协助配合。

************************************* 我是一条朴素的分割线 *************************************

关于蒲公英的思考

蒲公英的作用(在工作中)

  • 在我的实际工作中,蒲公英主要用于企业包(In-House证书打的包)的分发,方便 QA 和其他用户测试
  • 如果是自己做应用分发(下载),比如把 .ipainfo.plist 文件 上传到七牛服务器,然后自己制作一个下载页面

为什么蒲公英那么方便?

  • 我的想法是:在我们上传 ipa 文件的同时,蒲公英会根据 ipa 文件,读取应用对应的配置文件,获取必要信息 (比如bundleId),生成对应的 info.plist 文件,然后同时上传到服务器,就相当于我们自己手动上传那两个文件一样的效果。

思考:如何获取 ipa 或者 apk 文件的应用的配置文件信息呢?

获取 ipa 文件的配置文件信息

准备工作

  • iOS 安装包(.ipa 文件)
  • 解析 info.plist 文件所需的 jar 包(点我去下载页

主要代码

// AppUtil.java 文件

import com.dd.plist.NSDictionary;
import com.dd.plist.NSNumber;
import com.dd.plist.NSObject;
import com.dd.plist.NSString;
import com.dd.plist.PropertyListParser;

/**
 * 解析 IPA 文件
 * @param is 输入流
 * @return Map<String, Object>
 */
public static Map<String, Object> analyzeIpa(InputStream is) {
    Map<String, Object> resultMap = new HashMap<>();
    try {
        ZipInputStream zipIs = new ZipInputStream(is);
        ZipEntry ze;
        InputStream infoIs = null;
        while ((ze = zipIs.getNextEntry()) != null) {
            if (!ze.isDirectory()) {
                String name = ze.getName();
                // 读取 info.plist 文件
                // FIXME: 包里可能会有多个 info.plist 文件!!!
                if (name.contains(".app/Info.plist")) {
                    ByteArrayOutputStream abos = new ByteArrayOutputStream();
                    int chunk = 0;
                    byte[] data = new byte[256];
                    while(-1 != (chunk = zipIs.read(data))) {
                        abos.write(data, 0, chunk);
                    }
                    infoIs = new ByteArrayInputStream(abos.toByteArray());
                    break;
                }
            }
        }
        NSDictionary rootDict = (NSDictionary) PropertyListParser.parse(infoIs);
        String[] keyArray = rootDict.allKeys();
        for (String key : keyArray) {
            NSObject value = rootDict.objectForKey(key);
            if (key.equals("CFBundleSignature")) {
                continue;
            }
            if (value.getClass().equals(NSString.class) || value.getClass().equals(NSNumber.class)) {
                resultMap.put(key, value.toString());
            }
        }
        zipIs.close();
        is.close();
    } catch (Exception e) {
        resultMap.put("error", e.getStackTrace());
    }
    return resultMap;
}

获取 apk 文件的配置文件信息

准备工作

  • Android 安装包(.apk 文件)
  • 解析 AndroidManifest.xml 文件所需的 jar 包(点我去下载页

主要代码

// AppUtil.java 文件

import org.apkinfo.api.util.AXmlResourceParser;
import org.apkinfo.api.util.XmlPullParser;
import org.apkinfo.api.util.XmlPullParserException;

/**
 * 解析 APK 文件
 * @param is 输入流
 * @return Map<String,Map<String,Object>>
 */
public static Map<String,Map<String,Object>> analyzeApk(InputStream is) {
    Map<String,Map<String,Object>> resultMap = new HashMap<>();
    try {
        ZipInputStream zipIs = new ZipInputStream(is);
        zipIs.getNextEntry();
        AXmlResourceParser parser = new AXmlResourceParser();
        parser.open(zipIs);
        boolean flag = true;
        while(flag) {
            int type = parser.next();
            if (type == XmlPullParser.START_TAG) {
                int count = parser.getAttributeCount();
                String action = parser.getName().toUpperCase();
                if(action.equals("MANIFEST") || action.equals("APPLICATION")) {
                    Map<String,Object> tempMap = new HashMap<>();
                    for (int i = 0; i < count; i++) {
                        String name = parser.getAttributeName(i);
                        String value = parser.getAttributeValue(i);
                        value = (value == null) ? "" : value;
                        tempMap.put(name, value);
                    }
                    resultMap.put(action, tempMap);
                } else {
                    Map<String,Object> manifest = resultMap.get("MANIFEST");
                    Map<String,Object> application = resultMap.get("APPLICATION");
                    if(manifest != null && application != null) {
                        flag = false;
                    }
                    continue;
                }
            }
        }
        zipIs.close();
        is.close();
    } catch (ZipException e) {
        resultMap.put("error", getError(e));
    } catch (IOException e) {
        resultMap.put("error", getError(e));
    } catch (XmlPullParserException e) {
        resultMap.put("error", getError(e));
    }
    return resultMap;
}

private static Map<String,Object> getError(Exception e) {
    Map<String,Object> errorMap = new HashMap<>();
    errorMap.put("cause", e.getCause());
    errorMap.put("message", e.getMessage());
    errorMap.put("stack", e.getStackTrace());
    return errorMap;
}

注:以上代码,部分参考自网络。整合之后,亲测,可以正常使用。【20170903】

模拟蒲公英上传 ipa 文件

主要代码

@ResponseBody
@RequestMapping(value = "app/upload", method = RequestMethod.POST)
public Object upload(@RequestParam MultipartFile file, HttpServletRequest request) {
    Log.info("上传开始");
    int evn = 4;
    // 文件上传成功后,返回给前端的 appInfo 对象
    AppInfoModel appInfo = new AppInfoModel();
    appInfo.setEvn(evn);
    String path = request.getSession().getServletContext().getRealPath("upload");
    Date now = new Date();
    String[] extensions = file.getOriginalFilename().split("\\.");
    long time = now.getTime();
    String fileNameWithoutExtension = "app_" + evn + "_" + time;
    String fileExtension = extensions[extensions.length - 1];
    String fileName = fileNameWithoutExtension + "." + fileExtension;
    Log.info(path);
    try {
        File targetFile = new File(path, fileName);
        if(!targetFile.exists()) {
            targetFile.mkdirs();
        }
        file.transferTo(targetFile);
        InputStream is = new FileInputStream(targetFile);
        boolean isIOS = fileExtension.toLowerCase().equals("ipa");
        if (isIOS) {
              // 获取配置文件信息
            Map<String, Object> infoPlist = AppUtil.analyzeIpa(is);
            // 获取包名
            String packageName = (String) infoPlist.get("CFBundleIdentifier");
            appInfo.setBundleId(packageName);
            appInfo.setVersionCode((String) infoPlist.get("CFBundleShortVersionString"));
            // 这是个私有方法,根据包名获取特定的 app 信息,并设置 appInfo
            setupAppInfo(packageName, true, appInfo);
        } else if (fileExtension.toLowerCase().equals("apk")) {
              // 获取配置文件信息
            Map<String,Map<String,Object>> infoConfig = AppUtil.analyzeApk(is);
            Map<String, Object> manifestMap = infoConfig.get("MANIFEST");
            String packageName = (String)  manifestMap.get("package");
            appInfo.setBundleId(packageName);
            appInfo.setVersionCode((String) manifestMap.get("versionName"));
            setupAppInfo(packageName, false, appInfo);
        } else {
            Map<String, Object> map = new HashMap<>();
            map.put("code", NetError.BadRequest.getCode());
            map.put("message", "文件格式错误,请重新上传!");
            return map;
        }
        // 上传 FTP
        FTPUtil ftp = new FTPUtil(FtpHostName, FtpHostPort, FtpUserName, FtpPassword);
        String ftpPath = "/app/" + appInfo.getAppId() + "/" + appInfo.getVersionCode();
        FileInputStream in = new FileInputStream(targetFile);
        ftp.uploadFile(ftpPath, fileName, in);
        targetFile.delete();

        String url = ftpPath + "/" + fileName;
        if (isIOS) { // iOS 创建 plist 文件
            String plistFilName = fileNameWithoutExtension + ".plist";
            String plistUrl = path + "/" + plistFilName;
            // 创建 info.plist 文件
            boolean result = appUploadService.createPlist(plistUrl, nfo.getBundleId(), appInfo.getName(), appInfo.getVersionCode(), url, est.getLocalAddr(), request.getLocalPort());
            if (result == false) {
                NetError error = NetError.BadRequest;
                error.setMessage("创建Plist文件失败");
                throw new NetException(error);
            }
            File targetPlistFile = new File(path, plistFilName);
            in = new FileInputStream(targetPlistFile);
            ftp.uploadFile(ftpPath, plistFilName, in);
            url = ftpPath + "/" + plistFilName;
            targetPlistFile.delete();
        }
        Log.info("上传完成");
        final String uploadedUrl = url;
        return getResult(new HashMap<String, Object>(){{
            put("url", uploadedUrl);
            put("appInfo", appInfo);
        }});
    } catch (Exception e) {
        e.printStackTrace();
        NetError error = NetError.BadRequest;
        error.setMessage(e.toString());
        throw new NetException(error);
    }
}

Demo

demo.gif

Tips

关于 jar 包下载

  • 之前 csdn 上可以上传零积分下载的资源,现在至少是1积分,所以积分不足的同学,可以留下联系方式,私发。

关于 demo

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

推荐阅读更多精彩内容