Android – styles.xml로 커스텀 폰트 전역적용

Android Oreo에서 새로 나온 기능 중 Fonts in XML (커스텀 폰트 in XML)이 있다.

즉, 다른 리소스 (.jpg, .png 와 같은 이미지 리소스, strings.xml 같은 문자열 리소스 등) 과 같이 폰트 자체를 그 하나의 리소스로 취급, 폴더로 관리할 수 있게 하는 것이다.

font 폴더를 만들고 그 안에 ttf, otf 폰트 파일을 넣으면 R.font.goyang 로서 바로 사용할 수 있다.

파일을 더블 클릭하면 익숙한 로렘 입숨을 보여주는데, 표시되는 곳에 추가로 글자를 작성할 수 있어 언어가 제대로 표시하는지도 테스트 할 수 있다.

커스텀 폰트 를 위한 미리보기

사용법도 매우 간단하다.

<TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:fontFamily="@font/goyang"/>

또는

Typeface typeface = getResources().getFont(R.font.goyang);
textView.setTypeface(typeface);

이런 식으로, 더 이상 assets에 넣고 AssetsManager로부터 불러와서 LruCache에 넣고 캐시를 해서 적용시키는 그런 작업이 필요 없게 되었다.

지금 하는 프로젝트는 기본 앱 폰트가 노토 산스였는데, android:fontFamily 를 사용하면 스타일에 정의하는 것 만으로도 전체에 적용될 것 같았다.

0. 들어가기 앞서..

구글이 배포하는 노토 산스를 그대로 넣으면 앱의 용량이 매우 늘어나게 된다.

안 그래도 PDF 모듈 등 다소 JNI 떡칠 + protobuf 떡칠이 되어있어 앱의 용량이 기하급수적인데, 여기에 폰트까지 합치면… 생각하기도 무섭다.

그래서, 한글 웹 폰트용으로 경량화된 NotoSansKR-Hestia 의 otf 파일만 가져왔다. (실제는 한글 웹 폰트 경량화해 사용하기 에서 온 것이다.)

어디선가 파란 줄의 여신님이 벨 군~ 하면서 달려오는 것 같지만, 무시하기로 한다.

1. Noto Sans Font-Family 만들기

<?xml version="1.0" encoding="utf-8"?>
<font-family xmlns:android="http://schemas.android.com/apk/res/android"
             xmlns:app="http://schemas.android.com/apk/res-auto"
             xmlns:tools="http://schemas.android.com/tools">
    <font
        android:font="@font/noto_hestia_normal"
        android:fontStyle="normal"
        android:fontWeight="400"
        app:font="@font/noto_hestia_normal"
        app:fontStyle="normal"
        app:fontWeight="400"
        tools:ignore="UnusedAttribute"/>

    ...
</font-family>

Android Oreo 보다 낮은 버전에서도 폰트를 제대로 불러오기 위해, 별도의 app namespace를 사용했다. (구글의 설계상 API 14부터 되는 것 같다.)

android:font 에는 사용할 실제 폰트 리소스를, fontStyle에는 해당 폰트 스타일(normal 등) , fontWeight는 글꼴의 굵기이다.

적절히 이 3개 값을 설정하고 똑같은 값을 app namespace에도 대응시킨다.

이렇게 사용할 폰트를 정리하고, styles.xml을 수정하면 된다.

2. Styles.xml 적용하기

적용시킬 위젯은 총 5개로, 텍스트뷰, 버튼, 에디트 텍스트, 라디오 버튼, 체크 박스 등이다.

각각의 DeviceDefault 테마를 상속받아 fontFamily를 적용시킨다.

<style name="NotoSansTextViewStyle" parent="@android:style/Widget.DeviceDefault.TextView">
        <item name="android:fontFamily">@font/noto_serif</item>
</style>

<style name="NotoSansButtonStyle" parent="@android:style/Widget.DeviceDefault.Button.Borderless">
        <item name="android:fontFamily">@font/noto_serif</item>
</style>

<style name="NotoSansEditTextStyle" parent="@android:style/Widget.DeviceDefault.EditText">
        <item name="android:fontFamily">@font/noto_serif</item>
</style>

<style name="NotoSansRadioButtonStyle" parent="@android:style/Widget.DeviceDefault.CompoundButton.RadioButton">
        <item name="android:fontFamily">@font/noto_serif</item>
</style>

<style name="NotoSansCheckboxStyle" parent="@android:style/Widget.DeviceDefault.CompoundButton.CheckBox">
        <item name="android:fontFamily">@font/noto_serif</item>
</style>

3. 테마 Styles.xml 적용하기

<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.NoActionBar">
 <!-- Customize your theme here. -->
 <item name="colorPrimary">@color/colorPrimary</item>
 <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
 <item name="colorAccent">@color/colorAccent</item>
 <item name="android:buttonStyle">@style/NotoSansButtonStyle</item>
 <item name="android:editTextStyle">@style/NotoSansEditTextStyle</item>
 <item name="android:radioButtonStyle">@style/NotoSansRadioButtonStyle</item>
 <item name="android:checkboxStyle">@style/NotoSansCheckboxStyle</item>
 <item name="android:textViewStyle">@style/NotoSansTextViewStyle</item>
</style>

각각 buttonStyle, editTextStyle, radioButtonStyle, checkboxStyle, textviewStyle 에 위에 만든 스타일을 대응시킨다.

마무리

이거 하나 때문에 프로젝트 시작 단계에서 베타 버전의 Android Oreo를 타겟으로 하고 개발했던 것은 숨겨진 진실이기도 하다.

Kotlin – Unzip Zip files in Android

코틀린으로 파일을 효율적으로 읽는 방법 에 이은 두번째 글이다. (나름대로의 코드 골프라고도 명할 수 있겠다.)

지금 개발하는 앱에서는

  • 서버에서 Zip 다운로드 주소를 줌
  • 앱에서 주소를 받아 다운로드 하고 특정 경로에 압축을 풀음
  • 기능 실행

이런 구조로 작동해야 되는 것이 많다.

압축 파일을 풀어서 파일 하나하나를 작성해야 하는 만큼, BufferedOutputStream 을 이용할 수 밖에 없는데, 코틀린으로 어떻게 쓸 수 있는지 알아보기 위해서 코드를 몇 개 정도 작성했다.

결과적으론 32줄 (원본) > 20줄 (1차) > 16줄 (2차) > 14줄 (3차) > 9줄 (4차) 정도로 줄어들었다.

0. 의존 라이브러리

ZipFile 등이 안드로이드 API 24 (7.0) 이상부터 사용할 수 있어서, Apache ant를 추가했다.

compile group: 'org.apache.ant', name: 'ant', version: '1.10.1'

1. 자바

public void unzip(String filePath, String targetPath) {
   try {
       ZipFile zipFile = new ZipFile(filePath, "euc-kr");
       Enumeration e = zipFile.getEntries();
       while (e.hasMoreElements()) {
           ZipEntry entry = (ZipEntry) e.nextElement();
           File destinationFilePath = new File(targetPath, entry.getName());

           destinationFilePath.getParentFile().mkdirs();

           if (entry.isDirectory()) 
               continue;
                
           BufferedInputStream bis = new BufferedInputStream(zipFile.getInputStream(entry));

           int b;
           byte buffer[] = new byte[1024];
           FileOutputStream fos = new FileOutputStream(destinationFilePath);
           BufferedOutputStream bos = new BufferedOutputStream(fos, 1024);

           while ((b = bis.read(buffer, 0, 1024)) != -1) {
               bos.write(buffer, 0, b);
           }

           bos.flush();
           bos.close();
           bis.close();
       }
   } catch (Exception e) {
       e.printStackTrace();
   }
}

32줄. 매우 길다.

파라미터로는 filePath는 압축을 풀을 zip 경로, targetPath는 압축을 풀 경로이다.

2. use 사용

우선적으론 코틀린으로 파일을 효율적으로 읽는 방법 에서도 활용한 .use 메소드를 적극 활용해서 줄여보려고 한다.

private fun unzip(zipFile: File, targetPath: String) {
    val zip = ZipFile(zipFile, "euc-kr")
    val enumeration = zip.entries
    while (enumeration.hasMoreElements()) {
        val entry = enumeration.nextElement()
        val destFilePath = File(targetPath, entry.name)
        destFilePath.parentFile.mkdirs()

        if (entry.isDirectory)
            continue

        val bufferedIs = BufferedInputStream(zip.getInputStream(entry))

        bufferedIs.use {
            destFilePath.outputStream().buffered(1024).use { bos ->
                bufferedIs.copyTo(bos)
            }
        }
    }
}

그 외 while 문 등 기본적인 구조는 유지했다.

File.outputStream()란 확장 메소드로 FileOutputStream 을 꺼내고, FileOutputStream.buffered(size) 로 BufferedOutputStream을 꺼낸 다음 최종적으로 use 메소드를 사용해서 바이트 어레이를 copyTo 하는 것이다.

3. forEach ?

Enumeration 이란 클래스는 객체들의 집합에서 각각의 객체를 하나에 하나씩 처리할 수 있도록 돕는 인터페이스로, 두 개의 메소드를 제공한다.

package java.util;

public interface Enumeration<E> {
    boolean hasMoreElements(); // 다음 객체가 있는지

    E nextElement(); // 다음 객체 리턴
}

얼핏 보면 iterator랑 비슷한것 같다.

그리고 코틀린에서는 이 클래스의 확장 메소드로 Enumeration<E>.toList() 를 제공하는데, 열거된 순서로 Enumeration을 가져와 List 형태로 만들어준다.

그러면 언제나와 같이, 확장 메소드를 하나 더 만들어서 기능을 wrap 해보자.

private inline fun <E> Enumeration<E>.forEach(action: (E) -> Unit) {
    for (element in this.toList()) action(element)
}

기본적으로 Enumeration.toList() 한 결과값을 for-each 하여 한 객체마다 action을 invoke 하는 코드이다.

성능 이슈를 줄이기 위하여 Inline Function 을 사용했고,  Invoke operator를 사용했다.

위 확장 메소드를 사용해 만든 코드가 이쪽이다.

private fun unzip(zipFile: File, targetPath: String) {
    val zip = ZipFile(zipFile, "euc-kr")
    zip.entries.forEach {
        val destFilePath = File(targetPath, it.name)
        destFilePath.parentFile.mkdirs()

        if (!it.isDirectory) {
            val bufferedIs = BufferedInputStream(zip.getInputStream(it))
            bufferedIs.use {
                destFilePath.outputStream().buffered(1024).use { bos ->
                    bufferedIs.copyTo(bos)
                }
            }
        }
    }
}

forEach를 사용함으로서 implicit parameter (통칭 it) 가 사용이 가능해졌기 때문에,  val enumeration = zip.entries 와 val entry = enumeration.nextElement() 를 없애고 it를 사용했다.

4. 한번 더 줄여보자.

bufferedIs 를 굳이 선언하지 않아도 통합이 가능할 것 같다.

private fun unzip(zipFile: File, targetPath: String) {
    val zip = ZipFile(zipFile, "euc-kr")
    zip.entries.forEach {
        val destFilePath = File(targetPath, it.name)
        destFilePath.parentFile.mkdirs()

        if (!it.isDirectory)
            BufferedInputStream(zip.getInputStream(it)).use { bis ->
                destFilePath.outputStream().buffered(1024).use { bos -> 
                    bis.copyTo(bos)
                }
            }
    }
}

위 3번의 enumeration 처럼 변수 선언을 없애고 bis 라는 변수를 내부에서 선언함으로서 bis.copyTo(bos) 형태가 되었다.

그런데 잠깐만, 마지막의 buffered(1024).use 도 역시 하나의 파라미터니까 it를 사용할 수 있지 않을까?

… 결과적으로 나온 코드가 이쪽이다.

private fun unzip(zipFile: File, targetPath: String) {
    val zip = ZipFile(zipFile, "euc-kr")
    zip.entries.forEach {
        val destFilePath = File(targetPath, it.name)
        destFilePath.parentFile.mkdirs()

        if (!it.isDirectory)
            BufferedInputStream(zip.getInputStream(it)).use { bis ->
                destFilePath.outputStream().buffered(1024).use { bis.copyTo(it) }
            }
    }
}

폴더가 항상 만들어져 있다는 가정을 하면, destFilePath.parentFile.mkdirs() 를 제거하고 통합할 수 있다.

다행히 다른 클래스에서 경로를 반환할 때 항시 mkdirs를 거치도록 작성해놨기 때문에, 과감하게 제거한다.

private fun unzip(zipFile: File, targetPath: String) {
    val zip = ZipFile(zipFile, "euc-kr")
    zip.entries.forEach {
        if (!it.isDirectory)
            BufferedInputStream(zip.getInputStream(it)).use { bis ->
                File(targetPath, it.name).outputStream().buffered(1024).use { bis.copyTo(it) }
            }
    }
}

5. (부가) Semantic highlighting

마지막 나온 코드는 이 it가 이 it인지, 저 it가 저 it인지 구별이 쉽지 않다.

Kotlin 1.1.3 부터는 Semantic highlighting 를 제공하는데, 선택산 색상대로 변수에 색상을 지정하는 것이다.

첫번째 it와 두번째 it는 각각 ZipEntry를, 세번째 it는 BufferedOutputStream 을 가리키는 것 같다.

마무리

ZipFile도 줄일 수 있으면 더욱 좋았겠지만, 저것 만큼은 어떻게든 줄일 방법이 떠오르지 않았다.

아무튼, 32줄에서 9줄이면 약 72%의 감축을 이뤄낸 셈이 되었다.

안드로이드 – 악성 앱을 막기 위한 새로운 권한, REQUEST_INSTALL_PACKAGE

보통 안드로이드 기기의 정보를 탈취하기 위해서는 앱을 설치하고나서 그 앱이 기기 정보나 개인정보를 무단 수집, 서버로 보내는 형태를 많이 사용합니다.

이는 안드로이드의 장점 중 하나인 앱의 자유로운 설치가 단점으로 돌아온 대표적인 예시 중 하나입니다.

그래서 2017년 초에 구글은 Google Play Protect 를 사용하여 구글 플레이 스토어 전역에 있는 PHA(Potentially Harmful App, 잠재적 악성 앱) 의 비율을 줄여나갔습니다.

하지만 안드로이드 앱을 다운받을 수 있는 곳은 플레이 스토어 단 한 곳이 아닙니다. 2016년 연례 안드로이드 보안 보고서 의 일부에는 플레이 스토어에 출시되어 있는 의 비율은 줄었지만 비공식 마켓 등에서 배포되는 PHA는 여전히 남아있습니다.

그래서 구글은 안드로이드 8.0부터 플레이 스토어 이외의 곳에서 다운받은 앱을 설치하도록 하는 행동을 실행할 경우, 새로운 권한을 사용하도록 하였습니다.

그것이 바로 REQEUST_INSTALL_PACAKGE 권한입니다.

 

아래는 안드로이드 개발자 블로그 ‘Making it safer to get apps on Android O’ 글의 일부 번역입니다.


 

안드로이드 Oreo (8.0) 부터 새롭게 나온 ‘Install Unknown apps’ 권한은 알 수 없는 소스에서 앱을 안전하게 설치할 수 있도록 도와줍니다.

왼쪽: (안드로이드 8.0 전) 시스템 업데이트로 가장한 PHA
오른쪽: (안드로이드 8.0 이상) PHA가 설치되기 전 먼저 권한을 부여해야 합니다.

이 권한은 다른 런타임 권한처럼 설치할 때 권한을 띄우며 유저가 해당 소스를 사용해 앱을 설치할 수 있기 전에 권한을 획득하도록 합니다.

사용자가 안드로이드 Oreo 또는 이상의 버전을 사용중일 때, 악성 다운로더는 사용자에게 어떠한 앱을 설치하도록 속일 수 없습니다.

 

유저는 언제든지 알 수없는 앱 설치를 허용 한 앱을 검토 할 수 있습니다. 권한 부여 프로세스를 쉽게하기 위해 앱 개발자는 사용자를 권한 화면으로 안내 할 수 있습니다.

이 새로운 권한은 유저에게 신뢰할 수 있는 소스에 대한 투명성, 제어 및 간소화된 프로세스를 제공합니다.

설정 앱에서는 유저가 신뢰할 수 있는 소스의 리스트를 표시하며, 유저는 언제나 이 소스에 대해 권한을 취소할 수 있습니다.


사용하려면?

이 권한을 사용하려면, 우선 앱의 targetSdkVersion을 26 이상으로 하고, 매니페스트에 REQUEST_INSTALL_PACKAGE를 선언하면 됩니다.

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

권한을 사용하지 않은 앱은 다른 앱을 설치할 수 없게 됩니다.

개발자는 앱을 설치할 수 있도록 설정하는 화면을 ACTION_MANAGE_UNKNOWN_APP_SOURCES 인텐트 액션을 통해 표시할 수 있으며,  PackageManager.canRequestPackageInstalls() 를 사용하여 권한 상태를 판단할 수 있습니다.

마무리

이 권한을 사용해서 적어도 사용자를 속이고 설치하는 앱의 갯수는 줄어들거라 봅니다.

물론 언제나 사용자가 허용한다면 통하지는 않겠지만 적어도 사용자에게 이 앱은 알 수 없는 곳에서 다운받은 것이니 조심하라 라는 표시를 할 수 있으면 충분하다고 생각됩니다.