Skip to content

Commit f5f784f

Browse files
committed
apk compare tool
1 parent 563446d commit f5f784f

2 files changed

Lines changed: 387 additions & 0 deletions

File tree

apkcompare/.goreleaser.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Build customization
2+
project_name: apkcompare
3+
builds:
4+
- main: main.go
5+
binary: apkcompare
6+
goos:
7+
- windows
8+
- darwin
9+
- linux
10+
goarch:
11+
- amd64
12+
# Archive customization
13+
archive:
14+
format: tar.gz
15+
replacements:
16+
amd64: 64-bit
17+
darwin: macOS

apkcompare/main.go

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
/**
2+
* __________________________________________________________________________________________
3+
* __ ____ _ _ __ __ _ _ ____ __ ____ _____
4+
* / | / ) / ,' / ) / ) / /| / ) / | / ) / '
5+
* ---/__|---/____/---/_.'--------/--------/----/---/| /-|---/____/---/__|---/___ /---/__----
6+
* / | / / \ / / / / |/ | / / | / | /
7+
* _/____|_/________/____\______(____/___(____/___/__/___|_/________/____|_/_____|__/____ ___
8+
*
9+
*
10+
* Simple APK Analyzer. Can be used to compare size changes between apks. Compoents inside APK
11+
* are group by major types, such as:classesN.dex, assets/*, resources.arsc, res/*, lib/*,
12+
* META-INF/*, and other files include by third-part sdk.
13+
*/
14+
package main
15+
16+
import (
17+
"archive/zip"
18+
"bytes"
19+
"compress/gzip"
20+
"crypto/sha1"
21+
"encoding/hex"
22+
"encoding/json"
23+
"fmt"
24+
"io"
25+
"io/ioutil"
26+
"os"
27+
"os/user"
28+
"path/filepath"
29+
"regexp"
30+
"strconv"
31+
"strings"
32+
"text/template"
33+
34+
"github.com/360EntSecGroup-Skylar/excelize"
35+
"github.com/fatih/color"
36+
flag "github.com/ogier/pflag"
37+
)
38+
39+
// APKSizeInfo represents the major compoents whose size will be analyzed
40+
type APKSizeInfo struct {
41+
Name string `json:"name"` // apk file name
42+
Sha1 string `json:"sha1"` // hex string of sha1
43+
Dex uint64 `json:"dex"` // classesN.dex
44+
ResourcesArsc uint64 `jspn:"arsc"` // resources.arsc
45+
Res uint64 `json:"res"` // res/*
46+
Assets uint64 `json:"assets"` // assets/*
47+
Lib uint64 `json:"lib"` // lib/*
48+
MetaINF uint64 `json:"meta"` // META-INF/*
49+
Others uint64 `json:"others"` // any other files
50+
Total uint64 `json:"total"` // raw size
51+
Download uint64 `json:"download"` // download size compressed by gzip -9
52+
}
53+
54+
func (*APKSizeInfo) formatSize(size uint64) float32 {
55+
return float32(size) / 1024 / 1024
56+
}
57+
58+
func parseApk(apkFile string) *APKSizeInfo {
59+
r, e := zip.OpenReader(apkFile)
60+
_debug("file: %s", apkFile)
61+
if e != nil {
62+
_debug("Failed open apk: %v", e)
63+
return nil
64+
}
65+
apkSizeInfo := APKSizeInfo{
66+
Name: filepath.Base(apkFile),
67+
Dex: 0,
68+
ResourcesArsc: 0,
69+
Res: 0,
70+
Assets: 0,
71+
Lib: 0,
72+
MetaINF: 0,
73+
Others: 0,
74+
}
75+
for _, f := range r.File {
76+
if f.Name == "resources.arsc" {
77+
apkSizeInfo.ResourcesArsc = f.FileHeader.CompressedSize64
78+
} else if strings.Contains(f.Name, "classes.") {
79+
apkSizeInfo.Dex += f.FileHeader.CompressedSize64
80+
} else if strings.Contains(f.Name, "res/") {
81+
apkSizeInfo.Res += f.FileHeader.CompressedSize64
82+
} else if strings.Contains(f.Name, "assets/") {
83+
apkSizeInfo.Assets += f.FileHeader.CompressedSize64
84+
} else if strings.Contains(f.Name, "lib/") {
85+
apkSizeInfo.Lib += f.FileHeader.CompressedSize64
86+
} else if strings.Contains(f.Name, "META-INF/") {
87+
apkSizeInfo.MetaINF += f.FileHeader.CompressedSize64
88+
} else {
89+
apkSizeInfo.Others += f.FileHeader.CompressedSize64
90+
}
91+
}
92+
compressedApk, _ := gzipFile(apkFile, apkFile+".gz")
93+
defer os.Remove(compressedApk.Name())
94+
apk, _ := os.Open(apkFile)
95+
s, _ := apk.Stat()
96+
apkSizeInfo.Total = uint64(s.Size())
97+
if e != nil {
98+
_debug("Failed to calculate download size: %v", e)
99+
apkSizeInfo.Download = uint64(s.Size())
100+
} else {
101+
size, _ := compressedApk.Stat()
102+
apkSizeInfo.Download = uint64(size.Size())
103+
}
104+
105+
apkSizeInfo.Sha1 = sha14File(apkFile)
106+
_info("Apk Size, dex=%d, arsc=%d, res=%d, assets=%d, lib=%d, metainfo=%d, others=%d, total=%d, download=%d",
107+
apkSizeInfo.Dex, apkSizeInfo.ResourcesArsc, apkSizeInfo.Res, apkSizeInfo.Assets, apkSizeInfo.Lib, apkSizeInfo.MetaINF, apkSizeInfo.Others, apkSizeInfo.Total, apkSizeInfo.Download)
108+
return &apkSizeInfo
109+
}
110+
111+
func gzipFile(name string, dst string) (*os.File, error) {
112+
os.Remove(dst)
113+
apk, _ := os.Open(name)
114+
temp, _ := os.Create(dst)
115+
zw, _ := gzip.NewWriterLevel(temp, gzip.BestCompression)
116+
defer zw.Flush()
117+
defer zw.Close()
118+
io.Copy(zw, apk)
119+
return temp, nil
120+
}
121+
122+
func sha14File(name string) (hash string) {
123+
f, err := os.Open(name)
124+
if err != nil {
125+
_debug("invalid file: %v", err)
126+
}
127+
defer f.Close()
128+
129+
h := sha1.New()
130+
if _, err := io.Copy(h, f); err != nil {
131+
_debug("calculate sha1 failed: %v", err)
132+
}
133+
hash = hex.EncodeToString(h.Sum(nil))
134+
135+
_debug("%s", hash)
136+
return hash
137+
}
138+
139+
var apkPath string
140+
var output string
141+
var humanReadableSize bool
142+
var showLog bool
143+
var format string
144+
145+
func init() {
146+
flag.StringVarP(&apkPath, "path", "p", "./", "Path or Directory of the target apk file")
147+
flag.StringVarP(&output, "output", "o", "output.xlsx", "Output excel name")
148+
flag.BoolVar(&humanReadableSize, "readable", true, "output the size in MB, instead of bytes count")
149+
flag.BoolVar(&showLog, "log", false, "Print all debug log")
150+
flag.StringVar(&format, "format", "xlsx", "foramt to output: json, xlsx(which is default)")
151+
}
152+
153+
func main() {
154+
flag.Parse()
155+
if flag.NFlag() == 0 {
156+
printUsage()
157+
os.Exit(0)
158+
}
159+
// safty check
160+
if strings.HasPrefix(apkPath, "~") {
161+
_debug("calcute apk path: %s", apkPath)
162+
basePath, _ := user.Current()
163+
apkPath = filepath.Join(basePath.HomeDir, strings.Replace(apkPath, "~", "", 1))
164+
}
165+
// set to the same folder
166+
if !strings.HasPrefix(output, "~") && !strings.HasPrefix(output, "/") {
167+
_debug("calculate abs of output: %s", output)
168+
output = filepath.Join(filepath.Dir(apkPath), output)
169+
}
170+
if extension := filepath.Ext(output); extension == "" {
171+
_debug("append extension: %s", format)
172+
output += "." + format
173+
}
174+
_info("path: %s \toutput: %s", apkPath, output)
175+
176+
var apks []APKSizeInfo
177+
stat, _ := os.Stat(apkPath)
178+
if stat.IsDir() {
179+
subFiles, e := ioutil.ReadDir(apkPath)
180+
if e != nil {
181+
_error("Read directory failed %v", e)
182+
os.Exit(1)
183+
}
184+
for _, f := range subFiles {
185+
apkAbs := filepath.Join(apkPath, f.Name())
186+
if strings.HasSuffix(f.Name(), ".apk") {
187+
apk := parseApk(apkAbs)
188+
if apk == nil {
189+
continue
190+
}
191+
apks = append(apks, *apk)
192+
}
193+
}
194+
} else if strings.HasSuffix(apkPath, ".apk") {
195+
apk := parseApk(apkPath)
196+
if apk != nil {
197+
apks = append(apks, *apk)
198+
}
199+
}
200+
201+
var success bool
202+
if format == "xlsx" {
203+
success = exportXLSX(apks, output)
204+
} else if format == "json" {
205+
success = exportJSON(apks, output)
206+
}
207+
if success {
208+
_info("analyzing completed!!")
209+
} else {
210+
_error("analyzing failed!!")
211+
}
212+
}
213+
214+
/**
215+
* export apk size information into excel with table and chart
216+
*/
217+
func exportXLSX(apks []APKSizeInfo, dst string) bool {
218+
categories := map[string]string{
219+
"A1": "Version",
220+
"B1": "SHA1",
221+
"C1": "download",
222+
"D1": "raw",
223+
"E1": "dex",
224+
"F1": "arsc",
225+
"G1": "assets",
226+
"H1": "res",
227+
"I1": "lib",
228+
"J1": "META-INF",
229+
"K1": "others",
230+
}
231+
values := map[string]interface{}{}
232+
// 58client_v8.3.1_58585858_20180404_13.54_release.apk => v8.3.1
233+
regx, _ := regexp.Compile("_([v.0-9]+)_")
234+
for i, apkSizeInfo := range apks {
235+
rows := strconv.Itoa(i + 2)
236+
index := regx.FindStringIndex(apkSizeInfo.Name)
237+
var version string
238+
if index != nil {
239+
version = apkSizeInfo.Name[index[0]+1 : index[1]-1]
240+
_debug("mathed version is %s", version)
241+
} else {
242+
version = apkSizeInfo.Name
243+
}
244+
values["A"+rows] = version
245+
values["B"+rows] = apkSizeInfo.Sha1
246+
values["C"+rows] = apkSizeInfo.formatSize(apkSizeInfo.Download)
247+
values["D"+rows] = apkSizeInfo.formatSize(apkSizeInfo.Total)
248+
values["E"+rows] = apkSizeInfo.formatSize(apkSizeInfo.Dex)
249+
values["F"+rows] = apkSizeInfo.formatSize(apkSizeInfo.ResourcesArsc)
250+
values["G"+rows] = apkSizeInfo.formatSize(apkSizeInfo.Assets)
251+
values["H"+rows] = apkSizeInfo.formatSize(apkSizeInfo.Res)
252+
values["I"+rows] = apkSizeInfo.formatSize(apkSizeInfo.Lib)
253+
values["J"+rows] = apkSizeInfo.formatSize(apkSizeInfo.MetaINF)
254+
values["K"+rows] = apkSizeInfo.formatSize(apkSizeInfo.Others)
255+
}
256+
xlsx := excelize.NewFile()
257+
for k, v := range categories {
258+
xlsx.SetCellValue("Sheet1", k, v)
259+
}
260+
for k, v := range values {
261+
xlsx.SetCellValue("Sheet1", k, v)
262+
}
263+
lineChartTemplate := `{
264+
"type": "line",
265+
"series": [
266+
{
267+
"name": "Sheet1!$C$1",
268+
"categories": "Sheet1!$A$2:$A${{.size}}",
269+
"values": "Sheet1!$C$2:$C${{.size}}"
270+
},
271+
{
272+
"name": "Sheet1!$D$1",
273+
"categories": "Sheet1!$A$2:$A${{.size}}",
274+
"values": "Sheet1!$D$2:$D${{.size}}"
275+
}
276+
],
277+
"title": {
278+
"name": "APK Size Line"
279+
}
280+
}`
281+
t, _ := template.New("temp").Parse(lineChartTemplate)
282+
buf := new(bytes.Buffer)
283+
t.Execute(buf, map[string]interface{}{"size": len(apks) + 1})
284+
chartJSON := buf.String()
285+
_debug("line chart:\n\n\t%s", chartJSON)
286+
xlsx.AddChart("Sheet1", "A"+strconv.Itoa(len(apks)+3), chartJSON)
287+
288+
funcs := template.FuncMap{
289+
"customMethod": func(i int, l int) string {
290+
index := strconv.Itoa(i + 2)
291+
item := "{\"name\": \"Sheet1!$A$" + index + "\"," +
292+
"\"categories\": \"Sheet1!$C$1:$K$1\"," +
293+
"\"values\": \"Sheet1!$C$" + index + ":$K$" + index + "\"}"
294+
if i == l-1 {
295+
return item
296+
}
297+
return item + ","
298+
},
299+
}
300+
301+
col3DChartTemplate := `{
302+
"type": "col3DClustered",
303+
"series": [
304+
{{range $key, $value := .apkInfoSize}}
305+
{{ customMethod $key $.size}}
306+
{{end}}
307+
],
308+
"title": {
309+
"name": "APK Version Details"
310+
}
311+
}`
312+
buf = new(bytes.Buffer)
313+
t, _ = template.New("3D").Funcs(funcs).Parse(col3DChartTemplate)
314+
t.Execute(buf, map[string]interface{}{"apkInfoSize": apks, "size": len(apks)})
315+
col3DChartJSON := buf.String()
316+
_debug("column chart\n\n\t%s", col3DChartJSON)
317+
xlsx.AddChart("Sheet1", "M1", col3DChartJSON)
318+
319+
err := xlsx.SaveAs(dst)
320+
if err != nil {
321+
_error("Save xlsx failed, $v", err)
322+
return false
323+
}
324+
return true
325+
}
326+
327+
/**
328+
* export apk size information into json format
329+
*/
330+
func exportJSON(apks []APKSizeInfo, dst string) bool {
331+
jsonBytes, e := json.Marshal(apks)
332+
if e != nil {
333+
return false
334+
}
335+
jsonText := string(jsonBytes)
336+
_debug(jsonText)
337+
if jsonFile, e := os.Create(dst); e == nil {
338+
jsonFile.WriteString(jsonText)
339+
jsonFile.Close()
340+
} else {
341+
return false
342+
}
343+
return true
344+
}
345+
346+
func printUsage() {
347+
fmt.Printf("Usage: %s [options]", os.Args[0])
348+
fmt.Println("\nOptions:")
349+
flag.PrintDefaults()
350+
fmt.Println("\nExample:")
351+
color.Green("\tapkcompare [-p directory] [-o output.xlsx]")
352+
}
353+
354+
func tag(format string) string {
355+
return "[ApkCompare] " + format
356+
}
357+
358+
func _info(format string, params ...interface{}) {
359+
color.Green(tag(format), params...)
360+
}
361+
362+
func _error(format string, params ...interface{}) {
363+
color.Red(tag(format), params...)
364+
}
365+
366+
func _debug(format string, params ...interface{}) {
367+
if showLog {
368+
color.Yellow(tag(format), params...)
369+
}
370+
}

0 commit comments

Comments
 (0)