안드로이드 코틀린 : 카메라 사진 찍기, 내부저장소, 외부저장소, 공용저장소 저장 방법 with FileProvider
Lucy Archive
Lucy 2023
2021. 4. 5. 00:07

Android Kotlin : 기본 카메라 앱 호출해서 사진 찍기

Intent의 암시적 호출 방법으로

카메라를 호출하여 찍은 사진의 미리보기 및 원본을 내부저장소, 외부저장소, 공용저장소의 특정 폴더에 저장하는 방법

을 정리하였습니다.

 

프로젝트 생성 및 환경 설정

프로젝트 생성

아래와 같이 프로젝트를 생성합니다.

  • 프로젝트명 : TakePicture
  • 사용언어 : Kotlin

※ 이 포스트는 Android Studio 4.1.2, Kotlin 1.4.31 버전으로 작성하였습니다.

 

(Gradle)ViewBinding 설정

App > Gradle Scripts > build.gradle 에 ViewBinding 설정을 위해 아래의 코드를 추가합니다. 코드 추가가 완료되면 Sync Now 를 클랫해서 Gradle 변경 사항을 적용합니다.

android { 
	... 
    viewBinding { 
    	enabled = true 
    } 
}

Gragle 수정 사항 적용

※ ViewBinding은 Layout에 있는 View의 Id를 코틀린 코드에서 직접 사용 할 수 있도록 해주는 도구입니다. View Binding과 관련된 설명은 아래의 링크를 참조해주세요.

안드로이드 View Binding 사용하기

 

안드로이드 View Binding 사용하기 - kotlin-android-extensions 지원 중단

안드로이드 View Binding 방법 정리 안드로이드 코드에서 레이아웃 View에 접근하기 위해 사용된 kotlin-android-extensions 의 지원이 중단예정으로, 이를 대체하여 사용 할 수 있는 ViewBinding 사용법에 대해

juahnpop.tistory.com

 

(Manifest)카메라 및 저장소 권한 설정

아래와 같이 Android.Manifest.xml 에 카메라와 저장소 사용 권한을 명세합니다.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.blacklog.continuouscamera">
    
    ...

    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

	...
    
</manifest>

카메라와 저장소는 개인정보와 관련된 기능으로 위험 권한에 속합니다. 위험 권한 명세 및 권한 요청 코드 작성하는 법은 아래 포스트를 참고해주세요.

안드로이드 위험 권한

 

안드로이드 코틀린 : 런타임 권한(위험 권한) 요청 코드 작성 방법 - 카메라 및 외부 저장소 권한

안드로이드 권한2 : 위험 권한 허가 확인 및 허가 요청 코드 작성 방법 이전 포스트에서 안드로이드 권한에 대해 간략히 설명하고 일반 권한 사용법을 정리하였습니다. 이번 포스트에서는 카메

juahnpop.tistory.com

 

(Manifest)FileProvider 설정

안드로이드에서 안전하게 앱간 파일을 전송하는 방법은 콘텐츠 URI를 전송하고 하는 방법을 사용합니다. FileProvider 라이브러리는 지정된 파일의 콘텐츠 URI를 생성할 수 있는 gerUriForFile() 메서드를 제공합니다. 자세한 내용은 안드로이드 개발자 가이드를 참고해주세요.

아래와 같이 Android.Manifest.xml 의 <application>태그 내에 <provider> 태그의 FileProvider 설정 코드 추가합니다. android : authorities 속성은 "앱 패키지명 + fileprovider" 로 설정하면 됩니다. 

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.blacklog.takepicture">

    ...

    <application>
        
        ...
        
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="com.blacklog.takepicture.fileprovider"
            android:grantUriPermissions="true"
            android:exported="false">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/filepaths" />
        </provider>
        
        ...
        
    </application>

</manifest>

위 코드를 작성하면 @xml/filepaths에 붉은 색이 표시되는데, 참조할 리소스 파일이 생성되지 않았기 때문입니다. 아래와 같이 app > res > xml 폴더 생성 후 filepaths.xml 파일을 추가합니다. 

리소스 폴더 및 filepaths.xml 파일 추가

생성된 filepaths.xml 파일에 아래와 같이 코드를 작성합니다. 

<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <files-path name="my_internal_images" path="image/" />
    <external-files-path name="my_external_images" path="image/" />
    <root-path name="root" path="./"/>
</paths>

 

레이아웃 작성

activity_main.xml 레이아웃 파일에 아래 코드와 같이 1개의 ImageView 파일과 5개의 Button을 추가합니다. 5개 버튼의 기능은 아래와 같습니다.

  • Button1 : 카메라 앱으로 사진 찍고, ImageView에 미리보기 
  • Button2 : Button1 기능 + 미리보기 비트맵 내부 저장소에 저장하기
  • Button3 : 카메라 앱으로 사진 찍고, 원본 사진 내부 저장소에 저장하기
  • Button4 : 카메라 앱으로 사진 찍고, 원본 사진 외부 저장소의 앱 디렉토리에 저장하기
  • Button5 : 카메라 앱으로 사진 찍고, MediaStore에 사진 저장하기

내부 저장소, 외부 저장소, 공용 저장소(MediaStore) 용어가 혼란스러우신 분은 아래의 게시글이나 안드로이드 개발자 가이드를 참조해주세요.

안드로이드 저장소 간략 정리

 

안드로이드 저장소 정리 : 앱 전용 디렉토리? 내부 저장소? 외부 저장소? 공용저장소

안드로이드 저장소 정리 안드로이드 앱 전용 디렉토리, 내부 저장소, 외부 저장소, 공용 저장소에 대한 용어 혼동이 있어 정리하였습니다. 세부 내용들이 필요하면 추가할 예정입니다. 용어 글

juahnpop.tistory.com

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.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"
    tools:context=".MainActivity">

    <ImageView
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_margin="8dp"
        app:layout_constraintBottom_toTopOf="@+id/btn1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/btn1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="1 : Take Picture and Preview"
        android:layout_margin="8dp"
        android:textSize="13sp"
        app:layout_constraintBottom_toTopOf="@+id/btn2" />

    <Button
        android:id="@+id/btn2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="2 : Save Preview in Internal Storage"
        android:layout_margin="8dp"
        android:textSize="13sp"
        app:layout_constraintBottom_toTopOf="@+id/btn3" />

    <Button
        android:id="@+id/btn3"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:text="3 : Save Original Picture(Internal Storage)"
        android:textSize="13sp"
        app:layout_constraintBottom_toTopOf="@+id/btn4" />

    <Button
        android:id="@+id/btn4"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:text="4 : Save Original Picture(External Storage)"
        android:textSize="13sp"
        app:layout_constraintBottom_toTopOf="@+id/btn5" />

    <Button
        android:id="@+id/btn5"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:text="5 : Save Original Picture(MediaStore)"
        android:textSize="13sp"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

activity_main.xml 미리보기

 

권한 코드 작성

MainActivity에 권한 코드와 앞으로 작성할 전체 코드 틀을 아래와 같이 작성하였습니다. 권한과 권한 요청 코드 작성 방법은 글의 상단 부의 링크를 참조해주세요.

package com.blacklog.takepicture

import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.blacklog.takepicture.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    // ViewBinding
    lateinit var binding : ActivityMainBinding

    // Permisisons
    val PERMISSIONS = arrayOf(
        Manifest.permission.CAMERA,
        Manifest.permission.WRITE_EXTERNAL_STORAGE,
        Manifest.permission.READ_EXTERNAL_STORAGE
    )
    val PERMISSIONS_REQUEST = 100

    // Request Code
    private val BUTTON1 = 100
    private val BUTTON2 = 200
    private val BUTTON3 = 300
    private val BUTTON4 = 400
    private val BUTTON5 = 500
    
    // 원본 사진이 저장되는 Uri
    private var photoUri: Uri? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        checkPermissions(PERMISSIONS, PERMISSIONS_REQUEST)

        binding.btn1.setOnClickListener {

        }
        binding.btn2.setOnClickListener {

        }
        binding.btn2.setOnClickListener {

        }
        binding.btn3.setOnClickListener {

        }
        binding.btn4.setOnClickListener {

        }
    }

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

                }
                BUTTON2 -> {

                }
                BUTTON3 -> {

                }
                BUTTON4 -> {

                }
                BUTTON5 -> {

                }
            }
        }
    }


    private fun checkPermissions(permissions: Array<String>, permissionsRequest: Int): Boolean {
        val permissionList : MutableList<String> = mutableListOf()
        for(permission in permissions){
            val result = ContextCompat.checkSelfPermission(this, permission)
            if(result != PackageManager.PERMISSION_GRANTED){
                permissionList.add(permission)
            }
        }
        if(permissionList.isNotEmpty()){
            ActivityCompat.requestPermissions(this, permissionList.toTypedArray(), PERMISSIONS_REQUEST)
            return false
        }
        return true
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        for(result in grantResults){
            if(result != PackageManager.PERMISSION_GRANTED){
                Toast.makeText(this, "권한 승인 부탁드립니다.", Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }
}

위 코드를 작성 후 어플을 처음 실행하면 아래와 같이 권한 요청 메세지가 나타납니다. 사용자가 권한을 수락을 하지 않으면, 앱에서 안내 메시지 출력하고 앱이 종료됩니다. 

권한 요청 승인

 

Button1 : 카메라 앱 실행 및 미리보기

onCreate 함수 내부 Button Listener 코드

Intent(MediaSotre.ACTION_IMAGE_CAPTURE) 는 암시적 호출하는 방법으로 카메라 앱을 호출합니다. resolveActivity() 메서드는 암시적 인텐트 호출 대상앱이 기기에 존재하는지를 확인합니다. 아래 코드에서는 카메라 앱이 존재하는지를 확인합니다. 카메라 앱이 존재하지 않고, startActivityForResult() 함수로 액티비티를 실행하면 프로그램은 종료됩니다. 이를 방지하기 위해 resolveActivity() 메서드를 사용합니다. 

binding.btn1.setOnClickListener {
    val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    takePictureIntent.resolveActivity(packageManager)?.also {
        startActivityForResult(takePictureIntent, BUTTON1)
    }
}

onActivityResult 함수 내부 when 조건문

카매라 앱으로 사진을 찍는 경우 미리보기용 사진이 intent.extra.get("data") 에 저장되어 있습니다.

BUTTON1 -> {
    val imageBitmap = data?.extras?.get("data") as Bitmap
    binding.imageView.setImageBitmap(imageBitmap)
}

실행 결과

위 코드를 실행하고 버튼을 누르면 호출된 카메라 앱에서 찍은 사진의 미리보기가 이미지뷰에 표시됩니다.

 

Button2 : 미리보기를 내부 저장소에 저장

onCreate 함수 내부 Button Listener 코드

Button2의 버튼 리스너 코드는 Button1과 동일합니다. startActivityForResult() 함수의 requestCode 인자 부분만 BUTTON2로 수정합니다.

binding.btn2.setOnClickListener {
    val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    takePictureIntent.resolveActivity(packageManager)?.also {
        startActivityForResult(takePictureIntent, BUTTON2)
    }
}

onActivityResult 함수 내부 when 조건문

onActivityResult 함수 내부의 when 조건문에 아래의 코드를 등록합니다. 

BUTTON2 -> {
    val imageBitmap = data?.extras?.get("data") as Bitmap
    saveBitmapAsJPGFile(imageBitmap)
    binding.imageView.setImageBitmap(imageBitmap)
}

saveBitmapAsFile() 메서드는 Bitmap 데이터를 비트맵 파일로 저장하는 함수입니다. 경로는 내부 저장소로 임의로 지정하였습니다. saveBitmapAsFile() 메서드내 newFileName() 메서드는 파일 이름 중복 제거를 위해 시간을 사용한 파일명을 생성하는 메서드입니다.

private fun newJpgFileName() : String {
    val sdf = SimpleDateFormat("yyyyMMdd_HHmmss")
    val filename = sdf.format(System.currentTimeMillis())
    return "${filename}.jpg"
}

private fun saveBitmapAsJPGFile(bitmap: Bitmap) {
    val path = File(filesDir, "image")
    if(!path.exists()){
        path.mkdirs()
    }
    val file = File(path, newJpgFileName())
    var imageFile: OutputStream? = null

    try{
        file.createNewFile()
        imageFile = FileOutputStream(file)
        bitmap.compress(Bitmap.CompressFormat.JPEG, 100, imageFile)
        imageFile.close()
        Toast.makeText(this, file.absolutePath, Toast.LENGTH_LONG).show()
    }catch (e: Exception){
        null
    }
}

실행 결과

위 코드를 실행 후 두번째 버튼을 누르면 카메라 앱에서 찍은 사진이 ImageView에 보여지고, 미리보기 파일이 내부저장소에 저장됩니다. 

미리보기 사진 저장

 

Button3 : 원본 사진 내부 저장소 저장

onCreate 함수 내부 Button Listener 코드

카메라 앱을 실행 후 원본 사진을 저장할 Uri 지정하고 싶은 경우 Intent.putExtra(Media.EXTRA_OUTPUT, Uri)의 Uri 매개변수에 Content URI를 입력합니다. 원하는 경로에 파일을 생성하고, FileProvider.getUriForFile() 메서드로 컨텐트 Uri생성이 가능합니다. FileProvider.getUriForFile() 메서드를 사용 할 경우, 기준이 되는 파일은 filepaths.xml 에 정의된 폴더 내에 존재해야 합니다. FileProvider.getUriForFile() 의 두번째 변수는 Manifest의 FileProvider에 정의된 Authority 와 동일하게 입력되어야 합니다. 

binding.btn3.setOnClickListener {
    val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    val photoFile = File(
            File("${filesDir}/image").apply{
                if(!this.exists()){
                    this.mkdirs()
                }
            },
            newJpgFileName()
    )
    photoUri = FileProvider.getUriForFile(
            this,
            "com.blacklog.takepicture.fileprovider",
            photoFile
    )
    takePictureIntent.resolveActivity(packageManager)?.also{
        takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
        startActivityForResult(takePictureIntent, BUTTON3)
    }
}

onActivityResult 함수 내부 when 조건문

ImageDecoder는 Android P 이상부터 사용할 수 있는 API로 PNG, JPEG, WEBP, GIF, HEIF를 Bitmap 객체로 변환하는 클래스입니다. Uri 로 부터 Bitmap 객체를 생성하는 경우 createSource() 메서드를 사용하고, 매개변수로 contentResolver와 uri를 입력 받습니다. 컨텐츠 Uri로 부터 File Path를 출력하기 위해 path 프로퍼티를 사용합니다. path프로퍼티는 getPath() 메서드의 반환값입니다.

BUTTON3, BUTTON4, BUTTON5 -> {
    val imageBitmap = photoUri?.let { ImageDecoder.createSource(this.contentResolver, it) }
    binding.imageView.setImageBitmap(imageBitmap?.let { ImageDecoder.decodeBitmap(it) })
    Toast.makeText(this, photoUri?.path, Toast.LENGTH_LONG).show()
}

Button3, Button4, Button5 모두 photoUri의 사진을 ImageView에 보여주고, 경로를 출력하기 때문에 onActivityResult 콜백 함수에서 동일한 코드가 사용됩니다. 

실행 결과

위 코드가 실행되면 호출된 카메라 앱으로 찍은 원본 사진이 내부저장소에 저장되고, 미리보기에 표시합니다.

원본 사진 내부 저장소 저장

Button4 : 원본 사진 외부 저장소(앱 디렉토리) 저장

photoFile 선언시 디렉토리 경로를 외부 저장소로만 지정하는 부분외 Button3코드와 동일합니다. . 외부 저장소 경로는 getExternalFilesDir() 함수로 불러올 수 있습니다.

binding.btn4.setOnClickListener {
    val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    val photoFile = File(
            File(getExternalFilesDir(Environment.DIRECTORY_PICTURES), "image").apply{
                if(!this.exists()){
                    this.mkdirs()
                }
            },
            newJpgFileName()
    )
    photoUri = FileProvider.getUriForFile(
            this,
            "com.blacklog.takepicture.fileprovider",
            photoFile
    )
    takePictureIntent.resolveActivity(packageManager)?.also{
        takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
        startActivityForResult(takePictureIntent, BUTTON3)
    }
}

위 코드가 실행되면 아래와 같이 원본 사진이 외부 저장소의 앱 디렉토리 경로에 저장됩니다. getExternalFilesDir() 로 지정된 경로는 자신의 앱만 접근 가능합니다.

원본 사진 외부 저장소 앱 디렉토리 저장

Button5 : 원본 사진 공용 저장소 특정 폴더에 저장

공용 저장소에 저장하기 위해 ContentValue() 객체를 사용합니다. 공용 저장소내의 특정 폴더 경로를 생성하고 싶은 경우 MediaStoreMediaColumns 속성을 사용합니다.

binding.btn5.setOnClickListener {
    val values = ContentValues()
    values.put(MediaStore.MediaColumns.DISPLAY_NAME, newJpgFileName())
    values.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpg")
    values.put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/TakePicture")

    val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    photoUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
    photoUri?.path?.let { it1 -> Log.d("PhotoURI", it1) }
    takePictureIntent.resolveActivity(packageManager)?.also{
        takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
        startActivityForResult(takePictureIntent, BUTTON3)
    }
}

Button5 실행 결과는 아래 그림과 같습니다.

실행 결과

 

정리

본 포스트의 내용을 요약하면 아래와 같습니다.

  • 카메라 앱 호출하기 위해 Intent의 암시적 호출 방법을 사용
  • 카메라 앱의 미리보기 사진은 data.extras.get("data") 에 저장되어 있음
  • 카메라 앱으로 원본 사진을 저장하기 위한 경로는 Intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri) 에 입력
  • Uri 로 부터 비트맵 객체를 만들기 위해 ImageDecoder 클래스를 사용

끝까지 읽어 주셔서 감사합니다.^^

관련포스트

🤞 안드로이드(코틀린) 앱 제작하기 관련글 목록 보기

🤞 안드로이드 권한 관련글 목록 보기

🤞 안드로이드(코틀린) Intent 관련글 목록 보기

🤞 안드로이드(코틀린) 저장소 관련글 목록 보기

🤞 안드로이드(코틀린) 파일 및 폴더 관련글 목록 보기

🤞 안드로이드(코틀린) FileProvider 관련글 목록 보기

🤞 안드로이드(코틀린) 카메라 관련글 목록 보기