feat: 권한 설정 UI 개선 및 APK 다운로드 버그 수정
- 설정 화면에 리마인더 및 알 수 없는 앱 설치 권한 체크 추가 - PermissionHelper에 canInstallUnknownApps, openUnknownAppsSettings 추가 - APK 다운로드 BroadcastReceiver 사용하도록 개선 - 다운로드 실패 처리 및 진행률 개선
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user