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
import android.content.BroadcastReceiver
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
@@ -8,8 +9,11 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
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.lifecycle.lifecycleScope
import com.hotdeal.alarm.ui.theme.HotDealTheme
@@ -27,11 +31,13 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var workerScheduler: WorkerScheduler
private var downloadReceiver: BroadcastReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// 2분 간격으로 폴 시작
// 2분 간격으로 폴<EFBFBD> 시작
workerScheduler.schedulePeriodicPolling(2)
setContent {
@@ -47,6 +53,7 @@ class MainActivity : ComponentActivity() {
var showUpdateDialog by remember { mutableStateOf(false) }
var downloadProgress by remember { mutableStateOf(0) }
var isDownloading by remember { mutableStateOf(false) }
var downloadId by remember { mutableStateOf(-1L) }
LaunchedEffect(Unit) {
checkForUpdate { info ->
@@ -69,27 +76,41 @@ class MainActivity : ComponentActivity() {
onUpdate = {
// APK 다이렉트 다운로드
isDownloading = true
val downloadId = ApkDownloadManager.downloadApk(
downloadId = ApkDownloadManager.downloadApk(
this@MainActivity,
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 {
while (isDownloading) {
downloadProgress = ApkDownloadManager.getDownloadProgress(
val status = ApkDownloadManager.getDownloadStatus(
this@MainActivity,
downloadId
)
downloadProgress = status.progress
if (ApkDownloadManager.isDownloadComplete(
this@MainActivity,
downloadId
)
) {
isDownloading = false
showUpdateDialog = false
ApkDownloadManager.installApk(this@MainActivity)
if (status.isComplete || status.isFailed) {
break
}
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) {
Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator(
progress = downloadProgress / 100f,
progress = { downloadProgress / 100f },
modifier = Modifier.fillMaxWidth()
)
Text(

View File

@@ -210,6 +210,8 @@ private fun NotificationSettingsHeader(
onRequestPermission: () -> Unit,
onOpenSystemSettings: () -> Unit
) {
val context = LocalContext.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
@@ -260,40 +262,84 @@ private fun NotificationSettingsHeader(
Spacer(modifier = Modifier.height(16.dp))
// 알림 권한 상태
NotificationStatusRow(
PermissionStatusRow(
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,
action = if (!permissionStatus.hasNotificationPermission) {
onAction = if (!permissionStatus.hasNotificationPermission) {
{ onRequestPermission() }
} else null,
actionLabel = "권한 허용",
secondaryAction = if (permissionStatus.hasNotificationPermission) {
{ onOpenSystemSettings() }
} else null,
secondaryActionLabel = "시스템 설정"
actionLabel = "허용"
)
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,
title = if (hasEnabledSites) "사이트 선택 완료" else "사이트 선택 필요",
title = "사이트 선택",
description = if (hasEnabledSites) "완료" else "최소 1개 이상 필요",
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
private fun NotificationStatusRow(
private fun PermissionStatusRow(
icon: ImageVector,
title: String,
description: String,
isOk: Boolean,
action: (() -> Unit)? = null,
actionLabel: String = "",
secondaryAction: (() -> Unit)? = null,
secondaryActionLabel: String = ""
onAction: (() -> Unit)? = null,
actionLabel: String = ""
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -306,25 +352,25 @@ private fun NotificationStatusRow(
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
if (action != null) {
TextButton(onClick = action) {
Text(actionLabel, style = MaterialTheme.typography.labelMedium)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (secondaryAction != null) {
TextButton(onClick = secondaryAction) {
Text(secondaryActionLabel, style = MaterialTheme.typography.labelMedium)
if (onAction != null) {
TextButton(onClick = onAction) {
Text(actionLabel, style = MaterialTheme.typography.labelMedium)
}
}
}
}
@Composable
private fun SectionHeader(
title: String,

View File

@@ -1,12 +1,17 @@
package com.hotdeal.alarm.util
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Environment
import android.widget.Toast
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File
/**
@@ -36,6 +41,8 @@ object ApkDownloadManager {
setAllowedOverMetered(true)
setAllowedOverRoaming(true)
setMimeType("application/vnd.android.package-archive")
// Wi-Fi 환경에서 다운로드 우선
setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
}
val downloadId = downloadManager.enqueue(request)
@@ -49,6 +56,106 @@ object ApkDownloadManager {
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 파일 설치
*/
@@ -80,45 +187,12 @@ object ApkDownloadManager {
}
/**
* 다운로드 완료 여부 확인
* 다운로드 상태 데이터 클래스
*/
fun isDownloadComplete(context: Context, downloadId: Long): Boolean {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
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
}
data class DownloadStatus(
val progress: Int,
val bytesDownloaded: Long,
val isComplete: Boolean,
val isFailed: Boolean
)
}

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(
val hasNotificationPermission: Boolean,
val hasExactAlarmPermission: Boolean,
val canInstallUnknownApps: Boolean,
val isAllGranted: Boolean
)
@@ -99,11 +127,13 @@ object PermissionHelper {
fun checkAllPermissions(context: Context): PermissionStatus {
val hasNotification = hasNotificationPermission(context)
val hasExactAlarm = hasExactAlarmPermission(context)
val canInstallUnknown = canInstallUnknownApps(context)
return PermissionStatus(
hasNotificationPermission = hasNotification,
hasExactAlarmPermission = hasExactAlarm,
isAllGranted = hasNotification && hasExactAlarm
canInstallUnknownApps = canInstallUnknown,
isAllGranted = hasNotification && hasExactAlarm && canInstallUnknown
)
}
}