기술 블로그 - AdSense 광고 적용

광고 게재 승인을 기다리는 와중에 기존에 사용하던 티스토리 블로그를 정리하려고 갔는데 우연치않게 [수익]란을 보게 되었다.

현재 운영중이진 않아서 3개월 내 공개 발행글이 없는데 AdSense의 승인이 지연되는 이유가 발행글이 10 아래여서 인 것 같아서 포스팅을 늘리면서 기다려보기로 함. 20개 포스팅이 넘으면 다시 확인해볼 예정.

Google AdSense: 15일 소요(2022.02.01 - 02.15 중 포스팅 16개 작성)

이카루스 테마에서는 사이드바에 광고를 넣을 수 있도록 제공되는데, 여기에 광고를 넣으려면 _config.icarus.yml에서 client_id와 slot_id를 기입해주면 된다.

아래 작업을 하려면 우선 Google AdSense 내에서 계정을 검토하는 과정을 거쳐야하는데, 이게 2주까지 걸릴 수도 있다고 하더니 내 경우 15일 걸렸다. 막 만든 사이트고(2월 1일 신청당시 포스팅 3개), 검토 요청을 한 2월 1-2일이 설연휴라 더 늦어졌을지도 모르겠다.

승인이 나면 승인되었다는 메일이 오고, Adsense 페이지에 들어가면 다음과 같이 [준비됨]이라고 바뀌어있다.

(1) ads.txt 삽입

아래 나오는대로 ads.txt 파일을 다운받아 루트(source/)에 넣고 hexo cleanhexo g 하면 public/ 하위에 생성된 것을 볼 수 있을 것이다.

이 문제는 확인되어 해결되는 데까지 며칠 걸리는 것 같지만 광고 노출 자체는 승인 후 하루 지나서 바로 되어 문제삼지 않았다. (저 빨간 “수익 손실 위험” 팝업은 광고가 노출 되어도 계속 떠있었다.)

(2) 신규 광고 단위 만들기: 사이드 메뉴에 광고 넣기

우선 AdSense 사이드 메뉴의 [개요]에서 상단 탭의 [광고 단위 기준]으로 이동하면 디스플레이/인피드/콘텐츠 내 광고 중 타입을 골라서 단위를 생성할 수 있다.

이카루스 테마에서는 _config.icarus.yml 설정파일에서 위젯으로 제공하기 때문에 여기에 넣을 client ID와 unit ID만 만들어주면 된다.

1
2
3
4
5
6
7
8
9
widgets:
-
# Where should the widget be placed, left sidebar or right sidebar
position: left
type: adsense
# AdSense client ID
client_id: 'ca-pub-0'
# AdSense AD unit ID
slot_id: '0000000000'

내 경우 디스플레이 타입으로 생성하여 만들어진 코드에서 data-ad-client, data-ad-slot 속성값을 각각 client_id, slot_id로 붙여넣었다.

(3) 콘텐츠 내에 광고 삽입

콘텐츠 내에 광고는 포스팅 내에 광고 삽입을 말한다. 이 경우 [광고 단위 기준]의 콘텐츠 내 타입으로 생성하였는데 사이드 메뉴에 넣을 디스플레이 타입과는 다르게 아래와 같이 생성된 스크립트 코드를 직접 포스팅 내에 삽입해주어야 한다.

광고 단위가 처음 만들어져 게시되는 경우 1시간 내외로 시간이 걸릴 수 있다.

콘텐츠 내 타입 광고는 아래와 같이 나타나게 된다!

-

[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