From fb4f5e9ef40c22ea13f8ee6aac97ea9c5fed41e1 Mon Sep 17 00:00:00 2001 From: sanjeok77 Date: Wed, 4 Mar 2026 23:40:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B6=8C=ED=95=9C=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20UI=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20APK=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정 화면에 리마인더 및 알 수 없는 앱 설치 권한 체크 추가 - PermissionHelper에 canInstallUnknownApps, openUnknownAppsSettings 추가 - APK 다운로드 BroadcastReceiver 사용하도록 개선 - 다운로드 실패 처리 및 진행률 개선 --- .../alarm/presentation/main/MainActivity.kt | 55 +++++-- .../presentation/settings/SettingsScreen.kt | 106 ++++++++---- .../hotdeal/alarm/util/ApkDownloadManager.kt | 154 +++++++++++++----- .../hotdeal/alarm/util/PermissionHelper.kt | 34 +++- 4 files changed, 264 insertions(+), 85 deletions(-) diff --git a/app/src/main/java/com/hotdeal/alarm/presentation/main/MainActivity.kt b/app/src/main/java/com/hotdeal/alarm/presentation/main/MainActivity.kt index 7df48a9..8225474 100644 --- a/app/src/main/java/com/hotdeal/alarm/presentation/main/MainActivity.kt +++ b/app/src/main/java/com/hotdeal/alarm/presentation/main/MainActivity.kt @@ -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분 간격으로 폴� 시작 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( diff --git a/app/src/main/java/com/hotdeal/alarm/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/hotdeal/alarm/presentation/settings/SettingsScreen.kt index 00c7e22..6567c53 100644 --- a/app/src/main/java/com/hotdeal/alarm/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/hotdeal/alarm/presentation/settings/SettingsScreen.kt @@ -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, diff --git a/app/src/main/java/com/hotdeal/alarm/util/ApkDownloadManager.kt b/app/src/main/java/com/hotdeal/alarm/util/ApkDownloadManager.kt index 18bc043..6b1ed56 100644 --- a/app/src/main/java/com/hotdeal/alarm/util/ApkDownloadManager.kt +++ b/app/src/main/java/com/hotdeal/alarm/util/ApkDownloadManager.kt @@ -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 + ) } diff --git a/app/src/main/java/com/hotdeal/alarm/util/PermissionHelper.kt b/app/src/main/java/com/hotdeal/alarm/util/PermissionHelper.kt index 874ea9a..41b63ba 100644 --- a/app/src/main/java/com/hotdeal/alarm/util/PermissionHelper.kt +++ b/app/src/main/java/com/hotdeal/alarm/util/PermissionHelper.kt @@ -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 ) } }