반응형

이전에 빌드가 잘 되던 프로젝트 소스에서 갑자기 제목과 같은 오류가 발생해서 주말에 몇 시간을 날렸습니다.

 

근데 원인을 찾고 보니 좀 허탈하였습니다. 요건 나중에 말씀드리고요.

 

먼저 해당 소스는 몇 달 전에 작업하다가 둔 것으로 당시에 빌드가 잘 되었습니다.

 

주말에 그 소스를 가지고 테스트할 일이 있어서 코드 변경 후 빌드했는데 갑자기 오류가 발생한 것입니다.

 

아래처럼 app:mergeDebugResources 빌드 중에 Android resource compilation failed 오류 메시지가 나오면서 빌드가 멈추었습니다.

 

 

일단 리소스쪽은 건드린 것이 없어서 이상하다고 생각하고 있었습니다.

 

코드 외에 그동안 패치된 라이브러리 아티팩트 버전을 업데이트해서 빌드한 것이지만 버전을 롤백해도 현상은 동일했습니다.

 

스택오버플로우에서 제시한 여러 방법을 적용해봐도 해결이 되지 않았습니다.

최후의 방법으로는 안드로이드 스튜디오 등 빌드환경 재설치를 염두에 두고 해결책에 대해서 계속 찾아보았습니다.

- Build 폴더 삭제 후 빌드

- 파일 메뉴의 Invalidate Cashes / Restart 실행

- SDK 버전 변경

- Build Tools 버전 변경

 

소스의 문제가 아니다?

 

이쯤 되니 소스의 문제는 아닌 것 같다는 생각이 들어서 Empty Activity로 새 프로젝트를 만들고 바로 빌드를 했는데 역시나 오류가 발생했습니다.

 

그런데, 갑자기 아래와 같은 팝업이 동시에 발생하였습니다.

 

 

 

지난 주중에 랜섬웨어 대비해서 Bitdefender 설정 변경한 것이 있었는데 이것때문에 aapt2쪽에 동작이 멈춘것 같다는 느낌이 들었습니다. 생각해보니 app:mergeDebugResources 빌드때 오류 메시지를 발생시킨 것도 Aapt2Exception이었습니다. 

 

aapt2가 보안 프로그램에 의해서 차단되었다?

 

랜섬웨어 설정 관련한 옵션에 들어가서 보니 역시나 aapt2의 접근이 차단되어 있었습니다.

  

 

위의 위젯 스위치를 On 해주고 다시 빌드를 해주니 오류없이 빌드가 되었습니다.

 

빌드 오류 해결 :)

 

제가 사용중인 Bitdefender Total Security는 랜섬웨어에 대한 보호하는 기능이 있는데 시스템이 느려질까봐 초기에는 이 기능을 사용하지 않았습니다. 최근에 랜섬웨어 대비하고자 자료 정리하면서 백업도 하고 중요한 폴더들에 대해서는 이 기능도 활성화한 건데 이걸로 인하여 빌드 오류가 발생하였습니다.

 

최초 빌드오류 이슈가 된 프로젝트 소스에는 왜 Bitdefender 팝업 발생하지 않았는지 모르겠지만, 저처럼 혹시 원인을 알 수 없는 Android resource compilation failed 오류 메시지가 발생하였다면 보안 프로그램에 의해 aapt2가 차단된 건 아닌지 점검해보세요.

반응형

며칠 전 Play 스토어에 배포된 앱의 버그 리포트를 검토 및 수정하면서 정리한 내용의 기록입니다.

 

[문제가 된 경로 및 현상]

앱에서 구글 플레이스토어의 해당 앱으로 이동하는 기능 실행 시 FC(Force Close)

 

[문제가 된 코드]

appInfoButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("market://details?id=" + getPackageName()));
startActivity(intent);
}
});

[발생한 오류 및 콜 스택]

Fatal Exception: android.content.ActivityNotFoundException

No Activity found to handle Intent { act=android.intent.action.VIEW dat=market://details?id=packageName }

 

 

[분석]

문제가 된 모델은 GI-I9500_TMMARS인데, 메이커가 TrendMicro라고 나옵니다.

TrendMicro라면 보안업체인데 느낌상 apk 검증을 위한 가상 디바이스일 것 같다는 생각이 들었고, TMMARS로 검색해보니 TrendMicro Mobile App Reputation Service인데 글자 그대로 번역하면 TrendMicro 모바일 앱 평가 서비스입니다.

 

이 서비스를 통하여 apk를 검사하면 가상 디바이스에 apk를 설치한 후에 가상 디바이스 상에서 여러 가지 검증을 하는 것으로 생각됩니다. 그 검증하는 중에 문제가 된 경로로 진입하게 되고 FC 발생한 후 버그 리포팅 된 것으로 판단됩니다.

 

다행히 실사용 기기에서 발생한 문제는 아니었지만 생각해보니 실사용 기기에서도 오류가 발생할 가능성은 있어 보였습니다.

 

발생 원인

 - 앱에서 Play 스토어로 이동하기 위해서 인텐트를 전달하지만, 단말에서 마켓(Play 스토어)이 존재하지 않는 경우에 ActivityNotFoundException의 원인이 됩니다.

 

"Play 스토어"의 경우 시스템 앱인데 존재하지 않는 것이 가능한가?

먼저 "Play 스토어"의 경우 모든 안드로이드 단말에 다 들어가는 것은 아닙니다.

"Play 스토어" 및 구글의 모바일 서비스(GMS: Google Mobile Service)를 이용하려고 하면 구글 승인을 받아야 합니다.

즉, 구글의 승인(CDD[각주:1], CTS[각주:2])을 받지 못하거나 GMS를 제거하고 출시하고자 하는 단말의 경우는 "Play 스토어"가 존재하지 않습니다.

보통 AOSP(Android Open Source Project) 단말인 경우인데, 아마존의 파이어 테블릿과 샤오미 등의 중국 단말들이 많이 존재합니다. 이런 단말에서 앱을 실행하게 되면 ActivityNotFoundException이 발생합니다.

 

[해결책]

일단 AOSP 단말과 같이 "Play 스토어"가 존재하지 않아서 발생하는 ActivityNotFoundException에 대한 try~catch 처리로 오류 회피를 해주면서 인터넷 브라우저로 Play 스토어의 해당 앱으로 연결되도록 처리해줍니다.

 

[개선 코드]

appInfoButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("market://details?id=" + getPackageName()));
try {
startActivity(intent);
} catch (ActivityNotFoundException e) {
Intent webIntent = new Intent(Intent.ACTION_VIEW);
webIntent.setData(Uri.parse("https://play.google.com/store/apps/details?id=" + getPackageName()));
if (webIntent.resolveActivity(getPackageManager()) != null) {
startActivity(webIntent);
}
}
}
});
여기까지입니다. 읽어주셔서 감사합니다.
  1. Compatibility Definition Document [본문으로]
  2. Compatiblity Test Suite [본문으로]
반응형

앱의 Android 9 Pie(이하 파이) 마이그레이션에 대해서 알아보겠습니다.

 

현재 제조사별로 플래그쉽 모델들을 필두로 파이로 출시되거나 파이로 업그레이드된 바이너리가 계속 배포되고 있습니다.

 

작년 겨울부터 적어야겠다고 생각만 하다가 주변에 조금씩 파이 폰이 보이고 나서야 갑자기 생각나서 "파이 마이그레이션"에 대해서 적고 있습니다.

 

[마이그레이션이 필요한 앱]

모든 앱이 마이그레이션이 필요한 것은 아닙니다.

파이 업그레이드된 폰에서 앱이 잘 돌아간다면 굳이 해줄 필요는 없습니다.

 

보통 제조사에서 안드로이드 OS 업그레이드를 하면 사용자 데이터와 앱은 그대로 두고 안드로이드만 상위 버전으로 바뀌게 되는 것입니다.

 

안드로이드 업그레이드가 완료되면, 프리로드되어 있는 제조사나 통신사의 앱들은 마이그레이션된 상태일 것입니다.

구글 Play에서 다운로드한 앱들 중에도 오류가 나는 것이 있고, 오류가 없는 것도 있을 것입니다.

 

오류가 없는 앱은 그냥 두면 됩니다. 마이그레이션이 필요한 앱은 안드로이드 업그레이드 이후 오류가 발생하는 앱입니다.

 

[마이그레이션 과정]

오류가 발생해서 마이그레이션이 필요한 앱은 두 가지 부류로 나눠집니다.

 

1. 파이 플랫폼의 API 사용 없이 기존 API로 오류가 수정 가능한 경우


ⓒGoogle

- 파이 이미지를 다운받아서 폰에 플래시하세요. (바이너리를 폰의 NAND 메모리에 저장함을 의미) 
 - 대상 앱이 파이 변경사항의 영향 가능성을 유의하면서 검토.
 ※ 파이 변경사항 (http://bit.ly/2JrlEtg)
  
- 파이가 적용 폰 or 에뮬레이터에 대상 앱을 설치하고 파이 변경사항에 포커스를 맞춰 검증.

- 파이 변경사항의 영향으로 오류나 동작 이상 시 해당 이슈를 수정 후 앱을 다시 빌드.
(targetSdkVersion 변경 x)

- 앱 사이닝 후 apk 게시
  (구글
Play 등록, 배포 등등) 

 

 

 

 

 

 

 

 

 

 

2. 파이 플랫폼의 API 사용해야 오류가 수정 가능한 경우 (targetSdkVersion을 28로 변경 필요하다는 의미)


ⓒGoogle

- 파이 이미지 폰에 플래시.
- 안드로이드 스튜디오 버전을 3.1이상으로 업데이트 후 파이 SDK를 다운로드.
- targetSdkVersion 28로 변경
- 대상 앱이 파이 변경사항의 영향 가능성을 유의하면서 검토. 추가로 API level 28+ 대상 파이 변경사항도 검토 (http://bit.ly/2QaYBFV)  
- 대상 앱의 파이 변경사항(API level 28+ 대상 포함) 관련 코드 수정.
- 신규 백그라운드 제한 이슈 수정
- 필요시 파이 API 신규 기능 적용
- 수정된 소스 반영 후 다시 빌드

- 파이 적용 폰으로 대상 앱 검증
- 백그라운드 제한 경로 및 파이 변경사항에 포커스를 맞춰 검증.

 

 

- 최종 업데이트 후 빌드, 검증


- 앱 사이닝 후 apk 게시
  (구글 Play 등록, 배포 등등)

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

결론

 1. 파이 단말에 앱이 잘 돌아가면 그대로 두면 됩니다.

 2. 파이 단말에 앱이 문제가 있지만, 상위 API 버전으로 변경이 필요 없이 수정 가능하면 해당 문제를 수정 후 재배포합니다.

 3. 파이 단말에 앱이 문제가 있고, 상위 API 버전으로 변경이 되어야 수정할 수 있다면, targetSdkVersion을 28로 변경 후 해당 문제를 수정 후 재배포합니다.

 

위의 1, 2의 경우 구글 Play에 배포되는 앱이 아닌 경우에 해당합니다. 구글 Play에 배포되는 앱의 경우 파이 단말에서 앱이 잘 돌아가더라도 3의 경우처럼 targetSdkVersion을 28로 변경해주셔야 합니다.

 

현재(2019년 4월 19일) 구글 Play에 신규 앱 및 업데이트 필요한 앱의 경우 targetSdkVersion은 26 이상 되어야 하지만, 2019년 8월 1일부터는 신규 앱의 경우 targetSdkVersion은 28 이상이고, 2019년 11월 1일부터는 업데이트 필요한 앱의 경우에도 targetSdkVersion을 28 이상으로 변경해주어야합니다.

 

(관련 링크: https://support.google.com/googleplay/android-developer/answer/113469?hl=ko#targetsdk)

이번 글은 여기까지입니다. 읽어주셔서 감사합니다.

 


 

반응형

앱에서 시스템 설정 변경

 

앱(3rd party app.)에서 안드로이드 설정(Setting)을 읽어오거나 변경이 필요한 경우가 있습니다.

 

여기서 설정은 아래 그림처럼 설정 앱(com.android.settings, System app.) 또는 퀵세팅 타일의 항목을 말합니다.

 

이후 "앱"이라고 언급하면 3rd party app.으로 생각하시면 됩니다.

 

 

 

하나의 항목에는 이름(Name)과 값(Value)이 짝으로 기록되어집니다.

이는 SharedPreference의 키와 값이 짝으로 기록되는 데이터 저장방식과 유사합니다.

 

예로 들면 위 그림에서 설정 앱의 밝기 자동 조절 항목의 경우 이름과 (사용할 수 있는) 값은 아래와 같이 되어 있습니다.

/**
* Control whether to enable automatic brightness mode.
*/
public static final String SCREEN_BRIGHTNESS_MODE = "screen_brightness_mode";
/**
* SCREEN_BRIGHTNESS_MODE value for manual mode.
*/
public static final int SCREEN_BRIGHTNESS_MODE_MANUAL = 0;

/**
* SCREEN_BRIGHTNESS_MODE value for automatic mode.
*/
public static final int SCREEN_BRIGHTNESS_MODE_AUTOMATIC = 1;

그리고, 이 이름과 값은 아래 경로에 저장됩니다.

 

[Android 6.0 이전] 

 /data/data/com.android.providers.settings/databases/settings.db

 

[Android 6.0 이후]

/data/system/users/0/settings_system.xml

/data/system/users/0/settings_secure.xml

/data/system/users/0/settings_global.xml

 

앱에서 위와 같은 Database나 파일에 접근하기 위해서는 Content provider를 이용해야 합니다.

당연히 설정(Settings)에서도 이를 위한 SettingsProvider라는 Native Content provider가 존재합니다. 

 

하지만, 앱에서 설정에 접근하기 위해서 주로 사용하는 방법은 SettingsProvider의 설정 항목을 쿼리해서 참조할 수 있도록 만든 Settings 클래스(android.provider.settings)를 이용하는 것입니다.

 

Settings 클래스는 SettingsProvider에 대한 접근을 단순화해주는 클래스라고 보면 됩니다.

 

Settings-SettingsProvider 관련한 자세한 설명과 분석은 나중에 기회가 되면 따로 설명해 드리고 여기서는 Settings 클래스에 대해 간략한 설명만 하도록 하겠습니다.

 

NameValueTable 클래스

- 설정 항목의 데이터(이름과 값)를 데이터베이스나 파일에 기록하는 기본 클래스입니다.

 

Global, Secure, System 클래스

 - 설정 항목의 유형에 따라서 NameValueTable 클래스를 상속받은 Global, Secure, System 클래스가 사용됩니다.

 

Global의 경우 MultiUser(Android 4.2 버전에서 도입)와 관련됩니다. MultiUser를 위한 공용 설정 데이터 접근 용도입니다.

Secure의 경우 보안 관련한 설정 데이터 접근 용도입니다.

System의 경우 시스템과 관련한 설정 데이터 접근 용도입니다.

 

참고로 System 항목이었지만 이후에 Global 또는 Secure로 이동된 항목들도 많이 있습니다.

 

설정 항목 읽기

 

value = Settings.Global|Secure|System.getInt|getLong|getFloat|getString(ContentResolver cr, String name)

 

설정 항목 유형과 데이터 종류에 따라서 여러 조합으로 만들어집니다.

 

▶ 밝기 조절 모드 구하기

int brightnessMode = Settings.System.getInt(getContentResolver(), Settings.System.SCREEN_BRIGHTNESS_MODE);

 

▶ 위치 정보 모드 구하기

int locationMode = Settings.Secure.getInt(getContentResolver(), Settings.Secure.LOCATION_MODE);

 

▶ 개발자 옵션 - 디버깅할 앱 패키지명 구하기

String debugApp = Settings.Global.getString(getContentResolver(), Settings.Global.DEBUG_APP);

 

참고로 getInt, getLong, getFloat을 사용할 때 설정값이 존재하지 않는 경우에 대한 아래와 같은 에러 핸들링이 필요합니다.

try {
locationMode = Settings.Secure.getInt(getContentResolver(), Settings.Secure.LOCATION_MODE);
} cath (Settings.SettingNotFoundException e) {
// Error handling
}

또는 설정값이 존재하지 않는 경우의 디폴트 값을 세 번째 파라미터에 미리 정해줄 수도 있습니다.

int locationMode = Settings.Secure.getInt(getContentResolver(), Settings.Secure.LOCATION_MODE, Settings.Secure.LOCATION_MODE_NONE);

 

설정 쓰기 권한

 

<android.permission.WRITE_SETTINGS>

 

설정을 읽는 것과 달리 설정에 쓰는 것에는 권한이 필요하고 제한도 따릅니다.

"설정 쓰기" 권한 획득 없이 설정 항목에 기록하려고 하면 아래의 오류를 만나게 됩니다.

 

우선 작업은 AndroidManifest.xml 파일에 아래와 같이 "설정 쓰기" 권한을 넣어주는 것입니다.

<uses-permission android:name="android.permission.WRITE_SETTINGS"
tools:ignore="ProtectedPermissions"/>

안드로이드 6.0 이전까지는 이렇게만 해주면 앱의 설치단계에서 "설정 쓰기" 권한을 얻게 되어 설정 항목 변경이 가능합니다.

 

안드로이드 6.0 이후부터는 "설정 쓰기" 권한은 보호 수준이 서명 권한(Signature permission)이라서 플랫폼과 동일한 키로 서명된 앱만 정상 권한처럼 설치단계에서 권한을 얻을 수 있지만, 이렇게 동작할 수 있는 것은 시스템 앱의 경우에만 해당합니다.

 

이에 앱에서는 Settings 클래스의 ACTION_MANAGE_WRITE_SETTINGS 인텐트를 보내서 사용자의 승인을 요청합니다.

 

위험 권한(Dangerous permission)을 대상으로 하는 일반적인 런타임 권한 처리와는 차이가 있습니다.

 

일반적인 런타임 권한 요청에 대해서는 아래 글을 참조하시면 됩니다.

2019/02/07 - 안드로이드 런타임 권한 확인 및 요청 처리하기 (Android Runtime permission check and request)

 

 

 

런타임 권한 확인은 "checkSelfPermission()"이 사용되었는데, 설정 쓰기 권한 확인은 Settings.System.canWrite()를 이용합니다. 

 

아래 코드를 보면, Settings.System.canWrite()로 권한 확인해서 없는 경우에 토스트 메시지를 띄우고, 메시지가 사라지면서 시스템 설정 수정을 위한 액티비티로 넘어가게 되어 있습니다.

private boolean permissionCheckAndRequest() {
boolean permission;

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
permission = Settings.System.canWrite(getApplicationContext());
} else {
permission = ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.WRITE_SETTINGS)
== PackageManager.PERMISSION_GRANTED;
}

mHasWriteSettingsPermission = permission;

if (!permission) {
Toast.makeText(getApplicationContext(), "시스템 설정(밝기 조절 세팅, 화면 회전)을 변경하기 위해서 시스템 변경할 수" +
" 있는 권한이 필요합니다." +
"\n잠시 후에 시스템 설정 변경 창으로 이동합니다. 권한을 [허용]해주세요.", Toast.LENGTH_LONG).show();

new Handler().postDelayed(new Runnable() {
@Override
public void run() {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
Intent intent = new Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS);
intent.setData(Uri.parse("package:" + getPackageName()));
startActivityForResult(intent, WRITE_SETTINGS_PERMISSION_REQUEST_CODE);
} else {
ActivityCompat.requestPermissions(MainActivity.this, new String[]{Manifest.permission.WRITE_SETTINGS},
WRITE_SETTINGS_PERMISSION_REQUEST_CODE);
}
}
}, 3500);

return false;
}
return true;
}

아래 그림이 "설정 쓰기" 권한을 얻기 위해서 ACTION_MANAGE_WRITE_SETTINGS 인텐트를 보내면 전환되는 액티비티입니다.

단말 업체별 텍스트 등의 차이는 있습니다만 권한 활성화를 위한 스위치를 갖춘 UI로 되어 있는 것은 동일합니다.

 

 

권한 획득을 위해서 스위치 활성화하고 앱으로 복귀하면 onActivityResult()로 들어오는데 다시 한번 Settings.System.canWrite()를 실행해서 권한 여부를 확인합니다. (권한 획득하지 못한 경우는 종료처리)

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);

boolean permission;
if (requestCode == WRITE_SETTINGS_PERMISSION_REQUEST_CODE) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
permission = Settings.System.canWrite(this);
} else {
permission = ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.WRITE_SETTINGS)
== PackageManager.PERMISSION_GRANTED;
}

mHasWriteSettingsPermission = permission;

if (permission) {
initBrightness();
mButtonRotation.setVisibility(View.VISIBLE);
} else {
Toast.makeText(getApplicationContext(), "시스템 설정(밝기 조절 세팅)을 변경을 위한 권한이 없어서 앱을 종료하였습니다",
Toast.LENGTH_LONG).show();
finish();
}
}
}

설정 항목 쓰기

 

Settings.Global|Secure|System.putInt|putLong|putFloat|putString(ContentResolver cr, String name,

                                                                                           int|long|float|String value)

 

설정 항목의 유형 중에 Global과 Secure는 시스템 앱에서만 쓰기가 가능합니다.

 

시스템 앱이 아닌 경우에 Global과 Secure 항목을 변경하려고 하면 아래와 같은 오류가 발생합니다.

이는 AndroidManifest.xml에 android.permission.WRITE_SECURE_SETTINGS를 추가하더라도 발생하는 현상입니다.

 

즉, 앱에서는 System 유형의 설정 항목만 쓰기가 가능합니다.

 

▶ 밝기 조절 모드를 수동으로 변경

    Settings.System.putInt(getContentResolver(), Settings.System.SCREEN_BRIGHTNESS_MODE,

                        Settings.System.SCREEN_BRIGHTNESS_MANUAL);

 

▶ 화면을 자동 회전 설정

    Settings.System.putInt(getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 1);

 

▶ 화면을 180도 회전

    Settings.System.putInt(getContentResolver(), Settings.System.USER_ROTATION, 2);

 

▶ 버튼음/터치 소리 활성화

    Settings.System.putInt(getContentResolver(), Settings.System.SOUND_EFFECTS_ENABLED, 1);

 

▶ 폰트 배율을 2로 조정

    Settings.System.putFloat(getContentResolver(), Settings.System.FONT_SCALE, 2.0f);

 

 

시스템 설정 변경을 이용한 화면 밝기 및 화면 방향 조절 예제

 

레이아웃 (activity_main.xml)

- 밝기의 설정값을 보여주는 TextView

- 화면 밝기 조절을 위한 SeekBar

- 화면 방향 조절을 위한 Button

 

 

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 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"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/food_1898194_1280"
tools:context=".MainActivity">

<TextView
android:id="@+id/tvBrightness"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:background="#40000000"
android:text="화면 밝기값"
android:textColor="@android:color/white"
android:textSize="20dp"
android:textStyle="bold"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/tvBrightnessValue"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />

<TextView
android:id="@+id/tvBrightnessValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:background="#40000000"
android:text=""
android:textColor="@android:color/white"
android:textSize="20dp"
android:textStyle="bold"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/BrightnessSeekBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />

<SeekBar
android:id="@+id/BrightnessSeekBar"
android:layout_width="255dp"
android:layout_height="20dp"
android:max="255"
android:progress="1"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.75" />

<Button
android:id="@+id/btnRotation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="화면 회전"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/BrightnessSeekBar" />
</android.support.constraint.ConstraintLayout>

자바 (MainActivity.java)

※설정 쓰기 권한 처리 소스는 위에서 언급되어 있습니다.

 

화면 밝기 조절을 위한 SeekBar 리스너 / 화면 방향 조절을 위한 Button 리스너 처리

 - changeScreenBrightness에 SeekBar로 조절한 값 전달

 - 화면 방향 조절 버튼은 누르면 바로 시스템 설정에 수정 반영 / 누를 때마다 방향이 변경되도록 처리

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mTextViewBrightnessValue = findViewById(R.id.tvBrightnessValue);

mSeekBarBrightness = findViewById(R.id.BrightnessSeekBar);
mSeekBarBrightness.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
mTextViewBrightnessValue.setText(String.valueOf(progress));
changeScreenBrightness(progress);
}

@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}

@Override
public void onStopTrackingTouch(SeekBar seekBar) {
}
});

mButtonRotation = findViewById(R.id.btnRotation);
mButtonRotation.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Settings.System.putInt(getContentResolver(), Settings.System.USER_ROTATION, ++nRotationValue%4);
}
});
}

화면 밝기 조절

 - 넘어온 SeekBar 값으로 실제 화면 밝기 조절하는 코드

 - 넘어온 SeekBar 값을 화면 밝기 설정값으로 저장하는 코드(changeBrightnessSystemSetting)

private void changeScreenBrightness(int value) {
Window window = getWindow();
WindowManager.LayoutParams layoutParams = window.getAttributes();
layoutParams.screenBrightness = value * 1.0f / 255;
window.setAttributes(layoutParams);
changeBrightnessSystemSetting(value);
}

 

private void changeBrightnessSystemSetting(int value) {
    ContentResolver cResolver = getContentResolver();
    Settings.System.putInt(cResolver, Settings.System.SCREEN_BRIGHTNESS_MODE, Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL);
    Settings.System.putInt(cResolver, Settings.System.SCREEN_BRIGHTNESS, value);
}

화면 밝기 백업 / 복원

 - 앱 실행 전 화면 밝기 저장

 - 앱 종료 때 저장된 화면 밝기로 복원

@Override
protected void onResume() {
super.onResume();

if (!isFinishing() && permissionCheckAndRequest()) {
initBrightness();
initRotation();
}
}

@Override
protected void onPause() {
super.onPause();
if (mHasWriteSettingsPermission) {
restoreBrightnessSystemSetting();
}
}

private void backupBrightnessSystemSetting() {
mBrightnessModeBackup = Settings.System.getInt(getContentResolver(), Settings.System.SCREEN_BRIGHTNESS_MODE,
Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL);
mBrightnessValueBackup = Settings.System.getInt(getContentResolver(), Settings.System.SCREEN_BRIGHTNESS, 128);
Toast.makeText(getApplicationContext(), "[Screen brightness system setting]" +
"\n\nMode: " + ((mBrightnessModeBackup==Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC) ?"Automatic":"Manual")
+ ((mBrightnessModeBackup==Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC)?"":"\nValue: " + mBrightnessValueBackup),
Toast.LENGTH_LONG).show();
}

private void restoreBrightnessSystemSetting() {
Settings.System.putInt(getContentResolver(), Settings.System.SCREEN_BRIGHTNESS_MODE, mBrightnessModeBackup);
if (mBrightnessModeBackup == Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL) {
Settings.System.putInt(getContentResolver(), Settings.System.SCREEN_BRIGHTNESS, mBrightnessValueBackup);
}
}

메니페스트(AndroidManifest.xml)

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="kr.happydev.systemsettingcontrol">
<uses-permission android:name="android.permission.WRITE_SETTINGS"
tools:ignore="ProtectedPermissions"/>

<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>

프로젝트 압축파일

SystemSettingControl.zip

 

이번 글은 여기까지입니다. 읽어주셔서 감사합니다.

반응형

[Snippet]

 

화면 밝기 변경

밝기 값

0~255(최소~최대), -1(시스템 설정값)

private void changeScreenBrightness(int value) {
Window window = getWindow();
WindowManager.LayoutParams layoutParams = window.getAttributes();
layoutParams.screenBrightness = value * 1.0f / 255;
window.setAttributes(layoutParams);
}

 

[Sample]

버튼 3개(최소, 최대, 시스템 설정)로 해당 밝기 선택, 종료시는 시스템 설정 밝기로 복귀

public class MainActivity extends AppCompatActivity {
private static final int BRIGHTNESS_MIN = 0;
private static final int BRIGHTNESS_MAX = 255;
private static final int BRIGHTNESS_SYSTEM = -1;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

findViewById(R.id.btnMinBrightness).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
changeScreenBrightness(BRIGHTNESS_MIN);
}
});

findViewById(R.id.btnMaxBrightness).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
changeScreenBrightness(BRIGHTNESS_MAX);
}
});

findViewById(R.id.btnSystemBrightness).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
changeScreenBrightness(BRIGHTNESS_SYSTEM);
}
});
}

@Override
protected void onStop() {
super.onStop();
changeScreenBrightness(BRIGHTNESS_SYSTEM);
}

private void changeScreenBrightness(int value) {
Window window = getWindow();
WindowManager.LayoutParams layoutParams = window.getAttributes();
layoutParams.screenBrightness = value * 1.0f / 255;
window.setAttributes(layoutParams);
}

ScreenBrightness.zip 

 

반응형

안드로이드 권한(Permission)은 시스템의 보안과 개인정보 보호를 위해 존재합니다.

 

이 권한은 Protection level로 구분된 일반 권한(Normal permissions)위험 권한(Dangerous permissions)으로 나눠집니다.

 

이것 외에도 시스템 앱(Preload 앱)에 사용될 수 있는 Protection level 종류가 더 있지만, 일반 앱 개발자의 경우 사용할 수 있는 것은 위의 두 가지 Protection level에 해당하는 권한들만 사용할 수 있습니다.

 

Protection level은 아래 링크를 참조하시길 바랍니다.

https://developer.android.com/reference/android/R.attr#protectionLevel

 

[일반 권한 / 위험 권한]

일반 권한

 

시스템이나 개인정보 보안 등의 위험을 초래하지 않기 때문에 앱에서 권한을 요청만 하면 자동으로 권한을 얻게 되어서 접근할 수 있습니다.

 

▶ 일반 권한 확인 방법

[Play 스토어에서 앱을 설치 시 확인 방법]

일반 권한만 존재하는 앱은 아래 왼쪽 그림과 같이 바로 다운로드 되면서 설치됩니다. (Android 5.1 이하 단말)

Play 스토어의 해당 앱 설명 하단에 보면 "앱 권한" 이 있는데 확인을 하면 아래 오른쪽 그림과 같이 나옵니다. (모든 단말)

 - 아래의 앱("또 다른 지식의 성전" 게임)에서는 "일반 권한"만 존재합니다.

 

 

[설치된 앱에서 확인 방법]

"앱 정보" 진입(설정-애플리케이션-해당 앱)후 "권한" 확인 시 아래 왼쪽 그림과 같이 "위험 권한"은 존재하지 않고, 빨간색으로 표시된 "옵션 더 보기"를 누르면 아래 오른쪽 그림과 같이 "일반 권한"이 존재하는 것을 확인할 수 있습니다.

 

 

위 앱의 네트워크 접근 권한은 인터넷 권한(android.permission.INTERNET)으로 레퍼런스에 확인 시 "일반 권한"임을 알 수 있습니다.

 


 

위험 권한

 

시스템이나 개인정보 보안 등의 위험을 초래할 수 있기 때문에 사용자가 앱이 요청한 권한을 확인하고 승인해야 비로소 앱에서 접근할 수 있습니다.

앱이 카메라, 블루투스, 지문인식, 네트워크, NFC 등 시스템의 장치를 사용하거나 메시지, 전화번호, 일정과 같은 민감한 사용자 정보를 이용하기 위해서는 사용자에게 권한 승인을 얻은 이후 접근할 수 있습니다.

앱은 사용자가 승인하지 않으면 카메라를 사용할 수 없고, 전화번호를 읽어 올 수도 없습니다.

 

▶ 위험 권한 확인 방법

[Play 스토어에서 앱을 설치 시 확인 방법]

위험 권한이 포함된 아래("Send Anywhere" 앱)의 경우 설치 시 사용자에게 권한에 대한 승인을 받는 창이 나옵니다.

(단말이 Android 5.1 이하인 경우 또는 단말이 Android 6.0 이상이지만 앱의 targetSdkVersion이 22 이하인 경우)

Android 6.0 이상의 단말에서 targetSdkVersion이 23 이상이면 "위험 권한"이 존재하더라도 권한 승인 창이 나오지 않고 바로 설치됩니다. 이것은 나중에 "런타임 권한 확인"이 가능하기 때문에 그렇게 동작하는 것입니다.

 

[설치된 앱에서 확인 방법]

앱 정보 진입(설정-애플리케이션-해당 앱)후 "권한" 확인 시 아래 왼쪽과 같이 앱에서 사용하는 "위험 권한" 목록이 나오고 여기에서 권한 설정 및 해제도 가능합니다. 아래 오른쪽은 앱을 최초 실행했을 때 "위험 권한" 사용 요청을 위한 런타임 권한 요청하는 것을 확인할 수 있습니다.

 

 

 

[런타임 권한 (Runtime permission)]  

Android 6.0에서 최초 도입이 되었습니다. 이는 앱 설치 시 권한 요청을 하는 것이 아니라, 앱 실행 중 권한이 필요할 때 요청하는 것을 의미하며 요청에 대해 거부도 할 수 있으며, 승인/거부 상황에 맞게 처리하는 루틴이 고려해야 합니다.

 

요청을 하는 대상은 "위험 권한" 항목만 해당합니다. "일반 권한"은 자동으로 권한을 부여받습니다.

 

Android 6.0에 도입되었지만, 앱에서 targetSdkVersion을 23 이상으로 해주어야 실제 런타임 권한으로 동작합니다.

 

몇 달 전에 구글에서는 신규 및 기존 앱 업데이트 시 targetSdkVersion을 26이상으로 변경해야 등록되도록 정책을 변경하였습니다.
(관련 링크:
https://support.google.com/googleplay/android-developer/answer/113469?hl=ko#targetsdk)

 

그러므로, 이제부터는 구글플레이에 등록해야 하는 신규 앱은 처음부터 런타임 권한이 지원하도록 만들어야 하고, 기존 등록된 앱이 아직 targetSdkVersion을 23 이상으로 올리지 않았다고 하면 향후 업데이트 대비해서 런타임 권한이 지원시 문제가 될 수 있는 부분이 있는지 미리 파악해두시는 것이 좋을 것 같습니다.

 

런타임 권한 지원하기

 

아래와 같은 순서도로 동작하는 런타임 권한을 지원하도록 하겠습니다.

꼭 이렇게 해야 하는 것은 아니고, 하나의 예를 보여드리기 위함입니다.

 

 

 

런타임 권한과 관련된 API

권한 부여 여부: checkSelfPermission()

권한 요청: requestPermissions()

권한 요청 콜백: onRequestPermissionsResult()

사용자에게 설명이 필요한 상황 확인: shouldShowRequestPermissionRationale()

 


 

◈런타임 권한 지원하기 실제 예

지금부터는 런타임 권한 지원을 어떻게 하면 되는지 예제를 보면서 설명드리겠습니다. 아래에 있는 예제를 사용할 것입니다.

2018/10/13 - 손전등 앱 만들기 #2 - Camera2 API 이용방법 (Flashlight app using Camera2 API)

 

#1 targetSdkVersion 변경에 따른 이상 동작 확인

위에 언급한 예제는 아래와 같이 targetSdkVersion이 21로 런타임 권한 지원이 되지 않습니다.

android {
compileSdkVersion 27
defaultConfig {
applicationId "com.example.help.permissionflashlight3"
minSdkVersion 21
targetSdkVersion 21

먼저 런타임 관련한 코드 수정 없이 targetSdkVersion을 23으로 바꾸면 어떻게 되는지 확인해보겠습니다.

 

앱에서 카메라 장치에 접근(open)하는 순간 Exception이 발생해서 강제종료(Force close)됩니다. 

 

이는 targetSdkVersion이 23으로 되면서 "위험 권한"에 해당하는 카메라의 경우 자동으로 권한을 부여받지 못하고, 런타임 권한 요청에 의한 권한을 부여받아야 하기에 최초의 경우 권한이 없는 상태입니다. 그래서, CameraService 연결하려고 하면 권한 검사를 통과하지 못해 ERROR_PERMISSION_DENIED 상태로 연결 거부(Reject)되었음을 전달해주며 그 결과 CameraManger에서 SecurityException을 throw해서 Exception이 발생하게 됩니다.

[CameraService.cpp]

 

// If it's not calling from cameraserver, check the permission.
if (callingPid != getpid() &&
!checkPermission(String16("android.permission.CAMERA"), clientPid, clientUid)) {
ALOGE("Permission Denial: can't use the camera pid=%d, uid=%d", clientPid, clientUid);
return STATUS_ERROR_FMT(ERROR_PERMISSION_DENIED,
"Caller \"%s\" (PID %d, UID %d) cannot open camera \"%s\" without camera permission",
clientName8.string(), clientUid, clientPid, cameraId.string());
}
[CameraManager.java]

 

public static void
throwAsPublicException(Throwable t) throws CameraAccessException {
if (t instanceof ServiceSpecificException) {
ServiceSpecificException e = (ServiceSpecificException) t;
int reason = CameraAccessException.CAMERA_ERROR;
switch(e.errorCode) {
case ICameraService.ERROR_DISCONNECTED:
reason = CameraAccessException.CAMERA_DISCONNECTED;
break;
case ICameraService.ERROR_DISABLED:
reason = CameraAccessException.CAMERA_DISABLED;
break;
case ICameraService.ERROR_CAMERA_IN_USE:
reason = CameraAccessException.CAMERA_IN_USE;
break;
case ICameraService.ERROR_MAX_CAMERAS_IN_USE:
reason = CameraAccessException.MAX_CAMERAS_IN_USE;
break;
case ICameraService.ERROR_DEPRECATED_HAL:
reason = CameraAccessException.CAMERA_DEPRECATED_HAL;
break;
case ICameraService.ERROR_ILLEGAL_ARGUMENT:
case ICameraService.ERROR_ALREADY_EXISTS:
throw new IllegalArgumentException(e.getMessage(), e);
case ICameraService.ERROR_PERMISSION_DENIED:
throw new SecurityException(e.getMessage(), e);
case ICameraService.ERROR_TIMED_OUT:
case ICameraService.ERROR_INVALID_OPERATION:
default:
reason = CameraAccessException.CAMERA_ERROR;
}
throw new CameraAccessException(reason, e.getMessage(), e);
} else if (t instanceof DeadObjectException) {
throw new CameraAccessException(CameraAccessException.CAMERA_DISCONNECTED,
"Camera service has died unexpectedly",
t);
} else if (t instanceof RemoteException) {
throw new UnsupportedOperationException("An unknown RemoteException was thrown" +
" which should never happen.", t);
} else if (t instanceof RuntimeException) {
RuntimeException e = (RuntimeException) t;
throw e;
}
}

SecurityException에 의한 Fatal exception 대응 코드

아래와 같이 try ~ catch 사용으로 예외처리

try {
mCameraManager.openCamera(mCameraId, new CameraDevice.StateCallback() {
...
}, null);
} catch (SecurityException e) {
e.printStackTrace();
}

#2 SecurityException의 root cause인 Runtime Permission 적용

SecurityException가 발생한 이유는 targetSdkVersion이 23으로 변경되었지만, RuntimePermission이 적용되어있지 않아서 Permission이 허용되지 않는 상태에서 문제가 된 API를 호출해서 발생하였습니다.

 

위의 순서도에 맞게 추가 작업한 부분입니다. (빨간색 코드)

 

최초 앱 실행 시 checkSelfPermission()을 호출해서 권한 부여 여부를 확인합니다.

권한을 이미 부여받은 상태이면, 원래의 액티비티(R.layout.activity_main)을 호출하고, 그렇지 않으면 권한 요청을 위한 새로 만든 액티비티(R.layout.activity_permission)을 호출하도록 하였습니다.

 

 

위의 권한 요청 액티비티(R.layout.activity_permission)에서 "요청하기" 버튼을 누르면 requestPermissions()를 호출해서 권한 요청을 하면 아래와 같은 "권한 요청 시스템 팝업"이 발생합니다.

 

 

위의 "권한 요청 시스템 팝업"에서 선택(거부/허용)한 결과는 onRequestPermissionsResult()로 콜백됩니다.

아래는 이와 관련된 기존 예제에서 추가된 코드입니다.

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
if (requestCode == REQUEST_CAMERA) {
if (grantResults.length > 0) {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
recreate();
} else if (grantResults[0] == PackageManager.PERMISSION_DENIED) {
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) {
Toast.makeText(MainActivity.this, "잠시 후에 다시 권한 요청을 합니다.\n플래시를 사용하기 위해서는" +
" [카메라]권한이 필요합니다.", Toast.LENGTH_LONG).show();
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
ActivityCompat.requestPermissions(MainActivity.this,
new String[]{Manifest.permission.CAMERA},
REQUEST_CAMERA);
}
}, 3500);
} else {
Toast.makeText(MainActivity.this, "잠시 후에 앱 정보로 이동합니다. 권한 항목에서" +
" [카메라]를 허용해주세요.", Toast.LENGTH_LONG).show();
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
Intent intent = new Intent();
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.fromParts("package", getPackageName(), null));
startActivity(intent);
}
}, 3500);
}
}
}
} else {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
}

콜백에서 권한이 승인(PERMISSION_GRANTED)으로 넘어오면 recreate()를 호출해서 액티비티를 종료시키고 다시 액티비티(=MainActivity)를 실행하도록 하면 checkSelfPermission()에선 권한 부여받은 것으로 처리되어서 원래의 액티비티(R.layout.activity_main)를 호출해서 targetSdkVersion 23 이전처럼 앱이 정상동작하게 됩니다.

 

콜백에서 권한이 거부(PERMISSION_DENIED)로 넘어온 이후 두 번째 권한 요청할 때부터는 "권한 요청 시스템 팝업"에 "다시 묻지 않음" 체크 버튼이 추가되어서 나옵니다. 그래서, 위의 경우에 아래 왼쪽처럼 토스트 메시지가 나오고, 토스트가 사라질 때쯤에 오른쪽과 같이 "권한 요청 시스템 팝업"이 발생합니다.

 

 

위의 "권한 요청 시스템 팝업"에서 거부를 하면 다시 콜백되어서 토스트 메시지와 "권한 요청 시스템 팝업"이 다시 나오게 됩니다. 거부한 경우에 계속 반복적으로 요청하는 루틴으로 되어 있습니다. 이건 순서도에 있는 것처럼 이렇게 동작하도록 시나리오를 만든 것입니다. 이 부분의 처리는 각자 생각한 시나리오에 맞게 적절히 변경해주시면 됩니다.

 

만약 "다시 묻지 않음"을 체크한 경우에는 아래 왼쪽처럼 "허용"은 비활성화되고, "거부"만 선택할 수 있게 됩니다.

이때 "거부"를 선택하게 되면, 콜백에서 아래 가운데처럼 토스트 메시지가 나오도록 처리하고, 토스트가 사라질 때쯤 오른쪽과 같이 "앱 정보" 화면으로 넘어가도록 처리되어 있습니다.

 

참고: 2018/11/02 - 애플리케이션 정보 (Application info.) 보기로 이동

 

  

 

아래 왼쪽과 같이 "앱 정보"의 권한 항목에서 "카메라"를 허용해주고 앱으로 돌아가서 "요청하기" 버튼을 누르면 권한이 허용된 상태이기 때문에 원래의 액티비티(R.layout.activity_main)로 넘어갑니다.

 

"앱 정보"의 "카메라"를 허용하지 않는 상태로 앱으로 돌아가서 "요청하기" 버튼을 누르면 "다시 묻지 않음"이 체크된 상태라서 "권한 요청" 시 "권한 요청 시스템 팝업"이 나오지 않고 곧바로 "앱 정보"를 호출(아래 오른쪽 참고)하게 됩니다.

 

 

 

앱 동작 영상

 

프로젝트 화일

PermissionFlashlight3_support_runtime_permission.zip

여기까지입니다. 읽어주셔서 감사합니다.

 

 

 

 

+ Recent posts