- 새 기기에서 앱 복원
- 사용자가 앱 제거/재설치
- 사용자가 앱 데이터 소거
- 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일)로 예상된다.
이러한 Target를 상속받는 클래스를 만들어서 placeholder는 onLoadStarted, error는 onLoadFailed에 각각 정의하려고 한다. 이때 로딩이미지는 drawable 리소스로 앱에 저장되어있기 때문에 DrawableImageViewTarget을 상속받아 만든 게 아래와 같다.
@Override publicvoidonResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition){ ImageView imageView = getView(); imageView.setImageResource(0); // 로딩 이미지가 보여지고 있을 때 scaleType이 바뀌면서 이미지가 덜그럭 거리는 이슈 방어 imageView.setScaleType(mReadyScaleType); super.onResourceReady(resource, transition); }
그러나 이 방식을 적용한 ImageView가 담긴 RecyclerView를 구현하게 되면, RecyclerView 각 아이템이 재사용되면서 ScaleLoadingImageViewTarget의 생성자에서 호출하는 view.getScaleType()의 값이 기대한 대로 나오지 않는 경우가 있어 문제가 될 수 있다.
현재 한 Activity가 상하 스크롤이 되는 RecyclerView로 구성되어 있고, 그 아이템 중 하나가 좌우 스크롤 RecyclerView를 가지고 있다.
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의 맨 앞부분으로 스크롤이 움직이기 때문에 이를 현재 스크롤하던 위치로 옮겨주기 위함이다.
그리고 저장한 scrollOffset을 사용하여 스크롤을 유지할 수 있는 함수는 아래와 같이 구성하였다.
1 2 3 4 5 6 7 8 9 10
privatefunkeepScroll(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)
이 글은 실제로는 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 권한도 허용한다.
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중이라면 다른곳에서 데이터요구를 무시
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
publicstatic 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을 받아 처리하도록 한다.
이미지의 경우, 사진 및 스크린샷을 포함하며 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]
Parcelable protocol requires a Parcelable.Creator object called CREATOR
1 2 3 4 5 6 7 8 9 10 11
publicstaticfinal Creator<DataClass> CREATOR = new Creator<DataClass>() { @Override public SearchInfo createFromParcel(Parcel parcel){ returnnew DataClass(parcel); }
@Override public DataClass[] newArray(int i) { returnnew DataClass[i]; } };
이걸 코틀린 코드로 변환하면 다음과 같이 만들 수 있으나-
1 2 3 4 5 6 7 8 9 10
@JvmField// 프로젝트에 자바 클래스도 존재하는 경우 JvmField 어노테이션 누락에 주의할 것 val CREATOR: Parcelable.Creator<DataClass> = object : Parcelable.Creator<DataClass> { overridefuncreateFromParcel(parcel: Parcel): DataClass { return DataClass(parcel) }
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로 데이터를 받아올 수 있다.
위에 나열된 타입은 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, ...);