안드로이드/개발 관련

[Kotlin] 안드로이드 인스타그램 스토리 공유하기 기능 사용해보기. (예제 프로젝트)

kimyunseok 2021. 10. 25. 13:22
 

스토리에 공유하기 - Instagram 플랫폼 - 문서 - Facebook for Developers

개요 Android 암시적 인텐트 및 iOS 맞춤 URL 스키마를 사용하면 앱에서 사진, 동영상과 스티커를 Instagram 앱으로 보낼 수 있습니다. Instagram 앱이 해당 콘텐츠를 받아서 스토리 작성기에 읽어들이면

developers.facebook.com

가끔 화면에서 특정 화면을 인스타그램 스토리에 공유하는 기능을 본 적이 있을 것이다.

예를 들자면, 멜론에서 음악을 듣는 화면을 공유하는 것이 그 예시이다.

 

오늘은 이 기능을 구현한 예제 프로젝트를 정리해볼 것이다.

위 링크를 가면 Android 개발자를 위해 설명해 놓은 글들이 있다.

 

  • 암시적 인텐트를 사용
  • 배경 레이어, 스티커 레이어 존재.

이 두가지가 주요한 특징이다.

 

Android 개발자를 위한 글들을 더 살펴보자면,

위처럼 나와있다. 주요 특징은 꼭 기기의 로컬 파일에 대한 Uri를 넘겨줘야 한다는 것이다.

지난 번 이미지 저장하는 것에 대해서 정리했었는데,

인스타그램 공유하기 기능을 구현하기 위해서는 이미지 저장하는 기능 역시 구현할 줄 알아야 한다는 뜻이다.

 

우리가 해야할 일은

View -> Bitmap -> Image File(Uri) -> 인스타그램 앱에 암시적으로 전달

이다. 

 

이번에 정리할 코드이다. 배경 자산(배경 레이어)과 스티커 자산(스티커 레이어)를 인텐트를 통해 넘겨줄 것이다.

 

배경 자산과 스티커 자산은 각각 멜론 앱 인스타 공유 기능을 예시로 들면 다음과 같다.

  • 배경 자산 : 말 그대로 배경. 움직이지 않는다.
  • 스티커 자산 : 움직이거나 크기를 조절할 수 있는 스티커이다. 앨범아트, 가수명, 노래제목 등이 들어간다.

 

추가로 왜 암시적 인텐트인지는 위 코드를 통해 할 수 있다. 특정 Activity를 확정짓지 않고, String형태로 전달하기 때문에 암시적 인텐트라고 하는 것이다. (사실 instagram이 들어가 있어서 명시적이라고 생각도 되긴하는데.. 애매한 것 같다. 문법 상으로는 암시적이 맞긴 하다.)

 

이제 코드를 정리하겠다.

 

[Kotlin] 이미지 저장 기능 사용해보기. Bitmap -> Image File (View, Layout을 이미지로 저장해보기. Android 10

앱별 파일에 액세스  | Android 개발자  | Android Developers 앱별 파일에 액세스 대부분의 경우 앱은 다른 앱에서 액세스할 필요가 없거나 액세스하면 안 되는 파일을 만듭니다. 시스템에서 제공하는

kimyunseok.tistory.com

지난 번 이미지 저장 기능을 구현했던 코드를 많이 재사용할 것이다.

여기서 이미지 저장에 관한 부분은 바뀐 부분만 짤막하게 정리하고 넘어갈 것이다.

 

구현코드정리

 

사용한 파일

사용한 파일들이다.

추가된 파일은 res\xml 디렉토리가 추가되었다.

Android Q 미만 버전에서 FileProvider를 통해 Uri를 가져오게 되는데 이 부분에 관련된 것이다. 뒤에서 정리하겠다.

화면 구성은 바뀐게 거의 없다.

마지막 버튼 색깔과 버튼 이름만 바꾸었다.

 

레이아웃 코드는 생략하겠다.

 

뷰모델 클래스도 바뀐 게 없으므로 코드를 생략하겠다.

 

package com.khs.instagramshareexampleproject

import android.Manifest
import android.content.ActivityNotFoundException
import android.content.ContentValues
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.view.View
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModelProvider
import com.khs.instagramshareexampleproject.databinding.ActivityMainBinding
import com.khs.instagramshareexampleproject.model.MainViewModel
import java.io.*
import java.lang.Exception

class MainActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)

        initViewModel()
        setUpLifeCycleOwner()

        setContentView(binding.root)
    }

    private fun initViewModel() {
        // 뷰모델 초기화 메서드
        binding.viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
    }

    private fun setUpLifeCycleOwner() {
        binding.lifecycleOwner = this
        /*
        * LiveData에서는 LifeCycleOwner만 지정해주면
        * invalidateAll() 메서드를호출하지 않아도
        * DataBinding에서 ViewModel의 값 변동을 감지하고 View Update를 해준다.
        * */
    }

    fun instaShareBtn(view: View) {

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            val bgBitmap = drawBackgroundBitmap()
            val bgUri = saveImageOnAboveAndroidQ(bgBitmap)

            val viewBitmap = drawViewBitmap()
            val viewUri = saveImageOnAboveAndroidQ(viewBitmap)

            instaShare(bgUri, viewUri)
        } else {
            // Q 버전 이하일 경우. 저장소 권한을 얻어온다.
            val writePermission = ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)

            if(writePermission == PackageManager.PERMISSION_GRANTED) {
                val bgBitmap = drawBackgroundBitmap()
                val bgUri = saveImageOnUnderAndroidQ(bgBitmap)

                val viewBitmap = drawViewBitmap()
                val viewUri = saveImageOnUnderAndroidQ(viewBitmap)

                instaShare(bgUri, viewUri)
            } else {
                val requestExternalStorageCode = 1

                val permissionStorage = arrayOf(
                    Manifest.permission.READ_EXTERNAL_STORAGE,
                    Manifest.permission.WRITE_EXTERNAL_STORAGE
                )

                ActivityCompat.requestPermissions(this, permissionStorage, requestExternalStorageCode)
            }
        }
    }

    fun instaShare(bgUri: Uri?, viewUri: Uri?) {
// Define image asset URI
        val stickerAssetUri = Uri.parse(viewUri.toString())
        val sourceApplication = "com.khs.instagramshareexampleproject"

// Instantiate implicit intent with ADD_TO_STORY action,
// sticker asset, and background colors
        val intent = Intent("com.instagram.share.ADD_TO_STORY")
        intent.putExtra("source_application", sourceApplication)

        intent.type = "image/png"
        intent.setDataAndType(bgUri, "image/png");
        intent.putExtra("interactive_asset_uri", stickerAssetUri)

// Instantiate activity and verify it will resolve implicit intent
        grantUriPermission(
            "com.instagram.android", stickerAssetUri, Intent.FLAG_GRANT_READ_URI_PERMISSION
        )

        grantUriPermission(
            "com.instagram.android", bgUri, Intent.FLAG_GRANT_READ_URI_PERMISSION
        )

        try {
            this.startActivity(intent)
        } catch (e : ActivityNotFoundException) {
            Toast.makeText(applicationContext, "인스타그램 앱이 존재하지 않습니다.", Toast.LENGTH_SHORT).show()
        }
        try{
            //저장해놓고 삭제한다.
            Thread.sleep(1000)
            viewUri?.let { uri -> contentResolver.delete(uri, null, null) }
            bgUri?.let { uri -> contentResolver.delete(uri, null, null) }
        } catch (e: InterruptedException) {
            e.printStackTrace()
        }
    }

    // 화면에 나타난 View를 Bitmap에 그릴 용도.
    private fun drawBackgroundBitmap(): Bitmap {
        //기기 해상도를 가져옴.
        val backgroundWidth = resources.displayMetrics.widthPixels
        val backgroundHeight = resources.displayMetrics.heightPixels

        val backgroundBitmap = Bitmap.createBitmap(backgroundWidth, backgroundHeight, Bitmap.Config.ARGB_8888) // 비트맵 생성
        val canvas = Canvas(backgroundBitmap) // 캔버스에 비트맵을 Mapping.

        val bgColor = binding.viewModel?.background?.value // 뷰모델의 현재 설정된 배경색을 가져온다.
        if(bgColor != null) {
            val color = ContextCompat.getColor(baseContext, bgColor)
            canvas.drawColor(color) // 캔버스에 현재 설정된 배경화면색으로 칠한다.
        }

        return backgroundBitmap
    }

    private fun drawViewBitmap(): Bitmap {
        val imageView = binding.iv
        val textView = binding.tv

        val margin = resources.displayMetrics.density * 20

        val width = if (imageView.width > textView.width) {
            imageView.width
        } else {
            textView.width
        }

        val height = (imageView.height + textView.height + margin).toInt()

        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)

        val imageViewBitmap = Bitmap.createBitmap(imageView.width, imageView.height, Bitmap.Config.ARGB_8888)
        val imageViewCanvas = Canvas(imageViewBitmap)
        imageView.draw(imageViewCanvas)
        /*imageViewCanvas를 통해서 imageView를 그린다.
         *이 때 스케치북은 imageViewBitmap이므로 imageViewBitmap에 imageView가 그려진다.
         */

        val imageViewLeft = ((width - imageView.width) / 2).toFloat()

        canvas.drawBitmap(imageViewBitmap, imageViewLeft, (0).toFloat(), null)

        //아래는 TextView. 위에 ImageView와 같은 로직으로 비트맵으로 만든 후 캔버스에 그려준다.
        if(textView.length() > 0) {
            //textView가 공백이 아닐때만
            val textViewBitmap = Bitmap.createBitmap(textView.width, textView.height, Bitmap.Config.ARGB_8888)
            val textViewCanvas = Canvas(textViewBitmap)
            textView.draw(textViewCanvas)

            val textViewLeft = ((width - textView.width) / 2).toFloat()
            val textViewTop = imageView.height + margin

            canvas.drawBitmap(textViewBitmap, textViewLeft, textViewTop, null)
        }

        return bitmap
    }

    //Android Q (Android 10, API 29 이상에서는 이 메서드를 통해서 이미지를 저장한다.)
    @RequiresApi(Build.VERSION_CODES.Q)
    private fun saveImageOnAboveAndroidQ(bitmap: Bitmap): Uri? {
        val fileName = System.currentTimeMillis().toString() + ".png" // 파일이름 현재시간.png

        /*
        * ContentValues() 객체 생성.
        * ContentValues는 ContentResolver가 처리할 수 있는 값을 저장해둘 목적으로 사용된다.
        * */
        val contentValues = ContentValues()
        contentValues.apply {
            put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/ImageSave") // 경로 설정
            put(MediaStore.Images.Media.DISPLAY_NAME, fileName) // 파일이름을 put해준다.
            put(MediaStore.Images.Media.MIME_TYPE, "image/png")
            put(MediaStore.Images.Media.IS_PENDING, 1) // 현재 is_pending 상태임을 만들어준다.
            // 다른 곳에서 이 데이터를 요구하면 무시하라는 의미로, 해당 저장소를 독점할 수 있다.
        }

        // 이미지를 저장할 uri를 미리 설정해놓는다.
        val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)

        try {
            if(uri != null) {
                val image = contentResolver.openFileDescriptor(uri, "w", null)
                // write 모드로 file을 open한다.

                if(image != null) {
                    val fos = FileOutputStream(image.fileDescriptor)
                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
                    //비트맵을 FileOutputStream를 통해 compress한다.
                    fos.close()

                    contentValues.clear()
                    contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) // 저장소 독점을 해제한다.
                    contentResolver.update(uri, contentValues, null, null)
                }
            }
        } catch(e: FileNotFoundException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        } catch (e: Exception) {
            e.printStackTrace()
        }

        return uri
    }

    private fun saveImageOnUnderAndroidQ(bitmap: Bitmap): Uri? {
        val fileName = System.currentTimeMillis().toString() + ".png"
        val externalStorage = Environment.getExternalStorageDirectory().absolutePath
        val path = "$externalStorage/DCIM/imageSave"
        val dir = File(path)

        if(dir.exists().not()) {
            dir.mkdirs() // 폴더 없을경우 폴더 생성
        }

        val fileItem = File("$dir/$fileName")
        try {
            fileItem.createNewFile()
            //0KB 파일 생성.

            val fos = FileOutputStream(fileItem) // 파일 아웃풋 스트림

            bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
            //파일 아웃풋 스트림 객체를 통해서 Bitmap 압축.

            fos.close() // 파일 아웃풋 스트림 객체 close

            sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(fileItem)))
            // 브로드캐스트 수신자에게 파일 미디어 스캔 액션 요청. 그리고 데이터로 추가된 파일에 Uri를 넘겨준다.
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        } catch (e: Exception) {
            e.printStackTrace()
        }

        return FileProvider.getUriForFile(applicationContext, "com.khs.instagramshareexampleproject.fileprovider", fileItem)
    }
}

메인 액티비티 코드 전문.

원래 이미지 저장 예제 프로젝트보다 60줄정도? 더 길어졌다.

 

뷰모델, 라이프사이클오너 설정 메서드는 생략하고, 다른 메서드들을 살펴보겠다.

이미지를 저장하는 기능들은 이미지 저장 기능에 설명되어 있으므로 바뀐 부분만 설명하겠다.

 

Android 10 이상에서 이미지 저장한 후 Uri를 Return하는 메서드

    //Android Q (Android 10, API 29 이상에서는 이 메서드를 통해서 이미지를 저장한다.)
    @RequiresApi(Build.VERSION_CODES.Q)
    private fun saveImageOnAboveAndroidQ(bitmap: Bitmap): Uri? {
        val fileName = System.currentTimeMillis().toString() + ".png" // 파일이름 현재시간.png

        /*
        * ContentValues() 객체 생성.
        * ContentValues는 ContentResolver가 처리할 수 있는 값을 저장해둘 목적으로 사용된다.
        * */
        val contentValues = ContentValues()
        contentValues.apply {
            put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/ImageSave") // 경로 설정
            put(MediaStore.Images.Media.DISPLAY_NAME, fileName) // 파일이름을 put해준다.
            put(MediaStore.Images.Media.MIME_TYPE, "image/png")
            put(MediaStore.Images.Media.IS_PENDING, 1) // 현재 is_pending 상태임을 만들어준다.
            // 다른 곳에서 이 데이터를 요구하면 무시하라는 의미로, 해당 저장소를 독점할 수 있다.
        }

        // 이미지를 저장할 uri를 미리 설정해놓는다.
        val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)

        try {
            if(uri != null) {
                val image = contentResolver.openFileDescriptor(uri, "w", null)
                // write 모드로 file을 open한다.

                if(image != null) {
                    val fos = FileOutputStream(image.fileDescriptor)
                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
                    //비트맵을 FileOutputStream를 통해 compress한다.
                    fos.close()

                    contentValues.clear()
                    contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) // 저장소 독점을 해제한다.
                    contentResolver.update(uri, contentValues, null, null)
                }
            }
        } catch(e: FileNotFoundException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        } catch (e: Exception) {
            e.printStackTrace()
        }

        return uri
    }

이 코드는 지난 번 이미지 저장할 때 쓴 것과 바뀐게 없다.

uri를 그대로 Return해도 정상적으로 동작하게 된다.

추가된 점이라고는 void형에서 Uri형으로 바뀌고 uri를 return하는 점이다.

uri는 위에서 보여줬듯 인텐트에 데이터를 넘겨줄 때 쓴다.

 

Android 10 미만에서 이미지 저장한 후 Uri를 Return하는 메서드

    // Android Q 미만에서 파일 저장 후 Uri 반환해주는 메서드
    private fun saveImageOnUnderAndroidQ(bitmap: Bitmap): Uri? {
        val fileName = System.currentTimeMillis().toString() + ".png"
        val externalStorage = Environment.getExternalStorageDirectory().absolutePath
        val path = "$externalStorage/DCIM/imageSave"
        val dir = File(path)

        if(dir.exists().not()) {
            dir.mkdirs() // 폴더 없을경우 폴더 생성
        }

        val fileItem = File("$dir/$fileName")
        try {
            fileItem.createNewFile()
            //0KB 파일 생성.

            val fos = FileOutputStream(fileItem) // 파일 아웃풋 스트림

            bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
            //파일 아웃풋 스트림 객체를 통해서 Bitmap 압축.

            fos.close() // 파일 아웃풋 스트림 객체 close

            MediaScannerConnection.scanFile(applicationContext, arrayOf(fileItem.toString()), null, null)
            
            //sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(fileItem)))
            // 브로드캐스트 수신자에게 파일 미디어 스캔 액션 요청. 그리고 데이터로 추가된 파일에 Uri를 넘겨준다. - Deprecated 위 코드로 수정
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        } catch (e: Exception) {
            e.printStackTrace()
        }

        return FileProvider.getUriForFile(applicationContext, "com.khs.instagramshareexampleproject.fileprovider", fileItem)
    }

여기 부분에서 설명할 것이 좀 있다.

맨 아래를 보면 FileProvider라는 클래스의 메서드를 사용해서 Uri를 return하고 있다.

파일로부터 Uri를 얻는 방법은 내가 FileProvider를 쓰기 전 까지는 두 가지를 알고 있었다.

  1. {File객체}.toUri : 파일에서 Uri를 얻는다.
  2. Uri.fromFile({File객체}) : Uri 클래스 메서드를 통해서 파일에서 Uri를 얻는다.
D/File.toUri: file:///storage/emulated/0/DCIM/imageSave/1634891196981.png
D/Uri.fromFile: file:///storage/emulated/0/DCIM/imageSave/1634891196981.png

잘 나오는 것 같은데, 이렇게 Uri를 넘겨주면 다음과 같은 에러가 난다.

E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.khs.instagramshareexampleproject, PID: 24668
    java.lang.IllegalStateException: Could not execute method for android:onClick
        at androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:414)
        at android.view.View.performClick(View.java:6256)
        at android.view.View$PerformClick.run(View.java:24701)
        at android.os.Handler.handleCallback(Handler.java:789)
        at android.os.Handler.dispatchMessage(Handler.java:98)
        at android.os.Looper.loop(Looper.java:164)
        at android.app.ActivityThread.main(ActivityThread.java:6541)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.Zygote$MethodAndArgsCaller.run(Zygote.java:240)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:767)
     Caused by: java.lang.reflect.InvocationTargetException
        at java.lang.reflect.Method.invoke(Native Method)
        at androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:409)
        at android.view.View.performClick(View.java:6256) 
        at android.view.View$PerformClick.run(View.java:24701) 
        at android.os.Handler.handleCallback(Handler.java:789) 
        at android.os.Handler.dispatchMessage(Handler.java:98) 
        at android.os.Looper.loop(Looper.java:164) 
        at android.app.ActivityThread.main(ActivityThread.java:6541) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.Zygote$MethodAndArgsCaller.run(Zygote.java:240) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:767) 
     Caused by: android.os.FileUriExposedException: file:///storage/emulated/0/DCIM/imageSave/1634891599204.png exposed beyond app through Intent.getData()
        at android.os.StrictMode.onFileUriExposed(StrictMode.java:1958)
        at android.net.Uri.checkFileUriExposed(Uri.java:2348)
        at android.content.Intent.prepareToLeaveProcess(Intent.java:9766)
        at android.content.Intent.prepareToLeaveProcess(Intent.java:9720)
        at android.app.Instrumentation.execStartActivity(Instrumentation.java:1609)
        at android.app.Activity.startActivityForResult(Activity.java:4472)
        at androidx.activity.ComponentActivity.startActivityForResult(ComponentActivity.java:597)
        at android.app.Activity.startActivityForResult(Activity.java:4430)
        at androidx.activity.ComponentActivity.startActivityForResult(ComponentActivity.java:583)
        at android.app.Activity.startActivity(Activity.java:4791)
        at android.app.Activity.startActivity(Activity.java:4759)
        at com.khs.instagramshareexampleproject.MainActivity.instaShare(MainActivity.kt:118)
        at com.khs.instagramshareexampleproject.MainActivity.instaShareBtn(MainActivity.kt:80)
        at java.lang.reflect.Method.invoke(Native Method) 
        at androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:409) 
        at android.view.View.performClick(View.java:6256) 
        at android.view.View$PerformClick.run(View.java:24701) 
        at android.os.Handler.handleCallback(Handler.java:789) 
        at android.os.Handler.dispatchMessage(Handler.java:98) 
        at android.os.Looper.loop(Looper.java:164) 
        at android.app.ActivityThread.main(ActivityThread.java:6541) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.Zygote$MethodAndArgsCaller.run(Zygote.java:240) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:767) 
  • Caused by: android.os.FileUriExposedException: file:///storage/emulated/0/DCIM/imageSave/1634891599204.png exposed beyond app through Intent.getData()

이 부분이 중요하다.

 

FileUriExposedException  |  Android Developers

 

developer.android.com

공식 문서에 내용을 참고하자면, 

File Uri를 권한이 없는 앱에서 참조했을 때 나타나는 에러라고 한다. 앱에서는 Content Uri를 사용해야 한다고 되어있다.

(분명 READ, WRITE 권한 둘 다 줬는데 이상하다...)

 

어쨌든 File이 아닌 Content Uri로 넘겨줘야 하는데, 찾아보니 방법들이 별로 맘에 들지 않았다.

ContentResolver + ContentValue를 사용하자니 경로 설정도 API가 낮아서 설정이 안된다고 한다.

그래서 할 수 없이 FileProvider 클래스를 사용하기로 했다.

 

 

파일 공유 설정  |  Android 개발자  |  Android Developers

파일 공유 설정 앱에서 다른 앱으로 파일을 안전하게 제공하려면 파일에 보안 핸들을 콘텐츠 URI 형태로 제공하도록 앱을 구성해야 합니다. Android FileProvider 구성요소에서는 개발자가 XML에서 제

developer.android.com

FileProvider는 Content Uri를 제공해준다고 한다.

 

1. FileProvider를 Manifest에 지정해준다. 여기서 URI 생성에 사용할 권한을 지정한다.

        <!-- 외부 저장소 사용을 위한 FileProvider auth 추가. -->
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="com.khs.instagramshareexampleproject.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/path">

            </meta-data>
        </provider>

2. res\xml에 path.xml을 추가한다. 이는 공유 가능한 디렉토리를 설정하는 것이다.

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path name="storage/emulated" path="." />
</paths>

여기서 cache-path도 사용가능하고, files-path도 사용 가능하다고 한다.

 

3. FileProvider 클래스의 getUriForFile() 메서드를 사용한다. 메서드의 매개변수는 각각 다음과 같다.

 

  • getUriForFile(context, {Manifest에서 정의한 authorities}, File객체)

 

로그를 찍어보면 다음과 같이 바뀐다.

D/FileProvider :: content://com.khs.instagramshareexampleproject.fileprovider/storage%2Femulated/DCIM/imageSave/1635127394487.png
D/FileProvider :: content://com.khs.instagramshareexampleproject.fileprovider/storage%2Femulated/DCIM/imageSave/1635127394588.png

 

여기서 만일 내가 외부 저장소에 저장하고 싶지 않고 Cache 디렉토리를 사용하고 싶다면?

1. rew\xml\path.xml에서 <path> 태그 사이에 <cache-path name="cache" path="."/>를 추가해준다.

FileProvider에게 cache 디렉토리에 대한 권한을 주는 것이다.

 

2. Java : getCacheDir() / Kotlin : cacheDir 메서드를 사용해서(Fragment같은 경우 requireActivity().cacheDir)를 저장 경로로 설정해준다. 수정된 코드는 다음과 같다.

        val fileName = System.currentTimeMillis().toString() + ".png"
        //val externalStorage = Environment.getExternalStorageDirectory().absolutePath
        val cacheDir = cacheDir
        val path = "$cacheDir/file"
        val dir = File(path)

 

로그는 다음과 같이 찍히고, cache 파일을 URI로 잘 넘기는 것을 확인할 수 있다.

D/check: content://com.khs.instagramshareexampleproject.fileprovider/cache/file/1635128318935.png
D/check: content://com.khs.instagramshareexampleproject.fileprovider/cache/file/1635128319038.png

 

만일 Android Q 이상의 버전에서도 위처럼 cacheDir을 경로로 설정한다면,

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

위 같은 에러가 난다. 위에서 설명했 듯이, DCIM 혹은 Pictures만 접근이 가능하다는 뜻이다.

이 같은 경우 외부 저장소를 쓰는 게 아니므로, 그냥 Android Q 이하 버전의 코드와 동일하게 사용하면 된다.

 

이미지 저장 메서드를 정리하자면,

  • 외부 저장소에 저장해뒀다가 공유하겠다. -> Android Q / Android Q 미만의 로직을 나눈다. (Environment.getExternalStorageDirectory() 메서드가 Deprecated.)
  • 캐시에 저장해뒀다가 공유하겠다. -> Android Q / Android Q 미만의 로직을 나누지 않는다.

인스타 공유하기 기능만 구현할 경우 대부분의 경우에는 후자가 많을 것이므로,

Android Q 미만의 코드만 사용하면 된다.

 

배경화면 비트맵과 뷰 비트맵을 그리는 메서드

    // 화면에 나타난 View를 Bitmap에 그릴 용도.
    private fun drawBackgroundBitmap(): Bitmap {
        //기기 해상도를 가져옴.
        val backgroundWidth = resources.displayMetrics.widthPixels
        val backgroundHeight = resources.displayMetrics.heightPixels

        val backgroundBitmap = Bitmap.createBitmap(backgroundWidth, backgroundHeight, Bitmap.Config.ARGB_8888) // 비트맵 생성
        val canvas = Canvas(backgroundBitmap) // 캔버스에 비트맵을 Mapping.

        val bgColor = binding.viewModel?.background?.value // 뷰모델의 현재 설정된 배경색을 가져온다.
        if(bgColor != null) {
            val color = ContextCompat.getColor(baseContext, bgColor)
            canvas.drawColor(color) // 캔버스에 현재 설정된 배경화면색으로 칠한다.
        }

        return backgroundBitmap
    }

    private fun drawViewBitmap(): Bitmap {
        val imageView = binding.iv
        val textView = binding.tv

        val margin = resources.displayMetrics.density * 20

        val width = if (imageView.width > textView.width) {
            imageView.width
        } else {
            textView.width
        }

        val height = (imageView.height + textView.height + margin).toInt()

        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)

        val imageViewBitmap = Bitmap.createBitmap(imageView.width, imageView.height, Bitmap.Config.ARGB_8888)
        val imageViewCanvas = Canvas(imageViewBitmap)
        imageView.draw(imageViewCanvas)
        /*imageViewCanvas를 통해서 imageView를 그린다.
         *이 때 스케치북은 imageViewBitmap이므로 imageViewBitmap에 imageView가 그려진다.
         */

        val imageViewLeft = ((width - imageView.width) / 2).toFloat()

        canvas.drawBitmap(imageViewBitmap, imageViewLeft, (0).toFloat(), null)

        //아래는 TextView. 위에 ImageView와 같은 로직으로 비트맵으로 만든 후 캔버스에 그려준다.
        if(textView.length() > 0) {
            //textView가 공백이 아닐때만
            val textViewBitmap = Bitmap.createBitmap(textView.width, textView.height, Bitmap.Config.ARGB_8888)
            val textViewCanvas = Canvas(textViewBitmap)
            textView.draw(textViewCanvas)

            val textViewLeft = ((width - textView.width) / 2).toFloat()
            val textViewTop = imageView.height + margin

            canvas.drawBitmap(textViewBitmap, textViewLeft, textViewTop, null)
        }

        return bitmap
    }

 

원래 이미지 저장 예제 프로젝트에서는 배경봐 뷰를 한꺼번에 그렸다.

그러나 위에서 설명했듯이, 인스타그램에는 배경 레이어와 스티커 레이어가 존재한다.

따라서 두 개를 분리해서 만들어준다.

지난 번과 크게 달라진 건 아니고, 그냥 배경 + 뷰 비트맵 -> 배경 비트맵, 뷰 비트맵으로 바뀌었다고 생각하면 된다.

 

인스타그램 공유하기 코드

    fun instaShare(bgUri: Uri?, viewUri: Uri?) {
// Define image asset URI
        val stickerAssetUri = Uri.parse(viewUri.toString())
        val sourceApplication = "com.khs.instagramshareexampleproject"

// Instantiate implicit intent with ADD_TO_STORY action,
// sticker asset, and background colors
        val intent = Intent("com.instagram.share.ADD_TO_STORY")
        intent.putExtra("source_application", sourceApplication)

        intent.type = "image/png"
        intent.setDataAndType(bgUri, "image/png");
        intent.putExtra("interactive_asset_uri", stickerAssetUri)

// Instantiate activity and verify it will resolve implicit intent
        grantUriPermission(
            "com.instagram.android", stickerAssetUri, Intent.FLAG_GRANT_READ_URI_PERMISSION
        )

        grantUriPermission(
            "com.instagram.android", bgUri, Intent.FLAG_GRANT_READ_URI_PERMISSION
        )

        try {
            this.startActivity(intent)
        } catch (e : ActivityNotFoundException) {
            Toast.makeText(applicationContext, "인스타그램 앱이 존재하지 않습니다.", Toast.LENGTH_SHORT).show()
        }
        try{
            //저장해놓고 삭제한다.
            Thread.sleep(1000)
            viewUri?.let { uri -> contentResolver.delete(uri, null, null) }
            bgUri?.let { uri -> contentResolver.delete(uri, null, null) }
        } catch (e: InterruptedException) {
            e.printStackTrace()
        }
    }

매개변수에 bgUri가 배경화면 파일의 Uri, viewUri가 화면에 나타나있는 뷰를 그린 파일의 Uri이다.

인스타그램 공유하기에 소개된 것처럼 암시적 인텐트를 사용해서 구현한다.

 

intent에 액션으로 "com.instagram.share.ADD_TO_STORY"를 써주고,

png MIME Type으로 배경 Uri를 넘겨준다.추가로 "interactive_asset_uri", "source_application"을 putExtra 메서드로 각각 뷰 Uri, 현재 앱 패키지 명을 넘겨준다.

그리고 grantUriPermission() 메서드를 통해서 인스타그램 앱이 배경, 스티커파일에 대한 권한을 얻을 수 있도록 해준다.

startActivity() 메서드로 intent를 실행시키돼, 앱이 존재하지 않는 경우에는 ActivityNotFoundException 예외처리를 해준다.

 

그리고 공유가 됐을 경우 1초의 여유를 주고 contentResolver 객체를 통해서 viewUri와 bgUri를 찾아서 파일을 삭제하도록 해준다. 이 부분은 사실 선택사항이긴 한데, 나같은 경우는 삭제하도록 만들었다.

만일 외부 저장소가 아닌, 캐시 디렉토리에 넣어줬다면 굳이 삭제할 필요는 없을 것 같다.

 

인스타그램 공유하기 버튼

    fun instaShareBtn(view: View) {

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            val bgBitmap = drawBackgroundBitmap()
            val bgUri = saveImageOnAboveAndroidQ(bgBitmap)

            val viewBitmap = drawViewBitmap()
            val viewUri = saveImageOnAboveAndroidQ(viewBitmap)

            instaShare(bgUri, viewUri)
        } else {
            // Q 버전 이하일 경우. 저장소 권한을 얻어온다.
            val writePermission = ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)

            if(writePermission == PackageManager.PERMISSION_GRANTED) {
                val bgBitmap = drawBackgroundBitmap()
                val bgUri = saveImageOnUnderAndroidQ(bgBitmap)

                val viewBitmap = drawViewBitmap()
                val viewUri = saveImageOnUnderAndroidQ(viewBitmap)

                instaShare(bgUri, viewUri)
            } else {
                val requestExternalStorageCode = 1

                val permissionStorage = arrayOf(
                    Manifest.permission.READ_EXTERNAL_STORAGE,
                    Manifest.permission.WRITE_EXTERNAL_STORAGE
                )

                ActivityCompat.requestPermissions(this, permissionStorage, requestExternalStorageCode)
            }
        }
    }

만일 안드로이드 10버전 이상일 경우, 현재 화면을 이미지 파일로 저장한 후 Uri를 return받아서

바로 위에서 설명한 instaShare() 메서드를 실행시켜주면 된다.

안드로이드 10버전 이상의 경우에는 따로 저장소 권한이 필요없다.

안드로이드 10버전 미만의 경우에는 저장소 권한을 얻어와서 저장소 권한이 있는 경우에만

파일을 저장해서 Uri를 Return받고 인스타그램 공유하기 기능을 실행하도록 한다.

만일 권한이 없을 경우 권한을 요청한다.

지난 번 이미지 저장하기 기능과 크게 다를 바 없다.

 

실행 예제 - 안드로이드 30 API

 

실행 예제 - 안드로이드 26 API

 

 

    // 이미지를 캐시에 저장하는 메서드. Android 버전과 상관 없다.
    private fun saveImageAtCacheDir(bitmap: Bitmap): Uri? {
        val fileName = System.currentTimeMillis().toString() + ".png"
        val cachePath = "$cacheDir/file"
        val dir = File(cachePath)

        if(dir.exists().not()) {
            dir.mkdirs() // 폴더 없을경우 폴더 생성
        }

        val fileItem = File("$dir/$fileName")
        try {
            fileItem.createNewFile()
            //0KB 파일 생성.

            val fos = FileOutputStream(fileItem) // 파일 아웃풋 스트림

            bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
            //파일 아웃풋 스트림 객체를 통해서 Bitmap 압축.

            fos.close() // 파일 아웃풋 스트림 객체 close

            MediaScannerConnection.scanFile(applicationContext, arrayOf(fileItem.toString()), null, null)

            //sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(fileItem)))
            // 브로드캐스트 수신자에게 파일 미디어 스캔 액션 요청. 그리고 데이터로 추가된 파일에 Uri를 넘겨준다. - Deprecated 위 코드로 수정
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        } catch (e: Exception) {
            e.printStackTrace()
        }

        return FileProvider.getUriForFile(applicationContext, "com.khs.instagramshareexampleproject.fileprovider", fileItem)
    }

참고로 예시 프로젝트에는 위 같은 메서드가 적용될 예정이다.

외부 저장소에 저장하지 않고, 캐시에 저장하므로, Android Q 미만 버전에서도 저장소 권한이 필요가 없다.

(갤러리에는 어차피 안 보일 테니까 삭제하는 코드들도 주석처리 해놨다.)

외부 저장소에 이미지를 저장하고 uri를 return하는 메서드는 필요는 없지만 남겨두겠다.

 

 

GitHub - kimyunseok/android-study

Contribute to kimyunseok/android-study development by creating an account on GitHub.

github.com

예시 프로젝트의 전체는 위에서 확인이 가능하다.