[Android] 암시적 인텐트를 이용한 촬영 및 저장

암시적 인텐트를 사용해 사용자의 카메라 앱을 실행시켜서 사진을 찍고, 데이터에 추가로 저장하고 사용해보도록 한다. 사진을 어디에 저장하고 저장된 사진을 어떻게 보여주어야 할지도 본문에서 알아보도록 한다.

사진을 위한 장소

먼저 상세 내역 화면에 사진을 보여줄 곳을 만든다.

새로운 사용자 인터페이스

파일 스토리지

사진 파일은 화면이 아닌 다른 곳에 저장해야 한다. 그런데 실제 크기의 사진은 용량이 너무 커서 SQLite 데이터베이스에 넣기도 어렵다. 따라서 장치의 파일 시스템에 저장해야 한다.

다행스럽게도 이런 파일은 장치의 개인 스토리지 영역에 저장할 수 있다. SQLite 데이터베이스도 마찬가지다. Context.getFileStreamPath(String)이나 Context.getFilesDir() 같은 함수를 사용하면 일반 파일도 개인 스토리지 영역에 저장할 수 있다(SQLite 데이터베이스가 저장된 databases 서브 폴더와 인접한 다른 서브 폴더에 저장된다).

Context 클래스에 있는 기본적인 파일과 디렉터리 함수는 다음과 같다.

getFilesDir(): File

  • 앱 전용 파일들의 디렉터리 핸들을 반환한다.

openFileInput(name: String): FileInputStream

  • 데이터를 읽기 위해 파일 디렉터리의 기존 파일을 연다.

openFileOutput(name: String, mode: Int): FileOutputStream

  • 데이터를 쓰기 위해 파일 디렉터리의 파일을 연다(생성도 한다).

getDir(name: String, mode: Int): File

  • 파일 디렉터리 내부의 서브 디렉터리를 알아낸다.

fileList(...): Array<String>

  • 파일 디렉터리의 파일 이름들을 알아낸다. 예를 들면, openFileInput(String)과 함께 사용한다.

getCacheDir(): File

  • 캐시 파일 저장에 사용할 수 있는 디렉터리의 핸들을 반환한다. 단, 이 디렉터리는 가능한 한 작은 용량을 사용하도록 주의해야 한다.

그런데 문제가 있다. 개인 스토리지 영역의 파일들은 이 앱에서만 읽거나 쓸 수 있기 때문이다. 물론, 다른 앱에서 해당 파일들을 사용하지 않는다면 앞의 함수들만 사용해도 충분하다.

그러나 다른 애플리케이션이 파일에 써야 한다면 앞의 함수들로는 충분하지 않다. 본문의 CriminalIntent 앱의 경우가 바로 그렇다. 왜냐하면 외부의 카메라 앱에서 개인 스토리지 영역의 파일로 사진을 저장해야 하기 때문이다.

이때 Context.MODE_WORLD_READABLE 플래그를 openFileOutput(String, Int) 함수에 전달해서 쓸 수 있지만, 이제는 사용 금지되어 있어서 새로운 안드로이드 버전의 장치에서도 잘 된다는 보장이 없다. 그리고 이전에는 공용의 외부 스토리지를 사용해서 파일을 전송할 수 있었지만, 보안상의 이유로 최근 버전의 안드로이드에서는 금지되었다.

따라서 다른 앱과 파일을 공유하거나 받으려면 ContentProvider를 통해서 해야 한다. ContentProvider로 파일을 콘텐츠 URI로 다른 앱에 노출하면 다른 앱에서는 해당 URI로부터 파일을 다운로드하거나 쓸 수 있다. 그리고 제어할 수도 있으며, 읽거나 쓰는 것을 거부할 수 있다.

FileProvider 사용하기

다른 앱으로부터 파일을 받는 것이 전부라면 굳이 ContentProvider 전체를 구현할 필요 없다. 이런 용도로 사용하라고 구글에서는 FileProvider라는 편의 클래스를 제공한다.

ContentProvider로 FileProvider를 선언하기 위해 매니페스트 파일에 콘텐츠 제공자 선언을 추가한다.

FileProvider 선언 추가하기 (manifests/AndroidManifest.xml)

1
2
3
4
5
6
7
8
9
10
11
<activity android:name=".MainActivity">
...
</activity>

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.june0122.criminalintent.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
</provider>
...

여기서 android:authorities 속성은 이 FileProvider의 파일이 저장되는 위치이며, 시스템 전체에서 고유한 문자열이어야 한다. 따라서 패키지 이름을 문자열에 포함하는 것이 좋다(여기선 com.june0122.criminalintent가 패키지 이름).

그리고 exported=“false” 속성을 추가하면 우리 자신 및 우리가 권한을 부여한 사람 외에는 FileProvider를 사용할 수 없다. 그리고 grantUriPermissions 속성을 추가하면 인텐트로 android:authorities의 URI를 전송할 때 전송된 URI에 다른 앱이 쓸 수 있는 권한을 부여할 수 있다.

안드로이드 시스템에 FileProvider가 어디에 있는지 알려주었으니, 어떤 경로 path의 파일들을 노출할 것인지도 별도의 XML 리소스 파일에 정의해서 FileProvider에게 알려준다. app/res 폴더에서 'files’라는 이름의 XML Resource type의 Android Resource File을 생성하고 아래와 같이 코드를 변경한다.

경로 추가하기 (res/xml/files.xml)

1
2
3
4
5
6
7
<!--
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
</PreferenceScreen>
-->
<paths>
<files-path name="crime_photos" path="." />
</paths>

이 XML 파일은 개인 스토리지의 루트 경로를 crime_photos로 매핑하며, 이 이름은 FileProvider가 내부적으로 사용한다.

다음으로 매니페스트 파일에 meta-data 태그를 추가해 FileProvider에 files.xml을 연결한다.

경로를 FileProvider에 연결하기 (manifests/AndroidManifest.xml)

1
2
3
4
5
6
7
8
9
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.june0122.criminalintent.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/files" />
</provider>

사진 위치 지정하기

다음으로 사진을 개인 스토리지에 저장할 위치를 지정한다. 먼저 파일 이름을 얻는 연산 속성 computed property을 Crime 클래스에 추가한다(코틀린에서 연산 속성은 다른 속성의 값으로 자신의 값을 산출하므로 값을 저장하는 필드 즉, backing field를 갖지 않는다).

파일 이름 속성 추가하기 (Crime.kt)

1
2
3
4
5
6
7
8
9
10
@Entity
data class Crime(@PrimaryKey val id: UUID = UUID.randomUUID(),
var title: String = "",
var date: Date = Date(),
var isSolved: Boolean = false,
var suspect: String = "") {

val photoFileName
get() = "IMG_$id.jpg"
}

photoFileName은 사진 파일이 저장되는 폴더의 경로를 포함하지 않지만, 파일 이름은 고유한 것이 된다. 이는 Crime 클래스의 id 속성 값이 이름 속에 포함되어 있기 때문이다.

다음으로 사진이 저장되는 위치를 찾는다. 본문의 앱에서는 CrimeRepository가 데이터 저장에 관련된 모든 것을 책임지고 있으므로 CrimeRepository에 getPhotoFile(Crime) 함수를 추가한다. 이 함수는 Crime 클래스의 photoFileName 속성이 참조하는 사진 파일의 경로를 제공한다.

사진 파일 위치 찾기 (CrimeRepository.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CrimeRepository private constructor(context: Context) {
...
private val executor = Executors.newSingleThreadExecutor()
private val filesDir = context.applicationContext.filesDir

...

fun addCrime(crime: Crime) {
...
}

fun getPhotoFile(crime: Crime): File = File(filesDir, crime.photoFileName)
...
}

이 코드에서는 파일 시스템의 어떤 파일도 생성하지 않는다. 단지 올바른 위치를 가리키는 File 객체만 반환한다. 향후에 FileProvider를 사용해서 이 경로를 URI로 노출할 것이다.

끝으로 사진 파일 정보를 CrimeFragment에 제공하는 함수를 CrimeDetailViewModel에 추가한다.

CrimeDetailViewModel을 통해 사진 파일 정보 제공하기 (CrimeDetailViewModel.kt)

1
2
3
4
5
6
7
8
9
10
11
12
class CrimeDetailViewModel: ViewModel() {
...

fun saveCrime(crime: Crime) {
crimeRepository.updateCrime(crime)
}

// 사진 파일 정보를 CrimeFragment에 제공하는 함수 추가
fun getPhotoFile(crime: Crime): File {
return crimeRepository.getPhotoFile(crime)
}
}

카메라 인텐트 사용하기

다음으로 할 일은 실제로 사진을 찍을 수 있게 하는 작업이다. 암시적 인텐트를 사용하면 되므로 매운 쉬운 작업이다.

먼저, 사진 파일의 위치를 CrimeFragment의 photoFile 속성에 저장한다. 이 속성은 이후에도 몇 번 더 사용한다.

사진 파일 위치 저장하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CrimeFragment : Fragment(), DatePickerFragment.Callbacks, TimePickerFragment.Callbacks {

private lateinit var crime: Crime
private lateinit var photoFile: File
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
crimeDetailViewModel.crimeLiveData.observe(
viewLifecycleOwner,
Observer { crime ->
crime?.let {
this.crime = crime
photoFile = crimeDetailViewModel.getPhotoFile(crime) // 사진 파일 위치 저장
updateUI()
}
})
}
...
}

다음으로 사진을 찍고 받을 수 있게 MeidaStore를 사용해 카메라 버튼을 코드와 연결한다. MeidaStore는 미디어와 관련된 모든 것을 갖고 있는 안드로이드 클래스다.

인텐트 실행 요청하기

카메라 인텐트를 실행 요청할 준비가 되었다인텐트는 안드로이드 운영체제가 실행하므로 실행을 요청한다는 표현이 적합. 이때 필요한 액션은 ACTION_IMAGE_CAPTURE이며, MediaStore 클래스에 상수로 정의되어 있다. 여기서는 MediaStore.ACTION_IMAGE_CAPTURE 액션을 갖는 암시적 인텐트를 요청하면 안드로이드가 카메라 액티비티를 시작시켜 사진을 찍을 수 있다. MediaStore에는 이미지, 비디오, 음악 등의 미디어를 처리하는 안드로이드에서 사용되는 public 인터페이스가 정의되어 있다. 그리고 카메라 앱을 시작시키는 이미지 캡처 인텐트 상수도 포함한다.

기본적으로 ACTION_IMAGE_CAPTURE 액션은 카메라 앱을 시작시키고 찍은 사진을 받을 수 있게 해준다. 다만 전체 해상도의 사진은 아니고 낮은 해상도의 섬네일 사진이다. 그리고 찍은 사진은 onActivityResult(...)에서 반환하는 Intent 객체에 포함된다.

전체 해상도의 사진을 받으려면 이미지를 저장할 파일 시스템의 위치를 알려주어야 한다. 이때는 MediaStore.EXTRA_OUTPUT 상수를 엑스트라의 키로, 사진 파일을 저장할 위치를 가리키는 Uri를 엑스트라의 값으로 설정해 인텐트에 전달하면 된다. 여기서 UriFileProvider에 의해 서비스되는 위치를 가리킨다.

우선 사진 URI를 저장하는 photoUri 속성을 추가한다. 그리고 사진 파일의 참조를 얻은 후에 FileProvider가 반환하는 Uri로 photoUri 속성을 초기화한다.

사진 URI 속성 추가하고 초기화하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class CrimeFragment : Fragment(), DatePickerFragment.Callbacks, TimePickerFragment.Callbacks {

private lateinit var crime: Crime
private lateinit var photoFile: File
private lateinit var photoUri: Uri
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
crimeDetailViewModel.crimeLiveData.observe(
viewLifecycleOwner,
Observer { crime ->
crime?.let {
this.crime = crime
photoFile = crimeDetailViewModel.getPhotoFile(crime)
photoUri = FileProvider.getUriForFile(
requireActivity(),
"com.june0122.criminalintent.fileprovider",
photoFile
)
updateUI()
}
})
}

FileProvider.getUriForFile(...)을 호출하면 로컬 파일 시스템의 파일 경로를 카메라 앱에서 알 수 있는 Uri로 변환한다. 이 함수의 두 번째 인자는 FileProvider를 나타내며, 매니페스트의 android:authorities 속성에 정의했던 것과 같아야 한다.

다음으로 photoUri가 가리키는 위치에 저장할 새로운 사진을 요청하는 암시적 인텐트를 작성한다. 그리고 카메라 앱이 장치에 없거나 사진을 저장할 위치가 없으면, 카메라 버튼을 비활성화하는 코드도 추가한다(사용할 수 있는 카메라 앱이 있는지 판단하기 위해 여기서는 카메라 암시적 인텐트에 응답하는 액티비티의 PackageManager를 쿼리한다).

카메라 인텐트 실행 요청하기 (CrimeFragment.kt)

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
42
private const val REQUEST_CONTACT = 2
private const val REQUEST_PHOTO = 3
private const val DATE_FORMAT = "yyyy년 M월 d일 H시 m분, E요일"

class CrimeFragment : Fragment(), DatePickerFragment.Callbacks, TimePickerFragment.Callbacks {
...
override fun onStart() {
...
suspectButton.apply {
...
}

photoButton.apply {
val packageManager: PackageManager = requireActivity().packageManager

val captureImage = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
val resolvedActivity: ResolveInfo? =
packageManager.resolveActivity(captureImage, PackageManager.MATCH_DEFAULT_ONLY)
if (resolvedActivity == null) {
isEnabled = false
}

setOnClickListener {
captureImage.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)

val cameraActivities: List<ResolveInfo> =
packageManager.queryIntentActivities(captureImage, PackageManager.MATCH_DEFAULT_ONLY)

for (cameraActivity in cameraActivities) {
requireActivity().grantUriPermission(
cameraActivity.activityInfo.packageName,
photoUri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}

startActivityForResult(captureImage, REQUEST_PHOTO)
}
}
}
...
}

photoUri가 가리키는 위치에 실제로 사진 파일을 쓰려면 카메라 앱 퍼미션이 필요하다. 따라서 cameraImage 인텐트를 처리할 수 있는 모든 액티비티에 Intent.FLAG_GRANT_WRITE_URI_PERMISSION을 부여한다(매니페스트에 grantUriPermissions 속성을 추가했으므로 이처럼 퍼미션을 부여할 수 있다). 이렇게 하면 해당 액티비티들이 Uri에 쓸 수 있는 퍼미션을 갖는다.

앱을 실행해 상세 내역 화면에서 카메라 버튼을 눌러보면 각자 장치에 설치된 카메라 앱이 실행되는 것을 확인할 수 있다.

비트맵의 크기 조정과 보여주기

이제는 사진을 찍을 수 있게 되었다. 그리고 이 앱에서 사용할 수 있도록 사진은 파일 시스템의 파일로 저장된다.

다음으로 사진 파일을 읽어서 로드한 후 사용자에게 보여주자. 이렇게 하려면 적합한 크기의 Bitmap 객체로 로드해야 한다. 파일로부터 Bitmap 객체를 얻을 때는 BitmapFactory 클래스를 사용하면 된다.

1
val bitmap = BitmapFactory.decodeFile(photoFile.getPath())

그런데 한 가지 문제가 있다. 바로 적합한 크기에 관한 것이다. Bitmap은 화소 pixel 데이터를 저장하는 간단한 객체다. 즉, 원래 파일이 압축되었더라도 Bitmap 자체는 압축되지 않는다. 따라서 1600만 화소의 24비트 카메라 이미지는 5MB 크기의 JPG로 압축될 수 있지만, Bitmap 객체로 로드하면 48MB 크기로 커진다.

이 문제를 해결하려면 직접 비트맵의 크기를 줄여야 한다. 이때 파일 크기를 먼저 확인하고, 지정된 영역에 맞추기 위해 얼마나 줄여야 할지 파악한 후 해당 파일을 읽어서 크기를 줄인 Bitmap 객체를 생성하면 된다.

PictureUtil.kt라는 이름의 새로운 코틀린 파일을 생성하고 getScaledBitmap(String, Int, Int)라는 이름의 파일 수준 함수(코틀린 파일 내에서 클래스 외부에 정의된 함수이며, 앱의 어떤 코드에서도 사용 가능)를 추가한다.

getScaledBitmap(...) 함수 생성하기 (PictureUtils.kt)

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
fun getScaledBitmap(path: String, destWidth: Int, destHeight: Int): Bitmap {
// 이미지 파일의 크기를 읽는다
var options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(path, options)

val srcWidth = options.outWidth.toFloat()
val srcHeight = options.outHeight.toFloat()

// 크기를 얼마나 줄일지 파악한다
var inSampleSize = 1
if (srcHeight > destHeight || srcWidth > destWidth) {
val heightScale = srcHeight / destHeight
val widthScale = srcWidth / destWidth

val sampleScale = if (heightScale > widthScale) {
heightScale
} else {
widthScale
}
inSampleSize = Math.round(sampleScale)
}

options = BitmapFactory.Options()
options.inSampleSize = inSampleSize

// 최종 Bitmap을 생성한다
return BitmapFactory.decodeFile(path, options)
}

여기서 중요한 것은 inSampleSize다. 각 화소에 대해 각 '샘플 sample’이 얼마나 큰지를 결정한다. 예를 들어, inSampleSize가 1이면 원래 파일의 각 수평 화소당 하나의 최종 수평 화소를 갖는다. 그리고 2이면 원래 파일의 두 개의 수평 화소마다 하나의 수평 화소를 갖는다. 따라서 inSampleSize가 2일 때는 원래 이미지 화소의 1/4에 해당하는 화소 개수를 갖는 이미지가 된다.

그런데 문제가 하나 더 있다. 프래그먼트가 최초로 시작될 때는 PhotoView의 크기를 미리 알 수 없다. 왜냐하면 프래그먼트의 onCreate(...)onStart(...)onResume(...)이 차례대로 호출되어 실행된 후에 레이아웃이 뷰 객체로 생성되기 때문이다(레이아웃이 뷰 객체로 생성될 때까지는 이것의 뷰들이 화면상의 크기를 갖지 않는다).

이 문제의 해결 방법은 두 가지가 있다. 레이아웃이 뷰 객체로 생성될 때까지 기다리거나, PhotoView의 크기가 어느 정도 될지 추정하는 것이다. 크기를 추정하는 방법은 효율성은 떨어지지만 구현은 쉽다.

여기서는 파일 수준 함수인 getScaledBitmap(String, Activity)를 작성해 특정 액티비티의 화면 크기에 맞춰 Bitmap의 크기를 조정한다.

크기 추정 함수 추가하기 (PictureUtils.kt)

1
2
3
4
5
6
7
8
9
10
11
12
fun getScaledBitmap(path: String, activity: Activity): Bitmap {
val size = Point()

@Suppress("DEPRECATION")
activity.windowManager.defaultDisplay.getSize(size)

return getScaledBitmap(path, size.x, size.y)
}

fun getScaledBitmap(path: String, destWidth: Int, destHeight: Int): Bitmap {
...
}

getScaledBitmap(String, Activity) 함수에서는 화면 크기를 확인해서 해당 크기에 맞춰 이미지 크기를 줄이기 위해 오버로딩된 getScaledBitmap(String, Int, Int) 함수를 호출한다.

다음으로 ImageView에 Bitmap을 로드하기 위해 CrimeFragment에 새로운 함수를 추가하고 photoView를 변경한다.

photoView 변경하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class CrimeFragment : Fragment(), DatePickerFragment.Callbacks, TimePickerFragment.Callbacks {
...
private fun updateUI() {
...
}

private fun updatePhotoView() {
if (photoFile.exists()) {
val bitmap = getScaledBitmap(photoFile.path, requireActivity())
photoView.setImageBitmap(bitmap)
} else {
photoView.setImageDrawable(null)
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
...
}
...
}

그다음에 updatePhotoView() 함수를 updateUI()onActivityResult(...) 내부에서 호출하게 한다.

updatePhotoView() 호출하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class CrimeFragment : Fragment(), DatePickerFragment.Callbacks, TimePickerFragment.Callbacks {
...
private fun updateUI() {
...
if (crime.suspect.isNotEmpty()) {
suspectButton.text = crime.suspect
}
updatePhotoView()
}
...

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when {
resultCode != Activity.RESULT_OK -> return

requestCode == REQUEST_CONTACT && data != null -> {
...
}

requestCode == REQUEST_PHOTO -> {
updatePhotoView()
}
}
}

이제는 카메라 앱에서 저장한 사진 파일을 처리할 수 있게 되었다. 따라서 Uri에 파일을 쓸 수 있는 퍼미션을 취소할 수 있다. 카메라 앱에서 정상적으로 사진 파일을 쓴 이후에 URI 퍼미션을 취소하도록 onActivityResult(...)를 변경하고 onDetach()를 추가해보자(onDetach()는 부적합한 응답이 생길 가능성에 대비한 것이다).

URI 퍼미션 취소하기 (CrimeFragment.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class CrimeFragment : Fragment(), DatePickerFragment.Callbacks, TimePickerFragment.Callbacks {
...
override fun onStop() {
...
}

override fun onDetach() {
super.onDetach()
requireActivity().revokeUriPermission(photoUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
updatePhotoView()
}

...

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when {
...

requestCode == REQUEST_PHOTO -> {
requireActivity().revokeUriPermission(photoUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
updatePhotoView()
}
}
}

앱을 다시 실행해 범죄 리스트에서 한 항목을 클릭한 후 상세 내역 화면에서 카메라 버튼을 눌러 카메라 앱이 실행되면 사진을 찍는다. ‘확인’ 또는 ‘다시 시도’ 선택 버튼이 나타나면 '확인’을 클릭한다. 그러면 아래의 이미지와 같이 사진의 섬네일 이미지가 상세 내역 화면에 나타난다.

상세 내역 화면에 나타난 섬네일 이미지

사용하는 장치 기능 선언하기

사진 관련 기능을 구현해보았다. 그런데 할 일이 한 가지 더 있다. 앱에서 장치마다 다를 수 있는 기능(카메라나 NFC 등)을 사용할 때는 안드로이드에게 알려주는 것이 좋다. 장치가 지원하지 않는 기능을 앱이 사용하면 다른 앱(예를 들어, 플레이스토어)에서 해당 앱의 설치를 거부할 수 있기 때문이다.

카메라 사용을 선언하기 위해 매니페스트에 <uses-feature> 태그를 추가한다.

<uses-feature> 태그 추가하기 (manifests/AndroidManifest.xml)

1
2
3
4
5
6
7
8
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.june0122.criminalintent">

<uses-feature
android:name="android.hardware.camera2"
android:required="false" />
...
</manifest>

여기서는 생략 가능한 속성인 android:required 가 있다. 왜 그랬을까? 이 속성값을 true로 지정하면 해당 기능 없이는 앱이 제대로 동작하지 않음을 의미한다. 그런데 본문의 앱에서는 그렇지 않으므로 false를 지정하였다. 왜냐하면 resolveActivity(...)를 호출해 작동 가능한 카메라 앱이 있는지 확인해서 없으면 카메라 버튼을 사용할 수 없게 비활성화하기 때문이다.

즉, android:required 속성의 값으로 false를 지정하면 카메라 없이도 앱이 잘 실행될 수 있음을 안드로이드에게 알려주는 것이다.

댓글