[Kotlin] 화면 캡쳐 기능 구현하기 (with Hilt, MVVM)

화면 캡쳐 기능을 구현해보도록 하겠다.

 

가장 먼저 필요한 모듈을 implementation 해주겠다.

// Architectural Components
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
    // Coroutines
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'

    // Coroutine Lifecycle Scopes
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0"
    // Activity KTX for viewModels()
    implementation "androidx.activity:activity-ktx:1.4.0"

    // Retrofit
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
    implementation "com.squareup.okhttp3:logging-interceptor:4.5.0"

    // hilt
    implementation "com.google.dagger:hilt-android:2.44"
    implementation 'com.google.firebase:firebase-database-ktx:20.0.4'
    implementation 'com.google.firebase:firebase-auth-ktx:21.0.3'
    kapt "com.google.dagger:hilt-compiler:2.44"

 

Hilt를 사용하기 위해 BaseApp을 만들어주고

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class BaseApp : Application()

AndroidManifest.xml 파일에 다음을 추가해준다.

android:name=".BaseApp"

 

모듈을 생성해준다.

@Module
@InstallIn(SingletonComponent::class)
object ImageModule {

    @Provides
    fun provideContext(application: Application): Context {
        return application
    }

    @Provides
    fun provideImage(context : Context) : ImageRepository {
        return ImageRepository(context)
    }

}

 

레포지토리도 생성해준다.

class ImageRepository @Inject constructor(private val context : Context ){
    fun saveImageToStorage(bitmap: Bitmap) : Flow<Status<String>> = flow{
        emit(Status.Loading())
        try {
            val filename = "${System.currentTimeMillis()}.jpg"
            var fos: OutputStream? = null
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                context.contentResolver?.also { resolver ->
                    val contentValues = ContentValues().apply {
                        put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
                        put(MediaStore.MediaColumns.MIME_TYPE, "image/jpg")
                        put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
                    }
                    val imageUri: Uri? = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
                    fos = imageUri?.let { resolver.openOutputStream(it) }
                    // 미디어 스캐닝 실행
                    MediaScannerConnection.scanFile(
                        context,
                        arrayOf(imageUri.toString()),
                        null
                    ) { _, _ -> }
                }
            } else {
                val imagesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
                val image = File(imagesDir, filename)
                fos = FileOutputStream(image)

                // 미디어 스캐닝 실행
                MediaScannerConnection.scanFile(
                    context,
                    arrayOf(image.absolutePath),
                    null
                ) { _, _ -> }
            }
            fos?.use {
                bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it)
                emit(Status.Success())
            }
            fos?.close()
        }
        catch (e: Exception) {
            emit(Status.Error(message = e.localizedMessage ?: ""))
        }
    }

}

안드로이드 SDK 29 버전 이후에는 정책이 바뀌었기 때문에 29 전 후로 다르게 처리를 해주어야 한다.

29 버전 이후에는 resolver.insert로 파일쓰기가 가능하지만, 이전버전에는 FIle로 가능하다. 

또 사진을 저장한 후에는 미디어 스캐닝 과정이 진행이 되어야 갤러리에서 확인이 가능하기에 MediaSanner를 실행해준다.

 

각 과정은 Flow를 통해 흐름이 진행이되며, 함수 실행시 Status 상태가 Loading 상태이며,

성공시 Success를 emit하며 실패시 Error코드와 함께 Error를 emit한다.

 

 

상태는 다음과 같다.

sealed class Status<T>(val message: String? = null) {
    class Success<T>() :Status<T>()
    class Error<T>(message: String) : Status<T>(message)
    class Loading<T>() : Status<T>()
}

 

 

viewModel을 작성하면

@HiltViewModel
class ImageViewModel
@Inject
constructor(
    private var imageRepository: ImageRepository
) : ViewModel() {
    private val _save = MutableStateFlow(ImageState())

    val save : StateFlow<ImageState> = _save
    fun saveImage(bitmap: Bitmap) {
        imageRepository.saveImageToStorage(bitmap).onEach {
            when(it) {
                is Status.Loading -> {
                    _save.value = ImageState(isLoading = true)
                }
                is Status.Error -> {
                    _save.value = ImageState(error = it.message ?: "")
                }
                is Status.Success -> {
                    _save.value = ImageState(data = true)
                }
            }
        }.launchIn(viewModelScope)
    }
}

다음과 같은데, 상태의 변화에 따라 value값에 값을 저장해준다.

data class ImageState(
    val error: String = "",
    val isLoading: Boolean = false,
    val data : Boolean? = null
)

 

MainActivity는 다음과 같다.

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val imageViewModel: ImageViewModel by viewModels()

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

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

        lifecycle.coroutineScope.launchWhenCreated {
            imageViewModel.save.collect{
                if(it.isLoading){
                    Toast.makeText(this@MainActivity, "loading", Toast.LENGTH_SHORT).show()
                }
                else if (it.error.isNotBlank()) {
                    Toast.makeText(this@MainActivity, "Error : ${it.error}", Toast.LENGTH_SHORT).show()
                }
                it.data?.let {
                    Toast.makeText(this@MainActivity, "Save Image", Toast.LENGTH_SHORT).show()
                }
            }
        }

        binding.fab.setOnClickListener{
            requestPermission {
                val bitmap = getScreenShotFromView(binding.root)
                if (bitmap != null) {
                    imageViewModel.saveImage(bitmap)
                }
            }
        }

    }

    private fun requestPermission(logic : () -> Unit) {
        TedPermission.create()
            .setPermissionListener(object :PermissionListener{
                override fun onPermissionGranted() {
                    logic()
                }

                override fun onPermissionDenied(deniedPermissions: MutableList<String>?) {
                    Toast.makeText(this@MainActivity, "권한을 허가해주세요", Toast.LENGTH_SHORT).show()
                }
            }).setDeniedMessage("사진 저장 권한을 허용해주세요.").setPermissions(android.Manifest.permission.WRITE_EXTERNAL_STORAGE).check()
    }

    private fun getScreenShotFromView(v: View): Bitmap? {
        var screenshot: Bitmap? = null
        try {
            screenshot =
                Bitmap.createBitmap(v.measuredWidth, v.measuredHeight, Bitmap.Config.ARGB_8888)
            val canvas = Canvas(screenshot)
            v.draw(canvas)
        } catch (e: Exception) {
            Log.e("GFG", "Failed to capture screenshot because:" + e.message)
        }
        return screenshot
    }

}

사진 쓰기는 권한이 필요한 작업이기에 tedPermisstion을 이용하여 권한을 얻었다.

getScreenShotFromView의 경우에는 지정된 view를 bitmap 형태로 반환하는 함수이다.