The author is a developer of MIUI system application group. Before releasing APP, there was only one channel, APP store, so only one APK was needed for APP store. However, an external version of the application has been developed recently, which has multiple download channels such as advertisement and push. In order to count daily activity and conversion rate of each channel, multi-channel packaging is required. At present, VasDolly of Tencent and Walle of Meituan have realized multi-channel rapid packaging under V2 signature. However, the project did not want to introduce third-party libraries, so it chose to develop independently.
First, multi-channel packaging status quo
1. Android comes with multi-channel packaging
Add a meta-data tag under the application tag of the Manifest to indicate that this is a placeholder for channel number information.
<meta-data
android:name="channel"
android:value="${APP_CHANNEL}"/>
Copy the code
Gradle is configured as follows: The Release module in buildTypes specifies that the release in signingConfigs is used to package the configuration, and productFlavors defines shop and Push options.
android {
signingConfigs {
release {
storeFile file('/Users/...... /default.jks')
storePassword '123456'
keyAlias 'default'
keyPassword '123456'
v1SigningEnabled true
v2SigningEnabled true
}
}
buildTypes {
debug {
......
}
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}
productFlavors{
shop {
manifestPlaceholders = [APP_CHANNEL:"shop"]}push {
manifestPlaceholders = [APP_CHANNEL:"push"]}}}Copy the code
After configuration, run the./gradlew assembleRelease command on the terminal to generate apK packages for all channels in the app/build folder. If you only need to package a channel, such as the push channel, use the./gradlew assemblePushRelease command.
In this way, the packaging time of all channel packages is the same. For large projects, a channel package takes four or five minutes to be packaged. In the case of many channels, this packaging method is inefficient.
2. Decompress and resend signatures
To use this packaging method, an initial APK is required. After decompressing the APK, delete the previous signature information, add channel information, and then compress the signature. This packaging method is more efficient than the first method, but the compression/signature method is also time-consuming.
3. Insert information into the APK
This method inserts channel information directly into the APK file, even if there are hundreds of channels, with the initial APK, it only takes a few seconds. The key to this packaging is how APK can also be verified by Android signatures after additional information is inserted.
Let’s take a look at Android’s signature mechanism: To ensure that APK is not tampered with by third parties after release, the signature mechanism extracts a summary of the file and stores it in APK during packaging. During installation, the system extracts the summary of the file and compares it with the summary information stored in APK. The installation succeeds only when the two summaries are identical.
Android currently provides three signature technologies, V1, V2 and V3. Here is a brief introduction. For details, see [3]
① V1 signature: V1 signature is a signature technology for jars, used before Android7. All class files and resource files are abstracts when signed, and then a new mate-INF folder is created and the abstracts are stored in it. The meta-INF folder itself does not participate in signature verification. As mentioned above, the key of fast multi-channel packaging is that APK can pass the signature verification by adding additional information. Since the contents of the meta-INF folder under the V1 signature do not take part in the signature verification, it is only necessary to add a file describing the channel number under the meta-INF.
V2 signature: Android7 and above uses V2 signature technology. Compared with V1 signature technology, V2 signature has a great improvement in efficiency and security. APK with V1 signature needs to compare the summary of all class files and resource files during installation, which is inefficient. With V2 signature packaging, every 1M of the initial APK is extracted and then the summary Block is inserted into the APK. This is the APK Signing Block shown below. Therefore, during installation, the number of summaries to compare is much smaller than V1, and installation is faster.
V3 signature: compared with V2 signature, V3 signature has no essential improvement. It provides key rotation and limits the size of signature blocks.
Zip file format
APK itself is ZIP format, V2 signature principle is to insert signature block in ZIP to store summary information. The format of a ZIP file is as follows: Contains file information, central directory information, and directory information.
When parsing a ZIP file, find the information about the last directory to obtain the offset of the central directory relative to the start location. Then use the central directory to obtain the location of each file, and then parse each file.
V2 Signature mechanism Adds the signature block description summary information between the file information and the central directory. After adding the signature block, you only need to change the offset of the central directory in the directory information. The ZIP file still complies with the resolution specification.
The signature block stores information through key-value pairs, and its size is a multiple of 4096. Its data structure is shown below. In the key-value pairs of signature blocks, only the first key-value pair stores the real summary information, and the size of the key-value pair is not fixed. In order to make the size of the key-value pair meet the multiples of 4096, there are usually key-value pairs with all values of 0.
3. Insert signature information
After analyzing the ZIP file format and signature block structure, it is found that the signature block itself does not participate in signature verification. Therefore, the V2 signature mechanism can be verified by inserting channel information into the signature block.
Generally, there are key-value pairs with all values of 0 in the signature block, which are used to consolidate the size of the signature block to a multiple of 4096. You can choose to reduce the key-value pair to leave space for storing channel information, as shown below. If there are no key-value pairs with empty value in the signature block or the remaining space is not enough to store channel information, then the size of the signature block needs to be increased by 4096 bytes, but I have not encountered this situation yet.
The following details how to insert channel information into a V2/V3 signed APK.
Step 1: Check whether the current APK uses V2/V3 signature. You can locate the central directory based on the directory information at the end of the ZIP, and the signature Block is located in front of the central directory. If the first 16 bytes of the central directory are APK Sig Block 42, the APK uses V2/V3 signature.
{... zipFile =new ZipFile(apkPath)
String zipComment = zipFile.getComment()
int commentLength = 0
if(zipComment ! =null && zipComment.length() > 0) {
commentLength = zipComment.getBytes().length
}
File file = new File(apkPath)
long fileLength = file.length()
// Get the zip central directory end tag, read in little-endian mode
byte[] centralEndSignBytes = readReserveData(
file, fileLength - 22 - commentLength, 4)
int centralEndSign = ByteBuffer.wrap(centralEndSignBytes).getInt()
if(centralEndSign ! =0x06054b50) {
println("Zip central directory end mark error!!!!!!!!!!!!!!!!!")
return
}
long eoCdrLength = commentLength + 22
long eoCdrOffset = file.length() - eoCdrLength
// The offset of the central directory area is stored at the beginning of the EoCDR 16 bytes, a total of 4 bytes
long pointer = eoCdrOffset + 16
// Get the central directory offset, read in little endian mode
byte[] pointerBuffer = readReserveData(file, pointer, 4)
int centralDirectoryOffset = ByteBuffer.wrap(pointerBuffer).getInt()
// Read the string without inverting it
byte[] buffer = readDataByOffset(file, centralDirectoryOffset - 16.16)
String checkV2Signature = new String(buffer, StandardCharsets.US_ASCII)
if(! checkV2Signature.equals(SIGNATURE_MAGIC_NUMBER)) { println("Currently not using V2 signature!!!!!!!!!!!!!!!!!!!!!!!")
return
}
Copy the code
It is interesting to note that the readReserveData() method reads the data after an address and then inverts it, because numbers and IDS are stored in memory using the small-endian method. ReadDataByOffset (), on the other hand, is not inverted after reading the content, so the string is not inverted. These two methods are shown below.
byte[] readDataByOffset(File file, long offset, int length) throws Exception {
InputStream is = new FileInputStream(file)
is.skip(offset)
byte[] buffer = new byte[length]
is.read(buffer, 0, length)
is.close()
return buffer
}
byte[] readReserveData(File file, long offset, int length) throws Exception {
byte[] buffer = readDataByOffset(file, offset, length)
reserveByteArray(buffer)
return buffer
}
Copy the code
Step 2: Iterate through the key-value pair in the signature block, find the last key-value pair, and determine whether the value of this key-value pair is empty. Channel information can be inserted only when all values of the key-value pair are empty and there is enough space. The method of traversing the key-value pair and judging whether the value is empty is as follows.
/** * Check the key-value information in the signature block to find a place where the channel information can be inserted * select the address of the last key-value pair, which is usually all 0 */
def checkKeyValues(File file, long signBlockStart, long signBlockEnd) throws Exception {
long curKvOffset = signBlockStart + 8
long lastKvOffset
while (true) {
lastKvOffset = curKvOffset
byte[] kvSizeBytes = readReserveData(file, curKvOffset, 8)
long kvSize = ByteBuffer.wrap(kvSizeBytes).getLong()
byte[] idBuffer = readReserveData(file, curKvOffset + 8.4)
int id = ByteBuffer.wrap(idBuffer).getInt()
// CHANNEL_KV_ID is the key of channel number information. If it exists, channel information has been inserted previously
if (id == CHANNEL_KV_ID) {
int channelSize = (int) (kvSize - 4)
byte[] channelBytes = readDataByOffset(file, curKvOffset + 12, channelSize)
String channelString = new String(channelBytes, StandardCharsets.US_ASCII)
println("channelString: " + channelString)
return 0
}
curKvOffset = curKvOffset + 8 + kvSize
if (curKvOffset >= signBlockEnd) {
break}}return lastKvOffset
}
/** * Check whether the value of a certain KV is null. If it is null, insert your own information */
boolean checkIfSingleKvEmpty(File file, long offset) throws Exception {
boolean result = true
byte[] kvSizeBytes = readReserveData(file, offset, 8)
long kvSize = ByteBuffer.wrap(kvSizeBytes).getLong()
byte[] bytes = readDataByOffset(file, offset + 12, (int) (kvSize - 4))
for (byte b : bytes) {
if(b ! =0) {
result = false
break}}return result
}
Copy the code
Step 3: Modify the size of the empty key-value pair and insert a new key-value pair storage channel information as follows, where the insertOrOverrideBytes() method is used to override the original data writes, as described in Chapter 5.
/** * Insert channel information in signature block *@paramLastKvOffset Address offset of the last KV in the signature block. Signature information * needs to be inserted into this KV@paramSignBlockEnd End address of the signature block */
def insertChannelInfo(String channel, File file, String filePath,
long lastKvOffset, long signBlockEnd) throws Exception {
byte[] channelBytes = channel.getBytes()
byte[] channelInfo = buildKeyValue(CHANNEL_KV_ID, channelBytes)
byte[] lastKvSize = readReserveData(file, lastKvOffset, 8)
long size = ByteBuffer.wrap(lastKvSize).getLong()
long newSize = size - channelInfo.length
byte[] newLastKvSizeBytes = toLittleEndianBytes(newSize, 8)
insertOrOverrideBytes(filePath, lastKvOffset, newLastKvSizeBytes, true)
insertOrOverrideBytes(filePath, signBlockEnd - channelInfo.length, channelInfo, true)}/** * builds the key-value byte array to insert the signature block */
static byte[] buildKeyValue(int key, byte[] value) {
byte[] keyBytes = toLittleEndianBytes(key, 4)
long kvSize = 4 + value.length
byte[] kvSizeBytes = toLittleEndianBytes(kvSize, 8)
byte[] result = new byte[8 + 4 + value.length]
System.arraycopy(kvSizeBytes, 0, result, 0.8)
System.arraycopy(keyBytes, 0, result, 8.4)
System.arraycopy(value, 0, result, 12, value.length)
return result
}
/** * Convert a number to bytes in small-endian mode *@paramSize Is the number 4 bytes or 8 bytes */
static byte[] toLittleEndianBytes(long num, int size) {
byte[] result = new byte[size]
long t = num
for (int i = size - 1; i >= 0; i--) {
result[i] = (byte) (t % 256)
t /= 256
}
// Since it is small endian mode, we need to invert the result
reserveByteArray(result)
return result
}
Copy the code
Iv. Project integration
Here’s how to integrate multi-channel packaging into your project. You can describe all the channel numbers you need using the flavor.properties file. Gradlew assembleRelease (); gradlew assembleRelease (); gradlew assembleRelease (); gradlew assembleRelease (
Android projects are built using Gradle and can package multiple channels into a Gradle task named assembleFlavor. Because multichannel packaging requires the output of the assembleRelease command as the initial APK, the assembleFlavor task relies on the assembleRelease task. Then encapsulate the multi-channel packaging logic into flavor.gradle file and provide a packaged method for assembleFlavor to call. The build.gradle(:app) file is shown below.
apply plugin: 'com.android.application'
apply from : "flavor.gradle" // The current gradle file depends on flavor. Gradle.task assembleFlavor {
The assembleFlavor task relies on the assembleRelease task
dependsOn(":app:assembleRelease")
doLast {
// The assembleFlavor task actually calls the assembleFlavorApk method in flavor.gradle
assembleFlavorApk()
}
}
Copy the code
Run the./gradlew assembleFlavor command on the command line to generate the corresponding channel packages in the configuration file in the build folder.
Five, Demo source code
Gradle uses some Groovy features, but Java is fully compatible with Groovy. You can write in Java as well. Welcome to my project star ~
Six, reference
- Android dynamically writes information to APK
- In-depth understanding of Android Gradle
- Android | mountain, can offend jade! An article to understand v1/v2/v3 signature mechanism