[Android/푸시] 알림 Notification

권한 확인 동작

  • API 32 이하 앱의 경우, 시스템이 자동으로 퍼미션 팝업을 띄웠을 때, 사용자가 Don’t allow 버튼을 한번 누르면, 앱이 재시작해도 팝업이 발생하지 않는다.
  • API 33 이상 앱의 경우, 앱이 퍼미션 팝업을 띄웠을 때, 사용자가 Don’t allow 버튼을 두 번 누르기 전까지 퍼미션 팝업을 계속 띄울 수 있다.
  • 사용자가 Don’t allow 버튼을 눌러서 더 이상 팝업을 띄울 수 없다면 사용자가 직접 앱 설정에 들어가서 권한을 허가해줘야한다.

Android 13에서 변경된 사항

  • POST_NOTIFICATIONS (Notification Permission) 은 Target SDK API 33 이상부터 추가 가능
  • Target SDK API 32 이하의 앱이 Android 13 디바이스에 설치되면 Notification Channel을 등록할 때 자동으로 Notification 권한 요청 팝업이 나옴
  • Target SDK API 33 이상의 앱이 Android 13 디바이스에 설치되면 Notification 권한요청을 개발자가 원하는 타이밍에 노출 가능
  • Target SDK API 33 이상의 앱이 Android 12 이하 디바이스에 설치되면 기존과 동일하게 Notification 권한 요청 없이 사용 가능
  • Target SDK API 32 앱을 33으로 업데이트 시 기존 알림 권한 동의 상태라면 업데이트 이후 기본으로 허용이지만 예외 있음
    • 기기에 따라 자동으로 허용되지 않고 다시 한 번 권한을 얻어야하는 경우가 있음
    • 그렇기 때문에 Notification 권한이 허가되어 있는지 한 번 확인하는 작업을 넣기를 권장함

특성

  • 삼성 폰에서만 노티 아이콘에 색제한이 없다. 구글에서 권장하는 아이콘 색상배열은 투명배경에 흰색을 사용한 아이콘이다.
    배경색이 있는 런처 아이콘을 사용한 경우 픽셀 단말에서 아이콘이 깨진다.
  • 채널 중요도 수준

    출처: Android 공식문서

    ⚠️ setTicker넣었더니 Android API 27에서 테스트하길, 아이콘도 제대로 안나오고 priority high로 설정했는데 헤드업이 안뜸.

  • 가시성 잠금화면상태(system ui가 신뢰할 수 없는)에서 알림의 존재와 내용을 표시하는 방법, 가시성을 설정 (setVisibility()를 이용해 notificaiton에서 설정)
    • VISIBILITY_PUBLIC: 알림의 전체 콘텐츠가 잠금 화면에 표시된다.
    • VISIBILITY_SECRET: 알림의 어느 부분도 잠금 화면에 표시되지 않는다.
    • VISIBILITY_PRIVATE: 알림 아이콘과 콘텐츠 제목과 같은 기본 정보만 잠금 화면에 표시된다. 알림의 전체 콘텐츠가 표시되지 않는다.
  • [Google] 푸시 펼치기 기능은 OS가 아닌 단말에 따라 존재 유무가 달라진다.

[Android/FCM] 토큰 정책

등록 토큰 변경 조건

- 새 기기에서 앱 복원
- 사용자가 앱 제거/재설치
- 사용자가 앱 데이터 소거
- FCM에서 기존 토큰이 만료된 후 앱이 다시 활성화(active)

비활성 토큰

FCM에 1개월(30일, const EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30;) 넘게 연결되지 않은 비활성(inactive) 기기와 연결된 토큰은 비활성(stale, 탁한) 토큰이다.

비활성 등록 토큰은 FCM에 1개월 넘게 연결되지 않은 비활성 기기와 연결된 토큰입니다. 시간이 지날수록 기기가 FCM에 다시 연결될 가능성은 점점 줄어듭니다. 이러한 비활성 토큰의메시지 전송 및 주제 팬아웃은 전송되지 않을 가능성이 높습니다(are unlikely to ever be delivered.).

문서에 이렇게 나와 있는데,

  • “FCM에 1개월 넘게 연결되지 않은”은 ‘메세지가 마지막으로 정상전송된 이후로 1개월’을 의미하는 걸로 생각됨.
  • “전송되지 않을 가능성이 높다”라는 의미는 사용자가 해당 토큰과 연결된 기기(앱)를 사용하지 않아서 수신이 제대로 되지 않을 수 있음을 의미하며, (만료되었을 때처럼) FCM이 메세지전송을 막아서는 아닌 것으로 보임.

단, 비활성 토큰이 270일 동안 활동이 없으면 FCM에서 만료된(expired) 토큰으로 간주한다. 이때 만료된 토큰으로는 FCM에서 유효하지 않은 것으로 표시하고 이때, 메세지 전송을거부한다. (FCM은 기기가 다시 연결되고 앱이 열리는 경우에 앱 인스턴스의 새 토큰을 발급한다.)

따라서 토큰의 유효기간은 마지막 정상전송 후로부터 최대 300일(30일 후 비활성 + 만료 유예기간 270일)로 예상된다.

자세한 내용은 [Firebase 문서 - FCM 등록 토큰 관리를 위한 권장사항] 부문을 통해 확인할 수 있다.

[Android / Glide] Glide 이미지 로딩 중 다른 scaleType 적용하기

Glide 동작을 테스트하면서 아래와 같은 상황이 발생하였다.

  1. 기존에 사용하던 로딩이미지의 ScaleType은 CENTER를 사용해야 한다.
  2. Glide로 로드한 이미지는 로딩이미지와는 다른 각자의 ScaleType을 가지고 있다.

따라서 loadingImage와 loadedImage의 ScaleType을 외부에서 설정만 해주면 내부에서 자동으로 바꿔서 보여주도록 만들고자 했다.

이 작업을 위해 Glide에서는 이미지 로딩 중 placeholder 노출, 리소스 로드 등의 동작을 확장 구현할 수 있도록 Target을 제공한다는 점을 먼저 말해두겠다. 이 Target은 into()를 통해 전달할 수 있다.

1
2
3
4
5
6
Target<Drawable> target =
Glide.with(fragment)
.load(url)
.into(new Target<Drawable>() {
...
});

이러한 Target를 상속받는 클래스를 만들어서 placeholder는 onLoadStarted, error는 onLoadFailed에 각각 정의하려고 한다. 이때 로딩이미지는 drawable 리소스로 앱에 저장되어있기 때문에 DrawableImageViewTarget을 상속받아 만든 게 아래와 같다.

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class ScaleLoadingImageViewTarget extends DrawableImageViewTarget {
private ImageView.ScaleType mLoadingScaleType;
private ImageView.ScaleType mReadyScaleType;

 // RequestOptions에서 정의해둔 scale은 fitCenter
public ScaleLoadingImageViewTarget(ImageView view, ImageView.ScaleType scaleType) {
super(view);
// loadingCenter()에서 받아온 scale
mLoadingScaleType = scaleType;
        // xml에서 정의해둔 scale
// (따로 정의되지 않았다면 여기에 RequestOptions scaleType인 fitCenter가 들어옴
mReadyScaleType = view.getScaleType();
}

@Override
public void onLoadStarted(@Nullable Drawable placeholder) {
ImageView imageView = getView();
imageView.setScaleType(mLoadingScaleType);
super.onLoadStarted(placeholder);
}

@Override
public void onLoadFailed(@Nullable Drawable errorDrawable) {
ImageView imageView = getView();
imageView.setScaleType(mLoadingScaleType);
super.onLoadFailed(errorDrawable);
}

    @Override
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
ImageView imageView = getView();
imageView.setImageResource(0); // 로딩 이미지가 보여지고 있을 때 scaleType이 바뀌면서 이미지가 덜그럭 거리는 이슈 방어
imageView.setScaleType(mReadyScaleType);
super.onResourceReady(resource, transition);
}

@NonNull
public static ScaleLoadingImageViewTarget loadingCenter(ImageView imageView) {
return new ScaleLoadingImageViewTarget(imageView, ImageView.ScaleType.CENTER);
}
}

loadingCenter 메소드를 통해 위에 새롭게 정의된 Target에 접근할 수 있도록 한다.

1
2
3
4
5
Glide.with(context)
.load(url)
.listener(listener)
.apply(options)
.into(ScaleLoadingImageViewTarget.loadingCenter(imageView));

참고링크

그러나 이 방식을 적용한 ImageView가 담긴 RecyclerView를 구현하게 되면, RecyclerView 각 아이템이 재사용되면서 ScaleLoadingImageViewTarget의 생성자에서 호출하는 view.getScaleType()의 값이 기대한 대로 나오지 않는 경우가 있어 문제가 될 수 있다.

따라서 이를 아래와 같이 view의 ScaleType을 명시적으로 받도록 변경하였다.

1
2
3
4
5
Glide.with(context)
.load(url)
.listener(listener)
.apply(options)
.into(ScaleLoadingImageViewTarget.loadingCenter(imageView, ScaleType.FIT_CENTER));
1
2
3
4
5
6
7
8
9
10
public ScaleLoadingImageViewTarget(ImageView view, ImageView.ScaleType originScaleType, ImageView.ScaleType loadingScaleType) {
super(view);
mLoadingScaleType = loadingScaleType;
mReadyScaleType = originScaleType;
}

@NonNull
public static ScaleLoadingImageViewTarget loadingCenter(ImageView imageView, ImageView.ScaleType originScaleType) {
return new ScaleLoadingImageViewTarget(imageView, originScaleType, ImageView.ScaleType.CENTER);
}

[Android] 상하 스크롤 중 내부 RecyclerView의 좌우 스크롤 유지

1. 상황

현재 한 Activity가 상하 스크롤이 되는 RecyclerView로 구성되어 있고, 그 아이템 중 하나가 좌우 스크롤 RecyclerView를 가지고 있다.

Activity 구성

RecyclerView는 View를 재사용하기 때문에 item1, item2, …의 뷰를 그릴 때 item1에서 사용한 뷰를 n번째 item에서 재사용할 수 있다. 따라서 다시 item1을 그릴 때 View를 초기화하게 되므로 item 내의 RecyclerView 스크롤이 초기화된다.

즉, item1의 좌우 스크롤을 움직인 후 아래로 스크롤링하고 item1 위치로 다시 돌아왔을 때 그 좌우 스크롤이 처음으로 되돌아가있는 이슈가 발견되었다.

2. 해결

1
2
3
4
val pos = (rv.layoutManager as LinearLayoutManager?)?.findFirstCompletelyVisibleItemPosition() ?: -1
if (pos != -1 && scrollInfo?.scrollPos != pos) {
scrollInfo?.scrollPos = pos
}

이렇게 하면 일단 현재 보고있는 item의 position을 얻어와 저장할 수 있다. 이 동작을 사용하여 아래와 같이 OnScrollListener에서 scrollPos와 scrollOffset을 저장한다.

scrollOffset은 scrollPos만으로는 해당 item의 맨 앞부분으로 스크롤이 움직이기 때문에 이를 현재 스크롤하던 위치로 옮겨주기 위함이다.

1
2
3
4
5
6
7
8
9
10
rv.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val pos = (rv.layoutManager as LinearLayoutManager?)?.findFirstCompletelyVisibleItemPosition() ?: -1
if (pos != -1 && scrollInfo?.scrollPos != pos) {
scrollInfo?.scrollPos = pos
}
scrollInfo?.scrollOffset = rv.computeHorizontalScrollOffset()
}
})

그리고 저장한 scrollOffset을 사용하여 스크롤을 유지할 수 있는 함수는 아래와 같이 구성하였다.

1
2
3
4
5
6
7
8
9
10
private fun keepScroll(scrollInfo: ScrollInfo) {
var offset = 0
if (scrollInfo.scrollPos < goodsList.size - 2) {
offset = scrollInfo.scrollOffset
while (offset > itemWidth) {
offset -= itemWidth
}
}
(rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(scrollInfo.scrollPos, offset)
}

3. 번외

scrollToPositionWithOffset이 position과 offset 값을 받는 걸 이용하여, position은 0으로 고정하고 offset을 rv.computeHorizontalScrollOffset()을 저장한 값으로 넘겨주면 될 거라고 생각했으나 기대한대로 동작하지 않았다.

1
(rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, offset)

[Android] Android 12 변경사항 일부 요약

이하는 보편적으로 크게 영향있을 것 같은 것만 추렸다. Android 12 업데이트 관련하여 따로 targetSDK를 바꾸어 대응하지 않았기 때문에 간략하게만 요약하였다.

Android Developer - 동작 변경사항: 모든 앱

신뢰할 수 없는 터치 이벤트가 차단됨

이 변경사항은 FLAG_NOT_TOUCHABLE 플래그 등을 사용하여 터치가 창을 통과할 수 있도록 하는 앱에 영향을 줍니다.

어떤 오브젝트가 화면을 가리고 있는데 그 아래에 위치된 오브젝트를 터치하고자 할 때 이 터치를 허용할지에 관한 내용이다..

동작 변경사항: Android 12를 타겟팅하는 앱에만 영향

앱이 Android 12를 타겟팅하고 인텐트 필터를 사용하는 활동이나 서비스, broadcast receiver를 포함하면 이러한 앱 구성요소의 android:exported 속성을 명시적으로 선언해야 합니다.

예를 들자면 아래와 같다.

1
2
3
4
5
6
<service android:name="com.example.app.backgroundService"
android:exported="false">
<intent-filter>
<action android:name="com.example.app.START_BACKGROUND" />
</intent-filter>
</service>

[Android] Android 11 대응 - 2. 범위 지정 저장소 적용

이 글은 실제로는 2021.01에 작성되어 일부 업데이트된 내용이 누락되었을 수 있음을 미리 밝힙니다.

1. 내부 저장소와 외부 저장소

내부 저장소에 저장된 파일은 애플리케이션 전용이며 다른 애플리케이션에서는 액세스 할 수 없다. 내부 저장소는 /data/data/com.example.testapp/ 이와 같은 경로를 말한다.

외부 저장소는 내부 저장소(UT) 또는 이동식 저장소의 어느 위치에나 있을 수 있다. 휴대전화의 OEM 및 Android OS 버전에 따라 달라질 수 있으므로 단말 기종마다 저장되는 위치가 다르다.

/sdcard 및 /storage/emulated/0 와 같은 형식의 패스는 외부 저장소를 가리킨다. 그러나 이것들은 /storage/sdcard0 대한 심볼릭 링크이기 때문에 (예전에는 된 것 같지만) 현재는 이와 같은 패스를 하드코딩하여 사용하지 않는 게 좋다.

2. 접근 권한

  • READ_EXTERNAL_STORAGE
  • WRITE_EXTERNAL_STORAGE

접근 권한은 위와 같이 잘 알려진 두 가지가 있으나 Android 11부터 WRITE_EXTERNAL_STORAGE만 권한허용을 해도 READ_EXTERNAL_STORAGE 권한은 자동으로 부여되도록 변경되었다.

Any app that declares the WRITE_EXTERNAL_STORAGE permission is implicitly granted this permission.
역> WRITE_EXTERNAL_STORAGE 권한을 허용한 앱은 내재적으로 READ_EXTERNAL_STORAGE 권한도 허용한다.

3. 안드로이드 P이하에서의 저장소 링크

  • 앱 데이터 폴더 : /storage/Android/data/[앱의 package name]/
  • 공용 폴더(DCIM, Pictures 등): /storage/[폴더 이름]

4. 안드로이드 Q이상에서의 범위 지정 저장소(Scoped Storage)

Android 10(API 수준 29) 이상을 타겟팅하는 앱에는 기본적으로 외부 저장소로 범위가 지정된 액세스 권한 또는 범위 지정 저장소가 부여됩니다. - 범위지정 저장소

Android 10에서는 선택적으로 범위지정 저장소를 사용하거나 사용하지 않을 수 있다.

Android 11(Q)이상부터는 강제로 바뀌는데, 범위지정 저장소는 아래와 같은 구조로 되어있다. 지칭하는 용어도 위치, 접근하는 방법(코드)도 완전히 바뀌기 때문에 Android 11 업데이트에서 가장 대응하기 난감했던 부분이다.

  • 앱 데이터 폴더(App specific directory)
  • 미디어 파일들(MediaStore)
  • 공용 파일들(Storage Access Framework)

5. 관련 이슈

java.io.FileNotFoundException: /storage/emulated/0/mediapicker/images/c.png: open failed: ENOENT (No such file or directory)

기존에 사용하던 파일 접근 로직을 사용하는 중이라면 위와 같이 FileNotFoundException ENOENT error를 보게 된다. 원래 읽어오던 파일 또는 파일의 위치에 접근할 수 없어서 “찾을 수 없다”는 에러를 띄우는 것이다.

6. 변경 사항

Android Developer - Scoped Storage

  • /sdcard 의 접근이 불가능해진다.
  • 외부 저장소의 최상위 경로를 반환하는 Enviroment.getExternalStorageDirectory()는 Android 10(API 29)부터 deprecated 되었고, 위에서 말한 것처럼 Android 11에서는 더이상 사용할 수 없다.

7. Android 10 호환성을 위해 유지할 것

Android 11에서는 해당 속성은 무시된다. 하지만 Android 10 호환성을 위해 requestLegacyExternalStorage=true 값을 유지해야 한다.

Android Developer - 범위 지정 저장소를 일시적으로 선택 해제

8. 문제가 될만 한 호출 메소드

다음 두 개의 메소드는 Android 10(API 29)부터 deprecated 되니 Context#getExternalFilesDir() 또는 MediaStore, Intent#ACTION_OPEN_DOCUMENT로 대체해야 한다.

Android Developer

(1) getExternalStorageDirectory

1
public static File Environment#getExternalStorageDirectory()

Context#getExternalFilesDir()로 대체한다.

이 방식을 사용하는 건 다음 두 개를 만족하는 경우이다.

  1. 앱이 삭제되면 같이 지워져도 된다.
  2. 외부에서 사용할 수 있도록 공공 저장소에 저장해도 된다.

(2) getExternalStoragePublicDirectory

1
public static File Environment#getExternalStoragePublicDirectory(String type)

이는 사진 및 영화와 같이 잘 알려진 파일 유형을 저장하기 위한 중앙 집중식 장소이다. 이 디렉토리와 내용은 앱을 제거할 때 삭제되지 않는다.(ex 위치: DCIM)

MediaStore 또는 Intent#ACTION_OPEN_DOCUMENT 사용하여 대체

ACTION_OPEN_DOCUMENT는 파일을 선택할 수 있는 파일 탐색기가 뜨도록 유도할 수 있으며, MediaStore를 사용한 예는 아래와 같다.

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
26
27
28
29
30
31
32
33
34
35
36
37
38
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
SimpleDateFormat format1 = new SimpleDateFormat("yyyy-MM-dd_HH:mm:ss");
String fileName = format1.format(System.currentTimeMillis()) + ".jpg";

ContentValues values = new ContentValues();
// DCIM/ 또는 Pictures/ 이외의 장소에 접근하려고 하면 오류
values.put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/" + CommConstants.FOLDER_DIRECTORY);
values.put(MediaStore.Images.Media.DISPLAY_NAME, fileName);
values.put(MediaStore.Images.Media.MIME_TYPE, "image/*");
values.put(MediaStore.Images.Media.IS_PENDING, 1); // 파일을 write중이라면 다른곳에서 데이터요구를 무시

ContentResolver contentResolver = getContentResolver();
Uri collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
Uri item = contentResolver.insert(collection, values);
ParcelFileDescriptor imageFile = null;
try {
// Uri(item)의 위치에 파일을 생성해준다.
imageFile = contentResolver.openFileDescriptor(item, "w", null);
if (imageFile != null) {
InputStream inputStream = getImageInputStream(bitmap);
byte[] strToByte = getBytes(inputStream);
FileOutputStream fos = new FileOutputStream(imageFile.getFileDescriptor());
fos.write(strToByte);
fos.close();
inputStream.close();
imageFile.close();
contentResolver.update(item, values, null, null);
}
} catch (IOException e) {
e.printStackTrace();
}

if (imageFile != null) {
values.clear();
values.put(MediaStore.Images.Media.IS_PENDING, 0); // 파일을 모두 write하고 다른곳에서 사용할 수 있도록 0으로 업데이트
contentResolver.update(item, values, null, null);
}
}

(3) MediaStore.MediaColumns.DATA 칼럼 접근 불가

query issue: invalid column latitude

MediaStore.MediaColumns.DATA 칼럼이 android 10부터 deprecated되어 접근할 수 없어서 발생하는 이슈이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static String getRealPathFromURI(Context context, Uri contentUri) throws SecurityException {
String res = null;
String[] proj = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DISPLAY_NAME};
Cursor cursor = context.getContentResolver().query(contentUri, proj, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
int columnIndex = (contentUri.toString().startsWith("content://com.google.android.gallery3d")) ?
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) :
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA);
res = cursor.getString(columnIndex);
cursor.close();
}
if (TextUtils.isEmpty(res)) {
res = contentUri.getPath();
}
return res;
}

따라서 이미지는 Q 미만일 때 위의 코드를 그대로 사용하면서, 비디오를 업로드할 경우는 아래의 코드를 통해 새로 카피본을 만들어 File을 받아 처리하도록 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static File getVideoFile(Context context, Uri contentUri) throws SecurityException {
ContentResolver contentResolver = context.getContentResolver();
String filePath = context.getApplicationInfo().dataDir + "/" + UPLOAD_FILE_NAME_ORIGIN_VIDEO;
File file = new File(filePath);
if (file.exists()) {
return file;
}
try {
InputStream inputStream = contentResolver.openInputStream(contentUri);
OutputStream outputStream = new FileOutputStream(file);
byte[] buf = new byte[1024];
int len = 0;
while((len = inputStream.read(buf)) > 0) {
outputStream.write(buf, 0, len);
}
outputStream.close();
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
return file;
}

9. 파일 저장 위치

이미지의 경우, 사진 및 스크린샷을 포함하며 DCIM/ 및 Pictures/ 디렉터리에 저장된다. 시스템은 이러한 파일을 MediaStore.Images 테이블에 추가한다. 즉, 파일 탐색기에서 Images 영역에 가면 저장한 파일을 확인할 수 있다.

만약 DCIM/ 또는 Pictures/ 이외의 장소에 접근하려고 하면 아래와 같은 에러가 뜬다.

Caused by: java.lang.IllegalArgumentException: Primary directory TestDir not allowed for content://media/external_primary/images/media; allowed directories are [DCIM, Pictures]

파일 탐색기 > Images

이미지 외의 파일 종류에 관해서는 Android Developer - 공유 저장소의 미디어 파일에 액세스를 참조할 것.

10. Android 10 애뮬레이터 테스트

그런데 이 설정을 하지 않아도 Android 11 애뮬레이터로 처음 실행했을 때 관련 이슈는 바로 재현 가능했으니 참고 하시기.

테스트를 위해 필요한 플래그는 다음의 두 가지이다.

  • DEFAULT_SCOPED_STORAGE(기본적으로 모든 앱에 사용 설정됨)
  • FORCE_ENABLE_SCOPED_STORAGE(기본적으로 모든 앱에 사용 중지됨)

FORCE_ENABLE_SCOPED_STORAGE 플래그를 설정하기 위해 시스템 > 고급 > 개발자 옵션 > 앱 호환성 변경사항 > 앱 > 에서 FORCE_ENABLE_SCOPED_STORAGE 항목을 찾아 on 시킨다.

개발자 옵션 > 앱 호환성 변경사항

[Android] Android 11 대응 - 1. 패키지 공개 상태

변경사항 확인해보는 방법

Android Developer

문제가 될만 한 호출 메소드

  • packageManager.getInstalledApplications() 또는 packageManager.getInstalledPackages()

  • packageManager.resolveActivity(intent, 0)

  • packageManager.queryIntentActivities(intent, flags)

  • packageManager.getPackageInfo("packageName", flags)

  • packageManager.getLaunchIntentForPackage(packageName)

방법 1. QUERY_ALL_PACKAGES

QUERY_ALL_PACKAGES 권한을 추가하면 모든 앱을 찾거나 실행할 수 있다. 이 퍼미션은 어떤 앱이든 받을 수 있는 Install permission이다.

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

방법 2. <queries> 태그

gradle version 확인하기

queries 태그를 사용하기 위한 준비작업이다.

Android developer

좌측과 같은 버전을 사용하고 있다면 우측 이상에 해당하는 버전이 맞는지 확인해야 한다.

queries 태그 사용

이제 의존성을 가진 모든 앱의 패키지 정의를 queries 태그에 추가 적용한다.

Android11에서는 기본적으로 자신의 앱이 아닌, 다른 패키지를 찾거나 다른 패키지의 액티비티를 실행할 수 없다. 따라서 자신의 앱 동작에 의존적인 패키지가 있다면 AndroidManifest에 <queries> 태그로 필요한 패키지를 정의해야 한다.

다시 말해, 자신의 앱에서 다른 앱을 찾거나(탐색), 다른 앱을 실행하는 동작이 필요할 경우 그 앱의 패키지를 미리 자신의 앱에 정의해두어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
<!-- manifest 태그에 package 속성을 정의하는 것은 optional -->
<manifest package="com.example.game">
<queries>
<package android:name="com.example.store" />
<package android:name="com.example.services" />
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="text/plain" />
</intent>
</queries>
...
</manifest>

[Android] ViewPager.addOnPageChangeListener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
loopViewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
override fun onPageSelected(position: Int) {
loopViewPager.currentItem = position
}
override fun onPageScrolled(position: Int, positionOffset: Float,
positionOffsetPixels: Int) {
  }
override fun onPageScrollStateChanged(state: Int) {
if (state == RecyclerView.SCROLL_STATE_DRAGGING) {
// 수동으로 스크롤 시에만 터치했을 때
} else if (state == RecyclerView.SCROLL_STATE_SETTLING){
// 스크롤이 움직인 후 픽스되는 순간
} else if (state == RecyclerView.SCROLL_STATE_IDLE) {
// 아무것도 수행하지 않을 때
// (즉, 스크롤이 움직이지 않고, 스크롤을 수동으로 잡고 있지 않음.)
}
}
})

onPageSelected

  • 페이지 인덱스가 변경되지 않는다면 이 메소드는 호출되지 않는다. (수동으로 스크롤 하다가 뗐을 때 재현가능)
  • position: 스크롤 시 선택된 페이지의 인덱스

onPageScrolled

  • 사용자의 터치 스크롤 & smooth 스크롤 시에 호출된다.
  • position: 링크에 따르면, “SETTLE 상태에서는 대상 페이지가 넘어오고, DRAGGING 상태에서는 출발 페이지가 넘어온다”고 한다. 따라서 이를 활용하기는 어려울 것 같아 사용하지 않음.
  • positionOffset: 0.0 ~ 1 사이의 값. 왼쪽으로 스크롤 시 1 -> 0.0으로 감소하며, 오른쪽으로 스크롤 시 0.0 -> 1로 증가한다.

onPageScrollStateChanged

  • 페이지 인덱스가 변경될 경우, 변경되지 않을 경우 포함
  • 상태가 변경될 경우 한 번씩 호출된다.

이벤트 전달 순서

  • 수동으로 스크롤 시 페이지를 변경했을 때 순서 SCROLL_STATE_DRAGGING -> SCROLL_STATE_SETTLING -> onPageSelected SCROLL_STATE_IDLE
  • loopViewPager.setCurrentItem으로 스크롤될 경우 순서 SCROLL_STATE_SETTLING -> onPageSelected -> SCROLL_STATE_IDLE
  • 수동으로 스크롤 시 페이지를 변경하지 않았을 경우 순서 SCROLL_STATE_DRAGGING -> SCROLL_STATE_SETTLING -> SCROLL_STATE_IDLE

[Android] Parcelable: Java와 Kotlin 구현 차이

Parcelable을 상속받아 클래스 생성할 경우 CREATOR의 정의

Parcelable protocol requires a Parcelable.Creator object called CREATOR

1
2
3
4
5
6
7
8
9
10
11
public static final Creator<DataClass> CREATOR = new Creator<DataClass>() {
@Override
public SearchInfo createFromParcel(Parcel parcel) {
return new DataClass(parcel);
}

@Override
public DataClass[] newArray(int i) {
return new DataClass[i];
}
};

이걸 코틀린 코드로 변환하면 다음과 같이 만들 수 있으나-

1
2
3
4
5
6
7
8
9
10
@JvmField // 프로젝트에 자바 클래스도 존재하는 경우 JvmField 어노테이션 누락에 주의할 것 
val CREATOR: Parcelable.Creator<DataClass> = object : Parcelable.Creator<DataClass> {
override fun createFromParcel(parcel: Parcel): DataClass {
return DataClass(parcel)
}

override fun newArray(i: Int): Array<DataClass?> {
return arrayOfNulls<DataClass?>(i)
}
}

[코틀린] Parcelable 구현

-그러나 코틀린에서는 Parcelable 구현을 위해 @Parcelize 어노테이션을 제공하고 있기 때문에 꼭 필요한 경우가 아니면 자바와 같이 CREATOR를 만들 필요는 없다.

코틀린에서 구현한 Parcelable을 상속받은 Data Class는 다음과 같다. 이 코드는 이 자체만으로도 내부에서 CREATOR 기능을 수행한다.

1
2
3
4
5
6
7
8
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.android.parcel.Parcelize

@Parcelize
data class ParcelData(@SerializedName("A") var a: String? = "",
@SerializedName("B") var b: String? = "",
@SerializedName("C") var c: ArrayList<AnyData>? = arrayListOf()): Parcelable

참고를 위해 Parcelize 어노테이션에 작성된 주석의 일부를 가져왔다.

Instructs the Kotlin compiler to generate writeToParcel(), describeContents()[android.os.Parcelable] methods, as well as a CREATOR factory class automatically.

이는 자바 코드(또는 코틀린 코드) 상에서 putExtra를 통해 전달할 수 있다.

1
2
ParcelData p = new ParcelData();
intent.putExtra("extra", p);

그리고 intent를 통해 이동한 액티비티에서는 getParcelableExtra로 데이터를 받아올 수 있다.

[Android] 다른 앱 위에 그리기 권한: TYPE_APPLICATION_OVERLAY

[다른 앱 위의 그리기]는 폰 설정 > 애플리케이션 > 앱 > 고급 [다른 앱 위에 표시되는 앱]에서 권한 설정할 수 있다.

퍼미션

[다른 앱 위에 그리기] 설정을 추가하려면 Manifest에 이를 사용하겠다고 퍼미션을 추가해야하는데,

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

권한: 다른 앱 위에 그리기(Draw over other apps / Appear on top)

위 퍼미션을 추가하면 앱 설정(설정>애플리케이션>해당 앱)에 [다른 앱 위에 그리기] 정보가 뜬다. (안드로이드 버전마다 언어:영어일 때의 표시나 노출 위치는 다를 수 있다.)

중요한 것은 [다른 앱 위에 그리기]의 기본값이 버전마다 다르다는 건데,

  • API level 23(Android 6.0 Marshmallow) 미만에서는 true
  • 그 이상에서는 false

앱스토어에서 설치할 경우 기본적으로 위와 같이 설정되어 유저의 단말에 설치됨을 숙지하여 기능구현 프로세스를 짜야한다.

버전 체크 방법

[다른 앱 위에 그리기] 기능이 필요할 경우 API level 23 이상에서는 버전 체크 처리를 해주어야한다.

1
2
3
4
5
6
7
/* REQ_CODE_OVERLAY_PERMISSION는 임의로 정한 상수
onActivityResult(int requestCode, int resultCode, Intent data)에서 requestCode로 받을 때 사용함 */
@TargetApi(Build.VERSION_CODES.M)
private static void onObtainingPermissionOverlayWindow(Context context) {
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + context.getPackageName());
((Activity) context).startActivityForResult(intent, REQ_CODE_OVERLAY_PERMISSION);
}

덧붙여 [다른 앱 위에 그리기] 설정값이 true인지 확인하는 방법은 다음과 같다.

1
2
3
4
public static boolean alertPermissionCheck(Context context) {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
&& !Settings.canDrawOverlays(context);
}

주의: deprecated 된 WindowManager.LayoutParams의 플래그

  • TYPE_PHONE
  • TYPE_PRIORITY_PHONE
  • TYPE_SYSTEM_ALERT
  • TYPE_SYSTEM_ERROR
  • TYPE_SYSTEM_OVERLAY
  • TYPE_TOAST

위에 나열된 타입은 API level 26(Android 8.0 Oreo)에 deprecated 되어 아래와 같이 Android Developer 에서는 이것들 대신 TYPE_APPLICATION_OVERLAY를 쓰도록 가이드 하고 있다.

1
2
3
4
5
6
7
val param: WindowManager.LayoutParams
val flag = if (Build.VERSION.SDK_INT >= Build.Version_CODES.O) {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
}
param = WindowManager.LayoutParams(width,
WindowManager.LayoutParams.WRAP_CONTENT,
flag, ...);