feat: 권한 설정 UI 개선 및 APK 다운로드 버그 수정

- 설정 화면에 리마인더 및 알 수 없는 앱 설치 권한 체크 추가

- PermissionHelper에 canInstallUnknownApps, openUnknownAppsSettings 추가

- APK 다운로드 BroadcastReceiver 사용하도록 개선

- 다운로드 실패 처리 및 진행률 개선
This commit is contained in:
sanjeok77
2026-03-04 23:40:27 +09:00
parent 08722403c7
commit fb4f5e9ef4
4 changed files with 264 additions and 85 deletions

View File

@@ -1,5 +1,6 @@
package com.hotdeal.alarm.presentation.main package com.hotdeal.alarm.presentation.main
import android.content.BroadcastReceiver
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -8,8 +9,11 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.dp
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.hotdeal.alarm.ui.theme.HotDealTheme import com.hotdeal.alarm.ui.theme.HotDealTheme
@@ -27,11 +31,13 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var workerScheduler: WorkerScheduler lateinit var workerScheduler: WorkerScheduler
private var downloadReceiver: BroadcastReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
// 2분 간격으로 폴 시작 // 2분 간격으로 폴<EFBFBD> 시작
workerScheduler.schedulePeriodicPolling(2) workerScheduler.schedulePeriodicPolling(2)
setContent { setContent {
@@ -47,6 +53,7 @@ class MainActivity : ComponentActivity() {
var showUpdateDialog by remember { mutableStateOf(false) } var showUpdateDialog by remember { mutableStateOf(false) }
var downloadProgress by remember { mutableStateOf(0) } var downloadProgress by remember { mutableStateOf(0) }
var isDownloading by remember { mutableStateOf(false) } var isDownloading by remember { mutableStateOf(false) }
var downloadId by remember { mutableStateOf(-1L) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
checkForUpdate { info -> checkForUpdate { info ->
@@ -69,27 +76,41 @@ class MainActivity : ComponentActivity() {
onUpdate = { onUpdate = {
// APK 다이렉트 다운로드 // APK 다이렉트 다운로드
isDownloading = true isDownloading = true
val downloadId = ApkDownloadManager.downloadApk( downloadId = ApkDownloadManager.downloadApk(
this@MainActivity, this@MainActivity,
updateInfo!! updateInfo!!
) )
// 다운로드 완료 리시버 등록
downloadReceiver = ApkDownloadManager.registerDownloadCompleteReceiver(
context = this@MainActivity,
downloadId = downloadId,
onComplete = {
isDownloading = false
showUpdateDialog = false
ApkDownloadManager.installApk(this@MainActivity)
},
onFailed = {
isDownloading = false
Toast.makeText(
this@MainActivity,
"다운로드 실패. 다시 시도해주세요.",
Toast.LENGTH_SHORT
).show()
}
)
// 다운로드 진행률 모니터링 // 다운로드 진행률 모니터링
lifecycleScope.launch { lifecycleScope.launch {
while (isDownloading) { while (isDownloading) {
downloadProgress = ApkDownloadManager.getDownloadProgress( val status = ApkDownloadManager.getDownloadStatus(
this@MainActivity, this@MainActivity,
downloadId downloadId
) )
downloadProgress = status.progress
if (ApkDownloadManager.isDownloadComplete( if (status.isComplete || status.isFailed) {
this@MainActivity, break
downloadId
)
) {
isDownloading = false
showUpdateDialog = false
ApkDownloadManager.installApk(this@MainActivity)
} }
kotlinx.coroutines.delay(500) kotlinx.coroutines.delay(500)
@@ -103,6 +124,14 @@ class MainActivity : ComponentActivity() {
} }
} }
override fun onDestroy() {
super.onDestroy()
// 리시버 해제
downloadReceiver?.let {
ApkDownloadManager.unregisterDownloadCompleteReceiver(this, it)
}
}
/** /**
* 업데이트 체크 * 업데이트 체크
*/ */
@@ -159,7 +188,7 @@ fun UpdateDialog(
if (isDownloading) { if (isDownloading) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator( LinearProgressIndicator(
progress = downloadProgress / 100f, progress = { downloadProgress / 100f },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Text( Text(

View File

@@ -210,6 +210,8 @@ private fun NotificationSettingsHeader(
onRequestPermission: () -> Unit, onRequestPermission: () -> Unit,
onOpenSystemSettings: () -> Unit onOpenSystemSettings: () -> Unit
) { ) {
val context = LocalContext.current
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
@@ -260,40 +262,84 @@ private fun NotificationSettingsHeader(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// 알림 권한 상태 // 알림 권한 상태
NotificationStatusRow( PermissionStatusRow(
icon = if (permissionStatus.hasNotificationPermission) Icons.Filled.CheckCircle else Icons.Filled.Warning, icon = if (permissionStatus.hasNotificationPermission) Icons.Filled.CheckCircle else Icons.Filled.Warning,
title = if (permissionStatus.hasNotificationPermission) "알림 권한 허용됨" else "알림 권한 필요", title = "알림 권한",
description = if (permissionStatus.hasNotificationPermission) "허용됨" else "필요함",
isOk = permissionStatus.hasNotificationPermission, isOk = permissionStatus.hasNotificationPermission,
action = if (!permissionStatus.hasNotificationPermission) { onAction = if (!permissionStatus.hasNotificationPermission) {
{ onRequestPermission() } { onRequestPermission() }
} else null, } else null,
actionLabel = "권한 허용", actionLabel = "허용"
secondaryAction = if (permissionStatus.hasNotificationPermission) {
{ onOpenSystemSettings() }
} else null,
secondaryActionLabel = "시스템 설정"
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(8.dp))
// 정확한 알람 (리마인더) 권한 상태
PermissionStatusRow(
icon = if (permissionStatus.hasExactAlarmPermission) Icons.Filled.CheckCircle else Icons.Filled.Warning,
title = "리마인더 및 알람",
description = if (permissionStatus.hasExactAlarmPermission) "허용됨" else "정확한 시간 알림에 필요",
isOk = permissionStatus.hasExactAlarmPermission,
onAction = if (!permissionStatus.hasExactAlarmPermission) {
{ PermissionHelper.openExactAlarmSettings(context) }
} else null,
actionLabel = "설정"
)
Spacer(modifier = Modifier.height(8.dp))
// 알 수 없는 앱 설치 권한 상태
PermissionStatusRow(
icon = if (permissionStatus.canInstallUnknownApps) Icons.Filled.CheckCircle else Icons.Filled.Warning,
title = "앱 설치 권한",
description = if (permissionStatus.canInstallUnknownApps) "허용됨" else "업데이트 설치에 필요",
isOk = permissionStatus.canInstallUnknownApps,
onAction = if (!permissionStatus.canInstallUnknownApps) {
{ PermissionHelper.openUnknownAppsSettings(context) }
} else null,
actionLabel = "설정"
)
Spacer(modifier = Modifier.height(8.dp))
// 사이트 선택 상태 // 사이트 선택 상태
NotificationStatusRow( PermissionStatusRow(
icon = if (hasEnabledSites) Icons.Filled.CheckCircle else Icons.Filled.Error, icon = if (hasEnabledSites) Icons.Filled.CheckCircle else Icons.Filled.Error,
title = if (hasEnabledSites) "사이트 선택 완료" else "사이트 선택 필요", title = "사이트 선택",
description = if (hasEnabledSites) "완료" else "최소 1개 이상 필요",
isOk = hasEnabledSites isOk = hasEnabledSites
) )
// 시스템 설정 버튼
if (permissionStatus.hasNotificationPermission || permissionStatus.hasExactAlarmPermission || permissionStatus.canInstallUnknownApps) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = { PermissionHelper.openAppSettings(context) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("시스템 설정 열기")
}
}
} }
} }
} }
@Composable @Composable
private fun NotificationStatusRow( private fun PermissionStatusRow(
icon: ImageVector, icon: ImageVector,
title: String, title: String,
description: String,
isOk: Boolean, isOk: Boolean,
action: (() -> Unit)? = null, onAction: (() -> Unit)? = null,
actionLabel: String = "", actionLabel: String = ""
secondaryAction: (() -> Unit)? = null,
secondaryActionLabel: String = ""
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -306,25 +352,25 @@ private fun NotificationStatusRow(
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text( Column(modifier = Modifier.weight(1f)) {
text = title, Text(
style = MaterialTheme.typography.bodyMedium, text = title,
fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f) fontWeight = FontWeight.Medium
) )
if (action != null) { Text(
TextButton(onClick = action) { text = description,
Text(actionLabel, style = MaterialTheme.typography.labelMedium) style = MaterialTheme.typography.bodySmall,
} color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
if (secondaryAction != null) { if (onAction != null) {
TextButton(onClick = secondaryAction) { TextButton(onClick = onAction) {
Text(secondaryActionLabel, style = MaterialTheme.typography.labelMedium) Text(actionLabel, style = MaterialTheme.typography.labelMedium)
} }
} }
} }
} }
@Composable @Composable
private fun SectionHeader( private fun SectionHeader(
title: String, title: String,

View File

@@ -1,12 +1,17 @@
package com.hotdeal.alarm.util package com.hotdeal.alarm.util
import android.app.DownloadManager import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import android.widget.Toast import android.widget.Toast
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
/** /**
@@ -36,6 +41,8 @@ object ApkDownloadManager {
setAllowedOverMetered(true) setAllowedOverMetered(true)
setAllowedOverRoaming(true) setAllowedOverRoaming(true)
setMimeType("application/vnd.android.package-archive") setMimeType("application/vnd.android.package-archive")
// Wi-Fi 환경에서 다운로드 우선
setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
} }
val downloadId = downloadManager.enqueue(request) val downloadId = downloadManager.enqueue(request)
@@ -49,6 +56,106 @@ object ApkDownloadManager {
return downloadId return downloadId
} }
/**
* 다운로드 완료 리시버 등록
*/
fun registerDownloadCompleteReceiver(
context: Context,
downloadId: Long,
onComplete: () -> Unit,
onFailed: () -> Unit
): BroadcastReceiver {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) ?: -1
if (id == downloadId) {
val downloadManager = context?.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager?.query(query)
cursor?.use {
if (it.moveToFirst()) {
val statusIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = it.getInt(statusIndex)
when (status) {
DownloadManager.STATUS_SUCCESSFUL -> onComplete()
DownloadManager.STATUS_FAILED -> onFailed()
}
}
}
}
}
}
context.registerReceiver(
receiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
)
return receiver
}
/**
* 다운로드 완료 리시버 해제
*/
fun unregisterDownloadCompleteReceiver(context: Context, receiver: BroadcastReceiver) {
try {
context.unregisterReceiver(receiver)
} catch (e: Exception) {
// 이미 해제된 경우 무시
}
}
/**
* 다운로드 상태 확인 (suspend 함수)
*/
suspend fun getDownloadStatus(context: Context, downloadId: Long): DownloadStatus =
withContext(Dispatchers.IO) {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
var status = DownloadStatus(0, 0, false, false)
cursor?.use {
if (it.moveToFirst()) {
val statusIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
val bytesDownloadedIndex = it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val bytesTotalIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val downloadStatus = it.getInt(statusIndex)
val bytesDownloaded = it.getLong(bytesDownloadedIndex)
val bytesTotal = it.getLong(bytesTotalIndex)
val progress = if (bytesTotal > 0) {
((bytesDownloaded * 100) / bytesTotal).toInt()
} else 0
status = DownloadStatus(
progress = progress,
bytesDownloaded = bytesDownloaded,
isComplete = downloadStatus == DownloadManager.STATUS_SUCCESSFUL,
isFailed = downloadStatus == DownloadManager.STATUS_FAILED
)
}
}
status
}
/**
* 다운로드 완료 대기 (suspend 함수)
*/
suspend fun waitForDownload(context: Context, downloadId: Long): Boolean {
while (true) {
val status = getDownloadStatus(context, downloadId)
if (status.isComplete) return true
if (status.isFailed) return false
delay(500)
}
}
/** /**
* APK 파일 설치 * APK 파일 설치
*/ */
@@ -80,45 +187,12 @@ object ApkDownloadManager {
} }
/** /**
* 다운로드 완료 여부 확인 * 다운로드 상태 데이터 클래스
*/ */
fun isDownloadComplete(context: Context, downloadId: Long): Boolean { data class DownloadStatus(
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val progress: Int,
val query = DownloadManager.Query().setFilterById(downloadId) val bytesDownloaded: Long,
val isComplete: Boolean,
val cursor = downloadManager.query(query) val isFailed: Boolean
if (cursor.moveToFirst()) { )
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = cursor.getInt(statusIndex)
cursor.close()
return status == DownloadManager.STATUS_SUCCESSFUL
}
cursor.close()
return false
}
/**
* 다운로드 진행률 가져오기
*/
fun getDownloadProgress(context: Context, downloadId: Long): Int {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) {
val bytesDownloadedIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val bytesTotalIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val bytesDownloaded = cursor.getLong(bytesDownloadedIndex)
val bytesTotal = cursor.getLong(bytesTotalIndex)
cursor.close()
if (bytesTotal > 0) {
return ((bytesDownloaded * 100) / bytesTotal).toInt()
}
}
cursor.close()
return 0
}
} }

View File

@@ -41,6 +41,17 @@ object PermissionHelper {
} }
} }
/**
* 알 수 없는 앱 설치 권한이 있는지 확인
*/
fun canInstallUnknownApps(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
true
}
}
/** /**
* 정확한 알람 설정 화면 열기 * 정확한 알람 설정 화면 열기
*/ */
@@ -58,6 +69,22 @@ object PermissionHelper {
} }
} }
/**
* 알 수 없는 앱 설치 설정 화면 열기
*/
fun openUnknownAppsSettings(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
}
context.startActivity(intent)
} catch (e: Exception) {
openAppSettings(context)
}
}
}
/** /**
* 앱 설정 화면 열기 * 앱 설정 화면 열기
*/ */
@@ -90,6 +117,7 @@ object PermissionHelper {
data class PermissionStatus( data class PermissionStatus(
val hasNotificationPermission: Boolean, val hasNotificationPermission: Boolean,
val hasExactAlarmPermission: Boolean, val hasExactAlarmPermission: Boolean,
val canInstallUnknownApps: Boolean,
val isAllGranted: Boolean val isAllGranted: Boolean
) )
@@ -99,11 +127,13 @@ object PermissionHelper {
fun checkAllPermissions(context: Context): PermissionStatus { fun checkAllPermissions(context: Context): PermissionStatus {
val hasNotification = hasNotificationPermission(context) val hasNotification = hasNotificationPermission(context)
val hasExactAlarm = hasExactAlarmPermission(context) val hasExactAlarm = hasExactAlarmPermission(context)
val canInstallUnknown = canInstallUnknownApps(context)
return PermissionStatus( return PermissionStatus(
hasNotificationPermission = hasNotification, hasNotificationPermission = hasNotification,
hasExactAlarmPermission = hasExactAlarm, hasExactAlarmPermission = hasExactAlarm,
isAllGranted = hasNotification && hasExactAlarm canInstallUnknownApps = canInstallUnknown,
isAllGranted = hasNotification && hasExactAlarm && canInstallUnknown
) )
} }
} }