android apk变相打包技术。

目前国内的应用市场多如牛毛,虽然方便了应用的推广,但是想统计各个渠道的推广效果就比较费劲了,通常的做法就是打渠道包。在应用的Androidmainfest.xml文件中添加meta-data数据,例如渠道。应用中读取该渠道值,来区分不同的推广渠道。

        现有的打包工具基本原理如下:

友盟(https://github.com/umeng/umeng-muti-channel-build-tool)
    打包:解压apk文件 -> 替换AndroidManifest.xml中的meta-data -> 压缩apk文件 -> 签名
    读取渠道号:直接通过Android的API读取meta-data
    特点:需要解压缩、压缩、重签名耗费时间较多,重签名会导致apk包在运行时有兼容性问题;
美团(http://tech.meituan.com/mt-apk-packaging.html)
    打包:解压apk文件 -> META-INF目录下创建一个以渠道号为文件名的空文件 -> 压缩apk文件
    读取渠道号:解压已安装的data/app/<package>.apk -> 读取以渠道号为文件名的空文件的文件名
    特点:比友盟高效点,只有解压缩和压缩,没有签名,兼容性也比较好,但是读取渠道号需要解压缩apk,速度比较慢;
都是要解压缩和压缩apk文件,耗时较多速度比较慢。

之前在github看到了其他人分享出来的“快速打包技术”,其实就是利用zip可以添加comment的数据结构特性,

在文件的末尾加入需要的数据,不用解压缩zip文件,当应用被打开时读取这个数据来区分不同的渠道。

不需要对apk文件解压缩和重新签名即可完成多渠道自动打包,高效速度快,无兼容性问题;

代码如下:

/*
 * 
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */


import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.security.Key;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.zip.ZipFile;

import javax.crypto.Cipher;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.DESKeySpec;
import javax.crypto.spec.IvParameterSpec;

/**
 * 多渠道打包工具;<br/>
 * 利用的是Zip文件“可以添加comment(注释)”的数据结构特点,在文件的末尾写入任意数据,而不用重新解压zip文件(apk文件就是zip文件格式);<br/>
 * 创建时间: 2014-12-16 18:56:29
 * @author zhangguojun
 * @version 1.1
 * @since JDK1.6 Android2.2
 */
public class MCPTool {
	/**
	 * 数据结构体的签名标记
	 */
	private static final String SIG = "MCPT";
	/**
	 * 数据结构的版本号
	 */
	private static final String VERSION_1_1 = "1.1";
	/**
	 * 数据编码格式
	 */
	private static final String CHARSET_NAME = "UTF-8";
	/**
	 * 加密用的IvParameterSpec参数
	 */
	private static final byte[] IV = new byte[] { 1, 3, 1, 4, 5, 2, 0, 1 };

	/**
	 * 写入数据
	 * @param path 文件路径
	 * @param content 写入的内容
	 * @param password 加密密钥
	 * @throws Exception
	 */
	private static void write(File path, String content, String password) throws Exception {
		write(path, content.getBytes(CHARSET_NAME), password);
	}

	/**
	 * 写入数据(如:渠道号)
	 * @param path 文件路径
	 * @param content 写入的内容
	 * @param password 加密密钥
	 * @throws Exception
	 */
	private static void write(File path, byte[] content, String password) throws Exception {
		ZipFile zipFile = new ZipFile(path);
		boolean isIncludeComment = zipFile.getComment() != null;
		zipFile.close();
		if (isIncludeComment) {
			throw new IllegalStateException("Zip comment is exists, Repeated write is not recommended.");
		}
		
		boolean isEncrypt = password != null && password.length() > 0;
		byte[] bytesContent = isEncrypt ? encrypt(password, content) : content;
		byte[] bytesVersion = VERSION_1_1.getBytes(CHARSET_NAME);
		ByteArrayOutputStream baos = new ByteArrayOutputStream();
		baos.write(bytesContent); // 写入内容;
		baos.write(short2Stream((short) bytesContent.length)); // 写入内容长度;
		baos.write(isEncrypt ? 1 : 0); // 写入是否加密标示;
		baos.write(bytesVersion); // 写入版本号;
		baos.write(short2Stream((short) bytesVersion.length)); // 写入版本号长度;
		baos.write(SIG.getBytes(CHARSET_NAME)); // 写入SIG标记;
		byte[] data = baos.toByteArray();
		baos.close();
		if (data.length > Short.MAX_VALUE) {
			throw new IllegalStateException("Zip comment length > 32767.");
		}
		
		// Zip文件末尾数据结构:{@see java.util.zip.ZipOutputStream.writeEND}
		RandomAccessFile raf = new RandomAccessFile(path, "rw");
		raf.seek(path.length() - 2); // comment长度是short类型
		raf.write(short2Stream((short) data.length)); // 重新写入comment长度,注意Android apk文件使用的是ByteOrder.LITTLE_ENDIAN(小端序);
		raf.write(data);
		raf.close();
	}
	
	/**
	 * 读取数据
	 * @param path 文件路径
	 * @param password 解密密钥
	 * @return 被该工具写入的数据(如:渠道号)
	 * @throws Exception
	 */
	private static byte[] read(File path, String password) throws Exception {
		byte[] bytesContent = null;
		byte[] bytesMagic = SIG.getBytes(CHARSET_NAME);
		byte[] bytes = new byte[bytesMagic.length];
		RandomAccessFile raf = new RandomAccessFile(path, "r");
		Object[] versions = getVersion(raf);
		long index = Long.parseLong (versions[0].toString());
		String version = (String) versions[1];
		if (VERSION_1_1.equals(version)) {
			bytes = new byte[1];
			index -= bytes.length;
			readFully(raf, index, bytes); // 读取内容长度;
			boolean isEncrypt = bytes[0] == 1;

			bytes = new byte[2];
			index -= bytes.length;
			readFully(raf, index, bytes); // 读取内容长度;
			int lengthContent = stream2Short(bytes, 0);

			bytesContent = new byte[lengthContent];
			index -= lengthContent;
			readFully(raf, index, bytesContent); // 读取内容;

			if (isEncrypt && password != null && password.length() > 0) {
				bytesContent = decrypt(password, bytesContent);
			}
		}
		raf.close();
		return bytesContent;
	}
	
	/**
	 * 读取数据结构的版本号
	 * @param raf RandomAccessFile
	 * @return 数组对象,[0] randomAccessFile.seek的index,[1] 数据结构的版本号
	 * @throws IOException
	 */
	private static Object[] getVersion(RandomAccessFile raf) throws IOException {
		String version = null;
		byte[] bytesMagic = SIG.getBytes(CHARSET_NAME);
		byte[] bytes = new byte[bytesMagic.length];
		long index = raf.length();
		index -= bytesMagic.length;
		readFully(raf, index, bytes); // 读取SIG标记;
		if (Arrays.equals(bytes, bytesMagic)) {
			bytes = new byte[2];
			index -= bytes.length;
			readFully(raf, index, bytes); // 读取版本号长度;
			int lengthVersion = stream2Short(bytes, 0);
			index -= lengthVersion;
			byte[] bytesVersion = new byte[lengthVersion];
			readFully(raf, index, bytesVersion); // 读取内容;
			version = new String(bytesVersion, CHARSET_NAME);
		}
		return new Object[] { index, version };
	}

	/**
	 * RandomAccessFile seek and readFully
	 * @param raf
	 * @param index
	 * @param buffer
	 * @throws IOException
	 */
	private static void readFully(RandomAccessFile raf, long index, byte[] buffer) throws IOException {
		raf.seek(index);
		raf.readFully(buffer);
	}

	/**
	 * 读取数据(如:渠道号)
	 * @param path 文件路径
	 * @param password 解密密钥
	 * @return 被该工具写入的数据(如:渠道号)
	 */
	public static String readContent(File path, String password) {
		try {
			return new String(read(path, password), CHARSET_NAME);
		} catch (Exception ignore) {
		}
		return null;
	}
	
	/**
	 * Android平台读取渠道号
	 * @param context Android中的android.content.Context对象
	 * @param mcptoolPassword mcptool解密密钥
	 * @param defValue 读取不到时用该值作为默认值
	 * @return
	 */
	public static String getChannelId(Object context, String mcptoolPassword, String defValue) {
		String content = MCPTool.readContent(new File(getPackageCodePath(context)), mcptoolPassword);
		return content == null || content.length() == 0 ? defValue : content;
	}

	/**
	 * 获取已安装apk文件的存储路径(这里使用反射,因为MCPTool项目本身不需要导入Android的运行库)
	 * @param context Android中的Context对象
	 * @return
	 */
	private static String getPackageCodePath(Object context) {
		try {
			return (String) context.getClass().getMethod("getPackageCodePath").invoke(context);
		} catch (Exception ignore) {
		}
		return null;
	}

	/**
	 * 加密
	 * @param password
	 * @param content
	 * @return
	 * @throws Exception
	 */
	private static byte[] encrypt(String password, byte[] content) throws Exception {
		return cipher(Cipher.ENCRYPT_MODE, password, content);
	}

	/**
	 * 解密
	 * @param password
	 * @param content
	 * @return
	 * @throws Exception
	 */
	private static byte[] decrypt(String password, byte[] content) throws Exception {
		return cipher(Cipher.DECRYPT_MODE, password, content);
	}

	/**
	 * 加解密
	 * @param cipherMode
	 * @param password
	 * @param content
	 * @return
	 * @throws Exception
	 */
	private static byte[] cipher(int cipherMode, String password, byte[] content) throws Exception {
		DESKeySpec dks = new DESKeySpec(password.getBytes(CHARSET_NAME));
		SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DES");
		Key secretKey = keyFactory.generateSecret(dks);
		Cipher cipher = Cipher.getInstance("DES/CBC/PKCS5Padding");
		IvParameterSpec spec = new IvParameterSpec(IV);
		cipher.init(cipherMode, secretKey, spec);
		return cipher.doFinal(content);
	}

	/**
	 * short转换成字节数组(小端序)
	 * @param data
	 * @return
	 */
	private static short stream2Short(byte[] stream, int offset) {
        ByteBuffer buffer = ByteBuffer.allocate(2);
        buffer.order(ByteOrder.LITTLE_ENDIAN);
        buffer.put(stream[offset]);
        buffer.put(stream[offset + 1]);
        return buffer.getShort(0);
    }

	/**
	 * 字节数组转换成short(小端序)
	 * @param stream
	 * @param offset
	 * @return
	 */
	private static byte[] short2Stream(short data) {
        ByteBuffer buffer = ByteBuffer.allocate(2);
        buffer.order(ByteOrder.LITTLE_ENDIAN);
        buffer.putShort(data);
        buffer.flip();
        return buffer.array();
    }

	/**
	 * nio高速拷贝文件
	 * @param source
	 * @param target
	 * @return
	 * @throws IOException
	 */
	private static boolean nioTransferCopy(File source, File target) throws IOException {
		FileChannel in = null;
		FileChannel out = null;
		FileInputStream inStream = null;
		FileOutputStream outStream = null;
		try {
			File parent = target.getParentFile();
			if (!parent.exists()) {
				parent.mkdirs();
			}
			inStream = new FileInputStream(source);
			outStream = new FileOutputStream(target);
			in = inStream.getChannel();
			out = outStream.getChannel();
			return in.transferTo(0, in.size(), out) == in.size();
		} finally {
			close(inStream);
			close(in);
			close(outStream);
			close(out);
		}
	}

	/**
	 * 关闭数据流
	 * @param closeable
	 */
	private static void close(Closeable closeable) {
		if (closeable != null) {
			try {
				closeable.close();
			} catch (IOException ignore) {
			}
		}
	}
	
//	/**
//	 * 简单测试代码段
//	 * @param args
//	 * @throws Exception
//	 */
//	public static void test() throws Exception {
//		String content = "abc";
//		String password = "123456789";
//		System.out.println("content = " + content);
//		String contentE = new String(encrypt(password, content.getBytes(CHARSET_NAME)), CHARSET_NAME);
//		System.out.println("contentE = " + contentE);
//		String contentD = new String(decrypt(password, contentE.getBytes(CHARSET_NAME)), CHARSET_NAME);
//		System.out.println("contentD = " + contentD);
//		
//	}

	/**
	 * jar命令行的入口方法
	 * @param args
	 * @throws Exception
	 */
	public static void main(String[] args) throws Exception {
//		args = "-path D:/111.apk -outdir D:/111/ -contents googleplay;m360; -password 12345678".split(" ");
//		args = "-version".split(" ");
//		args = "-path D:/111_m360.apk -password 12345678".split(" ");

		long time = System.currentTimeMillis();
		String cmdPath  = "-path";
		String cmdOutdir  = "-outdir";
		String cmdContents  = "-contents";
		String cmdPassword  = "-password";
		String cmdVersion  = "-version";
		String help = "用法:java -jar MCPTool.jar [" + cmdPath + "] [arg0] [" + cmdOutdir + "] [arg1] [" + cmdContents + "] [arg2] [" + cmdPassword + "] [arg3]"
				+ "\n" + cmdPath + "		APK文件路径"
				+ "\n" + cmdOutdir + "		输出路径(可选),默认输出到APK文件同一级目录"
				+ "\n" + cmdContents + "	写入内容集合,多个内容之间用“;”分割(linux平台请在“;”前加“\\”转义符),如:googleplay;m360; 当没有" + cmdContents + "”参数时输出已有文件中的contents"
				+ "\n" + cmdPassword + "	加密密钥(可选),长度8位以上,如果没有该参数,不加密"
				+ "\n" + cmdVersion + "	显示MCPTool版本号"
				+ "\n例如:"
				+ "\n写入:java -jar MCPTool.jar -path D:/test.apk -outdir ./ -contents googleplay;m360; -password 12345678"
				+ "\n读取:java -jar MCPTool.jar -path D:/test.apk -password 12345678";
		
		if (args.length == 0 || args[0] == null || args[0].trim().length() == 0) {
			System.out.println(help);
		} else {
			if (args.length > 0) {
				if (args.length == 1 && cmdVersion.equals(args[0])) {
					System.out.println("version: " + VERSION_1_1);
				} else {
					Map<String, String> argsMap = new LinkedHashMap<String, String>();
					for (int i = 0; i < args.length; i += 2) {
						if (i + 1 < args.length) {
							if (args[i + 1].startsWith("-")) {
								throw new IllegalStateException("args is error, help: \n" + help);
							} else {
								argsMap.put(args[i], args[i + 1]);
							}
						}
					}
					System.out.println("argsMap = " + argsMap);
					File path = argsMap.containsKey(cmdPath) ? new File(argsMap.get(cmdPath)) : null;
					String parent = path == null? null : (path.getParent() == null ? "./" : path.getParent());
					File outdir = parent == null ? null : new File(argsMap.containsKey(cmdOutdir) ? argsMap.get(cmdOutdir) : parent);
					String[] contents = argsMap.containsKey(cmdContents) ? argsMap.get(cmdContents).split(";") : null;
					String password = argsMap.get(cmdPassword);
					if (path != null) {
						System.out.println("path: " + path);
						System.out.println("outdir: " + outdir);
						if (contents != null && contents.length > 0) {
							System.out.println("contents: " + Arrays.toString(contents));
						}
						System.out.println("password: " + password);
						if (contents == null || contents.length == 0) { // 读取数据;
							System.out.println("content: " + readContent(path, password));
						} else { // 写入数据;
							String fileName = path.getName();
							int dot = fileName.lastIndexOf(".");
							String prefix = fileName.substring(0, dot);
							String suffix = fileName.substring(dot);
							for (String content : contents) {
								File target = new File(outdir, prefix + "_" + content + suffix);
								if (nioTransferCopy(path, target)) {
									write(target, content, password);
								}
							}
						}
					}
				}
			}
		}
		System.out.println("time:" + (System.currentTimeMillis() - time));
	}
	
	
}
使用说明

命令行使用说明:
用法:java -jar MCPTool.jar [-path] [arg] [-contents] [arg] [-password] [arg]
-path       APK文件路径
-outdir     输出路径(可选),默认输出到APK文件同一目录
-contents   写入内容集合,多个内容之间用“;”分割,如:googleplay;m360; 当没有“-contents”参数时输出已有文件中的content
-password   加密密钥(可选),长度8位以上,如果没有该参数,不加密
-version    显示版本号
例如:
写入:java -jar MCPTool.jar -path D:/test.apk -outdir ./ -contents googleplay;m360; -password 12345678
读取:java -jar MCPTool.jar -path D:/test.apk -password 12345678

利用这个功能我们可以做很多事情,

例如,1)结合分享功能做自动添加好友,A用户点击了分享, 链接里含有A的id,B通过该链接下载应用时服务端将A的id写入apk中,B打开应用时,直接添加A作为好友。

   2)一些无良市场利用了该技术(不打脸了),用户搜索应用时,下载下来的可能是某个市场的应用,当你安装 打开之后,你会发现,该市场自动跳转到了你搜索的应用的下载页,其实也是利用了这个技术。

欢迎讨论其他的应用使用场景。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值