| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- rxjava disposable
- android ar 개발
- ar vr 차이
- 안드로이드 유닛 테스트
- jvm 작동 원리
- 안드로이드 레트로핏 crud
- 안드로이드 유닛 테스트 예시
- 자바 다형성
- 안드로이드 os 구조
- 큐 자바 코드
- jvm이란
- 멤버변수
- 스택 자바 코드
- 2022 플러터 안드로이드 스튜디오
- 서비스 쓰레드 차이
- 안드로이드 유닛테스트란
- 안드로이드 라이선스
- 서비스 vs 쓰레드
- rxjava cold observable
- 플러터 설치 2022
- 스택 큐 차이
- Rxjava Observable
- 안드로이드 레트로핏 사용법
- android retrofit login
- 안드로이드 라이선스 종류
- rxjava hot observable
- ANR이란
- 객체
- 2022 플러터 설치
- 클래스
- Today
- Total
나만을 위한 블로그
[Android] 다운로드 매니저(DownloadManager) 구현 방법 본문
※ 이 글의 코드는 예시 코드기 때문에 실제로 사용하려면 반드시 리팩토링한다
다운로드 매니저는 장기간 실행되는 HTTP 다운로드를 처리하는 시스템 서비스다. 백그라운드에서 파일을 다운로드하고 실패 후 or 연결 변경, 시스템 재부팅 시 다운로드를 재시도한다.
보통 앱 내에서 PDF나 영상, 음악 등의 파일을 다운로드하려고 하면 시계가 표시되는 부분에 다운로드 이미지가 표시되면서 유저에게 다운로드중임을 알리고 다운로드가 완료되면 다운로드 이미지가 한 번 깜박이면서 다운로드 완료를 알린다. 갤럭시를 사용한다면 굳이 설명할 필요도 없이 다 아는 플로우다. 아이폰 쓴다고요? 그럼 아쉬운거죠
각설하고 전체 코드부터 확인한다.
package com.example.regacyviewexample.presentation
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.content.PermissionChecker
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.regacyviewexample.R
import com.example.regacyviewexample.databinding.ActivityMainBinding
import dagger.hilt.android.AndroidEntryPoint
import androidx.core.net.toUri
import androidx.core.view.WindowCompat
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var downloadManager: DownloadManager
private var downloadId: Long = 0
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
startDownload("https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mp4-file.mp4")
} else {
Toast.makeText(this, "파일을 다운로드하려면 저장소 권한이 필요합니다", Toast.LENGTH_SHORT).show()
}
}
// 다운로드 완료 상태를 감지하는 브로드캐스트 리시버. 다운로드 완료 시 유저에 완료 알림 표시
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (downloadId == id) {
Toast.makeText(this@MainActivity, "다운로드 완료!", Toast.LENGTH_SHORT).show()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = true
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
setupDownloadManager()
binding.btnStartDownload.setOnClickListener {
downloadFile("https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mp4-file.mp4")
}
}
private fun setupDownloadManager() {
downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
ContextCompat.registerReceiver(
this,
downloadReceiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
private fun downloadFile(url: String) {
when {
// 안드로이드 10(api 29+) 이상에선 scoped storage를 통해 다운로드한 파일 처리
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
// 다운로드 매니저는 api 29+에서 별도 권한 없이 Downloads 폴더에 파일을 저장할 수 있음
startDownload(url)
}
// 안드로이드 9(api 28) 이하에선 권한 허용 여부에 따라 바로 파일을 다운로드하거나 권한 먼저 요청
else -> {
if (checkStoragePermission()) {
startDownload(url)
} else {
requestStoragePermission()
}
}
}
}
private fun checkStoragePermission(): Boolean {
return ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PermissionChecker.PERMISSION_GRANTED
}
private fun requestStoragePermission() =
requestPermissionLauncher.launch(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
private fun startDownload(url: String) {
val request = DownloadManager.Request(url.toUri())
.setTitle("파일 다운로드")
.setDescription("파일을 다운로드 중입니다...")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "downloadedVideo.mp4")
downloadId = downloadManager.enqueue(request)
Toast.makeText(this, "다운로드를 시작합니다...", Toast.LENGTH_SHORT).show()
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(downloadReceiver)
}
}
위 코드를 실행하면 api 28에서든 api 30 이상에서든 정상적으로 작동하는 걸 볼 수 있다. api 30+에선 권한 허용 창이 나타나지 않아도 다운로드 매니저가 작동해서 영상을 다운로드하기 시작한다.
파일 다운로드가 완료되면 에뮬레이터 기준으로 아래와 같이 표시된다.

하나씩 확인해 본다.
private lateinit var downloadManager: DownloadManager
private var downloadId: Long = 0
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
startDownload("https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mp4-file.mp4")
} else {
Toast.makeText(this, "파일을 다운로드하려면 저장소 권한이 필요합니다", Toast.LENGTH_SHORT).show()
}
}
// 다운로드 완료 상태를 감지하는 브로드캐스트 리시버. 다운로드 완료 시 유저에 완료 알림 표시
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
if (downloadId == id) {
Toast.makeText(this@MainActivity, "다운로드 완료!", Toast.LENGTH_SHORT).show()
}
}
}
지연 초기화를 위한 DownloadManager 프로퍼티와 다운로드한 파일의 id를 담아둘 프로퍼티를 만들었다.
downloadId는 브로드캐스트 리시버에서 인텐트로 전달받은 id와 동일하다면 다운로드가 완료된 것을 의미하기 때문에 다운로드가 완료됐음을 유저에게 알리기 위해 사용된다. 왜 브로드캐스트 리시버가 필요한지는 맨 밑에 있으니 이해가 안 된다면 일단 넘긴다. 권한 요청 런처는 익숙할테니 생략한다.
private fun setupDownloadManager() {
downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
ContextCompat.registerReceiver(
this,
downloadReceiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
DownloadManager를 onCreate에서 초기화하고 브로드캐스트 리시버에 얹어서 메인 액티비티에 등록한다.
registerReceiver()만 단독 사용한다면 버전 분기가 필요하지만 ContextCompat을 앞에 붙여서 사용한다면 버전 분기가 필요없다. 실제로 registerReceiver()만 사용하게 바꾸면 실행에 지장은 없지만 빨간 줄로 경고가 표시된다.
참고로 왜 이렇게 작동하냐면 ContextCompat은 내부적으로 안드로이드 api 레벨을 체크해서 api 34 이상이면 RECEIVER_NOT_EXPORTED와 같이 등록하고, 33 이하면 플래그 없이 기존 방식으로 등록한다.
private fun downloadFile(url: String) {
when {
// 안드로이드 10(api 29+) 이상에선 scoped storage를 통해 다운로드한 파일 처리
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
// 다운로드 매니저는 api 29+에서 별도 권한 없이 Downloads 폴더에 파일을 저장할 수 있음
startDownload(url)
}
// 안드로이드 9(api 28) 이하에선 권한 허용 여부에 따라 바로 파일을 다운로드하거나 권한 먼저 요청
else -> {
if (checkStoragePermission()) {
startDownload(url)
} else {
requestStoragePermission()
}
}
}
}
private fun startDownload(url: String) {
val request = DownloadManager.Request(url.toUri())
.setTitle("파일 다운로드")
.setDescription("파일을 다운로드 중입니다...")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "downloadedVideo.mp4")
downloadId = downloadManager.enqueue(request)
Toast.makeText(this, "다운로드를 시작합니다...", Toast.LENGTH_SHORT).show()
}
downloadFile()은 안드로이드 10(api 29) 이상 여부를 체크해서 안드로이드 10 이상이라면 별도의 권한 요청 없이 바로 파일을 다운로드하기 시작한다. 안드로이드 9 이하라면 권한이 허용돼 있지 않다면 권한을 요청하고, 허용돼 있다면 파일 다운로드가 시작된다.
안드로이드 10을 기준으로 로직이 바뀌는 이유는 안드로이드 10부터 범위 지정 저장소(scoped storage) 정책이 적용되기 때문이다. 범위 지정 저장소가 생소하다면 아래 문서를 참고한다.
https://source.android.com/docs/core/storage/scoped?hl=ko
범위 지정 저장소 | Android Open Source Project
2025년 3월 27일부터 AOSP를 빌드하고 기여하려면 aosp-main 대신 android-latest-release를 사용하는 것이 좋습니다. 자세한 내용은 AOSP 변경사항을 참고하세요. 이 페이지는 Cloud Translation API를 통해 번역되
source.android.com
이전엔 WRITE_EXTERNAL_STORAGE 권한이 있으면 다른 앱의 공용 파일에도 접근할 수 있었지만 보안 이슈로 안드로이드 10부터는 이를 막으려고 시도했고 그 결과가 scoped storage 정책이다.
downloadFile()에서 호출하는 startDownload()는 DownloadManager의 여러 함수들을 호출해서 파일 다운로드 관련 옵션들을 설정한다. 예시에서 사용된 함수를 간단하게 확인한다.
- setNotificationVisibility() : 알림 표시 정책을 설정함. 다운로드 중 / 완료 시 알림으로 각 상태를 표시하려면 VISIBILITY_VISIBLE_NOTIFY_COMPLETED를 사용한다
- setAllowedOverMetered(true) : 와이파이를 사용하지 않을 경우 사용하는 5G 데이터 사용 여부를 의미한다. true를 넘기면 모바일 데이터로도 다운로드할 수 있고 false를 넘기면 와이파이 환경에서만 다운로드하게 설정한다.
- setAllowedOverRoaming(true) : 로밍 중 다운로드를 허용할지 여부를 결정한다. true면 가능하고 false면 불가능
- setDestinationInExternalPublicDir() : 다운로드한 파일이 저장될 위치를 설정한다. DIRECTORY_DOWNLOADS를 사용하면 공용 Downloads 폴더가 저장 위치로 설정되고 2번째 매개변수로 저장할 파일명을 설정
모든 설정이 완료되면 enqueue()를 호출해서 다운로드 요청을 시스템 큐에 등록한다. 이 결과로 Long 타입의 고유한 다운로드 id가 리턴되는데 이 id로 다운로드 상태를 확인하고 취소, 감지하는 것이 가능하다.
앞서 브로드캐스트 리시버 전역 변수를 설정할 때 인텐트에서 EXTRA_DOWNLOAD_ID 키에 매핑된 데이터를 가져와서 다른 전역변수인 downloadId와 일치하면 다운로드 완료 토스트를 표시하는 코드를 봤었다. 다운로드 매니저가 파일 다운로드를 완료하면 다운로드 완료된 파일의 downloadId를 담아서 브로드캐스트에 담아서 시스템 전체에 보내는데, 이 브로드캐스트를 캐치해서 id를 추출해 서로 비교한다.
그래서 여러 파일들을 다운로드하더라도 각 파일 별 다운로드 상태가 상단 상태바에 표시되는 것이다.
'Android' 카테고리의 다른 글
| [Android] AlarmManager란? 사용 예시 (0) | 2025.08.26 |
|---|---|
| [Android] XML용 NumberPicker 커스텀 라이브러리 사용법 (0) | 2025.07.18 |
| [Android] 안드로이드 16(바클라바) 변경점 (0) | 2025.07.07 |
| [Android] XML 프로젝트에서 커스텀 달력 라이브러리 구현하는 법 (0) | 2025.06.22 |
| [Android] Retrofit 3.0.0의 등장 (0) | 2025.06.04 |