캡틴 APK 톺아보기

스토어에 등록된 다양한 앱들을 보며, "와! 이 앱은 코드를 어떻게 구현했을까?" 궁금했던적이 있었나요?

필자의 경우엔 "앱 소스코드", "앱 디컴파일" 등 키워드로 구글 검색을 해가며 하나씩 호기심을 해결했고, 마침내 iOS 앱은 IPA (Ios app store PAckage) [1] 를 맥북에 다운로드 해서 파인더로 패키지 내용 보기를 하면 사용된 아이콘, 이미지 등의 리소스 정도는 확인이 가능하고, Android 앱은 툴을 사용해 APK (Android app PacKage) [2] 추출 및 디컴파일 방식을 통해 대부분은 소스코드 까지 살펴볼 수 있다는걸 알게 되었습니다.

여러 시행착오를 겪으며 필자는 편의를 도모코자 딱 필요한 기능만으로 구성된 개인 툴을 만들어 사용하고 있는데, 최근 이를 "캡틴 APK" 란 앱으로 플레이 스토어에 배포했고, 개발자 사회에 콘트리뷰션? 하고자 소스코드 공개 (GitHub) 및 "APK 소스코드 분석하기", "캡틴 APK 소스코드 톺아보기", 총 2편의  글을 연재하여 이러한 주제에 관심있는 개발자, 특히 Android 앱 개발자에게 짧은 지식으로나마 도움을 드리고자 합니다.

연재글 중 두번째로 이번 글에서는 필자가 개발한 "캡틴 APK" 의 주요 비즈니스 로직 및 기술적인 이슈를 주제로 다루겠습니다.

주요 비즈니스 로직 및 이슈들

필자도 여전히 왕성한 호기심을 가진 개발자 중 한명으로 "코드를 어떻게 구현했을까?" 궁금해서거나 때로는 업무상, JAR (Java ARchive) [3] 로 배포되는 라이브러리나 Android 앱을 디컴파일 해보곤 했는데, Android 앱의 경우에 APK 추출은 "APK Extractor" [4], "AndroidManifest.xml" 및 사용된 리소스를 분석하기 위해서는 "APK Parser" [5] 등 용도별로 따로 나누어 사용하는게 번거로와서, 이 2개의 앱에서 딱 필요한 기능만을 제공하는 "캡틴 APK" [6] 를 개발하게 되었습니다.

"캡틴 APK" 가 제공하는 기능은 단순해서 주요 비즈니스 로직은 2개, 이를 구현하며 다루게 될 주요 기술적인 이슈는 3개 정도이며 다음과 같습니다.

주요한 비즈니스 로직은 딱 2개!

1. APK 목록 가져오기

"캡틴 APK" 를 실행하면 다음과 같이, 스마트폰에 기본 설치된 앱 및 사용자가 플레이 스토어에서 직접 다운로드 받아 설치한 앱 등 전체 목록을 보여 줍니다.

캡틴 APK 실행화면

"captain" 등 으로 직접 키 입력 또는 편의상 음성 검색을 통해 간추려 볼 수 있습니다.

캡틴 APK 검색화면

2. APK  및 리소스 추출하기

앱 목록에서 우측의 SAVE 버튼을 터치하면 다음과 같이, 해당 앱의 APK, DEX (Dalvik EXecutable) [7] 및 "AndroidManifest.xml", "res/*" 등 사용된 리소스를 스마트폰 내장 스토리지의 다운로드 경로에 추출합니다.

캡틴 APK 다운로드

SAVE 완료 후, 스마트폰의 파일 탐색기로 다운로드 경로를 확인하면 "app.apk", "app.dex", "meta.zip" 3개의 파일이 생성되어 있고, 이는 각각 APK, DEX 및 개발시 사용된 리소스를 ZIP [8] 으로 압축한 파일입니다.

파일 탐색기 파일목록

다루게 될 주요 기술적인 이슈는 딱 3개!

1. ZipFile, FileUtils 다루기

APK, DEX 및"AndroidManifest.xml", "res/*" 등 "리소스를 추출해서 저장하기" 알고리즘에서 사용합니다.

2. Thread & AsyncTask 다루기

스마트폰에 설치된 "앱 전체 목록 가져오기", "리소스를 추출해서 저장하기" 등 다소 긴 처리 시간을 요구하는 알고리즘을 UI 버벅거림 없이 매끄럽게 실행하고자 할 때 사용합니다.

3. Notification 다루기

"리소스를 추출해서 저장하기" 에서 사용되며, Android Oreo (8.0) [9] 버전 이상에서는 채널 설정 등의 활용 방식이 다소 변경 되어서 이 부분에 대하여 익숙하지 않은 개발자에게는 참고가 될 겁니다.

APK 목록 가져오기

스마트폰에 설치된 전체 APK 목록을 가져오는 비즈니스 로직은 "캡틴 APK" 앱을 실행하면 MainActivity.onCreate () 하단의 glanceMedia () 메소드로 부터 시작 됩니다.

MainActivity.glanceMedia () 에서는 AlloData.glanceMedia () 를 다음과 같이 설정하여 실행합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
AlloData data = new AlloData ();
data.setListener (new AlloData.Listener ()
{
    public void onLoaded (HashMap <String, Media> medias)
    {
        Allo.i ("onLoaded () @" + getClass ());
        glanceMediaDone (medias);
    }
    public void onFailed ()
    {
        Allo.i ("onFailed () @" + getClass ());
    }
});
data.glanceMedia (getApplicationContext ());

AlloData.glanceMedia () 에서는 다음과 같이 Thread [10] 를 설정하여 AlloData.glanceMediaHandler () 를 실행합니다.

1
2
3
4
5
6
7
new Thread ()
{
    public void run ()
    {
        glanceMediaHandler (context);
    }
}.start ();

AlloData.glanceMediaHandler () 에서는 다음과 같이 스마트폰에 설치된 앱 전체 목록을 가져옵니다. Android 에서 기본 제공하는 Context.getPackageManager ().getInstalledPackages (int flags) [11] 메소드를 사용하며, 관련 도큐먼트를 보면 다양한 플래그 활용법을 볼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
final HashMap <String, Media> medias = new HashMap ();
List <PackageInfo> infos = context.getPackageManager ().getInstalledPackages (0);
for (int i = 0; i < infos.size (); i++)
{
    PackageInfo info = infos.get (i);
    (... ...)
    Drawable icon = info.applicationInfo.loadIcon (context.getPackageManager ());
    String name = info.applicationInfo.loadLabel (context.getPackageManager ()).toString ();
    String bundle = info.applicationInfo.packageName;
    String source = info.applicationInfo.sourceDir;
    File file = new File (source); int size = (int) file.length ();
    String version = context.getPackageManager ().getPackageInfo (bundle, 0).versionName;
    Media media = new Media ();
    media.setIcon (icon);
    media.setName (name);
    media.setSize (size);
    media.setBundle (bundle);
    media.setSource (source);
    media.setVersion (version);
    media.setCategory (category);
    medias.put (name.toLowerCase (), media);
}

필요시 시스템 앱 여부 체크를 위해 필자가 구현해 놓은 AlloData.isSystemPackage () 메소드를 참고하여 활용 할 수도 있습니다.

AlloData.glanceMediaHandler () 메소드 하단에서는 메인 (Original Thread) 이외의 Thread 에서 UI 관련 코드를 실행하면 발생하는 오류 방지를 위해 다음과 같이 사용합니다

ThreadException: Only the original thread that created a view hierarchy can touch its views.

1
2
3
4
5
6
7
8
(new Handler (Looper.getMainLooper ())).postDelayed (new Runnable ()
{
    @Override
    public void run ()
    {
        if (null != listener) listener.onLoaded (medias);
    }
}, 10);

이렇게 "다소 긴 처리 시간을 요구하는 알고리즘" 즉, 설치된 앱 전체 목록을 가져오는 모든 처리를 마치고, 사용자에게 보여줄 앱 목록 업데이트 등 UI 관련 처리를 위해 Listener 와 연동하는 코드에만 적용하는 방식이 경험상 더 쿨한게 작동하는거 같습니다.

APK 및 리소스 추출하기

목록에서 선택한 Android 앱의 APK, DEX 및 리소스를 추출해서 저장하는 비즈니스 로직은 SAVE 버튼을 누르면 실행되는 MainActivity.touchSave () 메소드로 부터 시작 됩니다.

1
2
3
4
5
String name = media.getBundle ();
String bundle = media.getBundle ();
String version = media.getVersion ();
(... ...)
(new SaveAsyncTask ()).executeOnExecutor (AsyncTask.THREAD_POOL_EXECUTOR, media);

비즈니스 로직을 담고 있는 SaveAsyncTask 클래스는 사용자가 동시에 여러개를 저장하는 경우에도 매끄럽게 처리 될 수 있도록, 또한 개별 저장하기 단위별 Notification [12] 이 가능하도록 AsyncTask [13] 로 구현했습니다.

먼저 SaveAsyncTask.doInBackground () 메소드에서는"Apache common-io" FileUtils [14] 를 사용하여 APK 를 다운로드 경로에 복사합니다. 물론 기본 클래스로 직접 구현을 해도 무방하지만, "Apache common-io" 에서는 FileUtils.readLines (final File file), FileUtils.lineIterator (final File file), FileUtils.readFileToString (final File file), FilenameUtils.normalize (final String name) 등 이미 검증된 여러 유용한 코드를 제공합니다.

1
2
3
4
5
6
7
8
9
10
11
String name = media.getName ();
String bundle = media.getBundle ();
String source = media.getSource ();
String version = media.getVersion ();
String savePath = Environment.DIRECTORY_DOWNLOADS;
File saveRoot = Environment.getExternalStoragePublicDirectory (savePath);
File origin = new File (source);
(... ...)
String saveAs = bundle + "_" + version + "_" + name;
saveAs = saveAs.replaceAll ("[:\\\\/*?|<>]", " ").replaceAll ("\\s+", "_");
File target = new File (saveRoot, saveAs + ".apk"); FileUtils.copyFile (origin , target);

그리고 ZipFile [15] 를 사용하여 APK 안에 있는 DEX 및 리소스를 추출합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
String name = media.getName ();
String bundle = media.getBundle ();
String source = media.getSource ();
String version = media.getVersion ();
String savePath = Environment.DIRECTORY_DOWNLOADS;
File saveRoot = Environment.getExternalStoragePublicDirectory (savePath);
(... ...)
ZipFile zipFile = new ZipFile (source);
(... ...)
String message = "";
Enumeration <? extends ZipEntry> entries = zipFile.entries ();
FileOutputStream fileOS = new FileOutputStream (saveRoot + System.getProperty ("file.separator") + saveAs + "_meta.txt");
while (entries.hasMoreElements ())
{
    ZipEntry entry = entries.nextElement ();
    String each = entry.getName (); each = each.toLowerCase ();
    if (each.startsWith (Const.RESOURCE.toLowerCase ()) || each.equals (Const.CLASSDEX.toLowerCase ()) || each.equals (Const.MANIFEST.toLowerCase ()))
    {
        count++;
        int inter = 1; if (10 < total) inter = (int) (total / 10);
        if (10 < total && (0 == (int) (count % inter))) { sendNotification (name, total, count); }
        message += "(" + count + ") " + entry.getName () + System.getProperty ("line.separator");
    }
}
fileOS.write (message.getBytes ()); fileOS.flush (); fileOS.close ();

단, 소스코드 공개용 "캡틴 APK" 라이트 버전에서는 리소스 목록을 추려 TXT 파일로 저장합니다. 이유는 필자가 플레이 스토어에 "캡틴 APK" 를 등록시 "설치된 다른 앱의 로고 이미지 및 리소스를 다운로드 하는 로직이 해당 앱에 대한 저작권 침해가 발생 할 수 있다" 는 등의 피드백을 받기도 했었기에, 가능한 문제의 소지를 없애기 위함이며, 필요시 GitHub 하단 Thanks to 섹션의 "APKParser" 프로젝트 링크를 참고하면 관련 알고리즘을 직접 쉽게 구현 할 수 있을 겁니다.

이제 마지막으로 살펴 볼 Notification 의 경우, Android Oreo 버전 "Build.VERSION_CODES.O" 이상에서는 NotificationChannel 을 명시적으로 설정 후 사용하는 방식으로 처리해주었고, 여러개를 동시에 저장시 개별 진행 상태를 확인 할 수 있도록 시스템 시간 정보를 Notification ID 로 활용 했습니다.

AsyncTask 를 베이스로 구현한 SaveAsyncTask 클래스에 다음과 같이 Notification 관련 멤버 변수를 선언하고, SaveAsyncTask.initNotification () 메소드에서 초기화 설정을 합니다.

1
2
3
4
5
6
7
private final class SaveAsyncTask extends AsyncTask <Media, Void, String>
{
    private NotificationManager notificationManager;
    private NotificationCompat.Builder notificationBuilder;
    private final int notificationId = (int) System.currentTimeMillis ();
    (... ...)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
String channelId = getApplicationContext ().getPackageName (); String channelName = channelId;
if (Build.VERSION_CODES.O <= Build.VERSION.SDK_INT)
{
    NotificationChannel channel = new NotificationChannel (channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT);
    channel.setShowBadge (false);
    notificationManager = ((NotificationManager) getSystemService (Context.NOTIFICATION_SERVICE));
    notificationManager.createNotificationChannel (channel);
    notificationBuilder = new NotificationCompat.Builder (getBaseContext (), channelId)
            .setSmallIcon (R.drawable.ic_launcher_round)
            .setContentTitle (title)
            .setContentIntent (PendingIntent.getActivity (getBaseContext (), 0, (new Intent ()).addFlags (Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_ACTIVITY_SINGLE_TOP), PendingIntent.FLAG_UPDATE_CURRENT));
}
else
{
    notificationManager = (NotificationManager) getSystemService (Context.NOTIFICATION_SERVICE);
    notificationBuilder = new NotificationCompat.Builder (getBaseContext (), channelId)
            .setSmallIcon (R.drawable.ic_launcher_round)
            .setContentTitle (title)
            .setContentIntent (PendingIntent.getActivity (getBaseContext (), 0, new Intent (), PendingIntent.FLAG_UPDATE_CURRENT));
}

그리고 SaveAsyncTask.sendNotification () 메소드에서는 "APK 및 리소스 추출하기" 진행 상황에 따른 상태를 Notification 합니다.

1
2
3
4
5
6
7
int progress = 1;
String message = getString (R.string.message_download);
if (0 < total) { if (progress < (count * 100 / total)) progress = (count * 100 / total); }
if (0 == count) { message += " (files " + total + ")"; } else { message += " (files " + count + "/" + total + ")"; }
notificationBuilder.setProgress (100, progress, false);
notificationBuilder.setContentText (message); 
notificationManager.notify (notificationId, notificationBuilder.build ());

추가로 앱 아이콘에 Notification 관련하여 달리는 Badge 및 Notification 메시지 목록의 Clear 설정을 위하여 MainActivity.onCreate () 메소드에서 MainActivity.clearNotification () 메소드를 실행합니다.

1
((NotificationManager) getSystemService (Context.NOTIFICATION_SERVICE)).cancelAll ();

글을 마치며

이번 글에서는 필자가 개발한 앱 "캡틴 APK" 의 주요 비즈니스 로직 및 기술적인 이슈를 톺아봤습니다.

글을 마치며 몇마디 여담을 하자면필자는 올해 대장정의 끝을 마무리한 마블의 어벤져스 시리즈에서 캡틴 마블이나, 캡틴 아메리카의 지구를 벗어나 우주를 넘나들며 그려지는 대단한 스토리 보다는그들이 입고 있는 유니폼의 엔티크한 빨강과 파랑 컬러 컨셉에 주목합니다.

동백꽃 필 무렵 동네 아주머니들

최근 종영한 드라마 "동백꽃 필 무렵" 에서 동네 아주머니들

출처 : https://1boon.kakao.com/benter/dbtmi

마치 평소 동네에서 츄리닝에 삼선 슬리퍼를 끌고 다니는번화가에서는 마주치고 싶지 않은 캐릭터지만 누군가 도움이 필요한 순간에는 언제나 아낌없이 콘트리뷰션 해주는 인간미 넘쳐나는 누나, 오빠, 아줌마와 같은 휴머니즘을 담은 컬러 컨셉이 아닐까 생각하기에마침 "음양의 조화가 이루어진 행복한 삶" 을 의미하는 태극 문양에 사용되기도 합니다.

캡틴 APK”  탈북 개발자가 만들었는가싶을 정도로, 어쩌다 보니 북조선 느낌나는 로고 엔티크한 컬러 컨셉이 되었지만 전혀 상관없고서두에 언급한 대로 이번에 연재한  2편의 글에서 다룬 주제인 "APK 에 포함된 소스코드 분석하기" 에 관심있는 개발자에게 조금이나마 도움이 된다면 좋겠습니다.

이제 올 한해 마지막 남은 12월, 모두 유종의  거두시고필자는 2020년 경자년 보다 유익한 주제로 다시 POPIT 에서 만날  있도록 하겠습니다

각주 및 참고 자료

1. IPA, iOS App Store Package

https://en.wikipedia.org/wiki/.ipa

(글을 쓰며 Apple 기술지원쪽에 다시 확인해 본 바로는, 앱 보안강화의 이유로 iOS, iTunes, MacOS 등 최근 버전에서는 IPA 추출이 보다 어렵게 되었고, 다음에 기회가 된다면 포스팅 주제로 다뤄 보겠습니다)

2. APK, Android App Package

https://en.wikipedia.org/wiki/Android_application_package

(APK 에서 추출된 "res/*" 리소스를 살펴보면 개발자가 추가한 것외, 빌드시 추가되는 기본 리소스가 상당히 많습니다. 최근에는 최적화 등의 이유로 AAB, Android App Bundle 방식으로 배포를 권장하는 추세이니 아래 링크도 함께 참고 바랍니다)

https://developer.android.com/platform/technology/app-bundle

https://support.google.com/googleplay/android-developer/answer/9006925

3. JAR, Java Archive

https://en.wikipedia.org/wiki/JAR_(file_format)

4. APK Extractor

https://play.google.com/store/apps/details?id=com.ext.ui

5. APK Parser

https://play.google.com/store/apps/details?id=com.gmail.heagoo.apkeditor.parser

6. Captain APK

https://cafewill.com

https://github.com/cafewill/captain-apk

7. DEX, Dalvik Executable

https://en.wikipedia.org/wiki/Dalvik_(software)

https://source.android.com/devices/tech/dalvik/dex-format

8. ZIP

https://en.wikipedia.org/wiki/Zip_(file_format)

9. Android Oreo

https://en.wikipedia.org/wiki/Android_Oreo

https://en.wikipedia.org/wiki/Android_version_history

10. Thread

https://developer.android.com/reference/java/lang/Thread

https://developer.android.com/reference/android/os/HandlerThread

11. PackageManager

https://developer.android.com/reference/android/content/pm/PackageManager

https://developer.android.com/reference/android/content/pm/PackageManager.html#getInstalledPackages(int)

12. Notification

https://developer.android.com/reference/android/app/Notification

https://developer.android.com/reference/android/service/notification/package-summary

13. AsyncTask

https://developer.android.com/reference/android/os/AsyncTask

14. FileUtils

https://github.com/apache/commons-io

https://github.com/apache/commons-io/blob/master/src/main/java/org/apache/commons/io/FileUtils.java

15. ZipFile

https://docs.oracle.com/javase/8/docs/api/index.html?java/util/zip/ZipFile.html


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.