diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index e8f9407..df25f17 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -94,4 +94,7 @@ android { dependencies { implementation(platform("com.google.firebase:firebase-bom:33.7.0")) implementation("com.google.firebase:firebase-messaging-ktx") + implementation("com.google.firebase:firebase-config") + implementation("com.google.android.play:app-update:2.1.0") + implementation("com.google.android.play:app-update-ktx:2.1.0") } diff --git a/Android/app/src/main/kotlin/AppUpdateCoordinator.kt b/Android/app/src/main/kotlin/AppUpdateCoordinator.kt new file mode 100644 index 0000000..00cd8db --- /dev/null +++ b/Android/app/src/main/kotlin/AppUpdateCoordinator.kt @@ -0,0 +1,159 @@ +package pi.ckadmin + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import com.google.android.play.core.appupdate.AppUpdateManager +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.InstallStatus +import com.google.android.play.core.install.model.AppUpdateType +import com.google.android.play.core.install.model.UpdateAvailability + +private const val PLAY_STORE_PACKAGE = "com.android.vending" + +class AppUpdateCoordinator( + private val activity: AppCompatActivity, + private val updateLauncher: ActivityResultLauncher, +) { + private val policyClient = AppVersionPolicyClient(activity) + private val appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(activity) + + private var activeForcedPolicy: AppVersionPolicy? = null + private var optionalPromptShown = false + + fun checkForUpdates() { + policyClient.fetchPolicy { policy -> + if (policy == null) { + logger.info("No update policy available; allowing app launch") + return@fetchPolicy + } + + val currentVersionCode = policyClient.currentVersionCode() + when (policy.requirementFor(currentVersionCode)) { + AppUpdateRequirement.FORCED -> { + logger.info("Forced app update required: current=$currentVersionCode min=${policy.minSupportedVersionCode}") + activeForcedPolicy = policy + startImmediateUpdateOrFallback(policy) + } + AppUpdateRequirement.OPTIONAL -> { + logger.info("Optional app update available: current=$currentVersionCode latest=${policy.latestVersionCode}") + showOptionalUpdateDialog(policy) + } + AppUpdateRequirement.NONE -> { + logger.info("App update not required: current=$currentVersionCode") + } + } + } + } + + fun resumeImmediateUpdateIfNeeded() { + appUpdateManager.appUpdateInfo + .addOnSuccessListener { appUpdateInfo -> + when { + appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED -> { + appUpdateManager.completeUpdate() + } + appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS -> { + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + updateLauncher, + AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(), + ) + } + } + } + .addOnFailureListener { error -> + logger.warning("Failed to resume in-app update: $error") + } + } + + fun onUpdateResult(resultCode: Int) { + if (resultCode == Activity.RESULT_OK) { + activeForcedPolicy = null + return + } + + activeForcedPolicy?.let { policy -> + logger.warning("Forced in-app update was cancelled or failed; showing Play Store fallback") + showForcedUpdateDialog(policy) + } + } + + private fun startImmediateUpdateOrFallback(policy: AppVersionPolicy) { + appUpdateManager.appUpdateInfo + .addOnSuccessListener { appUpdateInfo -> + val canStartImmediateUpdate = + appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE && + appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) + + if (canStartImmediateUpdate) { + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + updateLauncher, + AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(), + ) + } else { + logger.info("Immediate in-app update unavailable; showing Play Store fallback") + showForcedUpdateDialog(policy) + } + } + .addOnFailureListener { error -> + logger.warning("Failed to query in-app update availability: $error") + showForcedUpdateDialog(policy) + } + } + + private fun showForcedUpdateDialog(policy: AppVersionPolicy) { + if (activity.isFinishing || activity.isDestroyed) { return } + + AlertDialog.Builder(activity) + .setTitle("업데이트가 필요합니다") + .setMessage(policy.forceMessage) + .setCancelable(false) + .setPositiveButton("업데이트") { _, _ -> + openPlayStore(policy.playStoreUrl) + showForcedUpdateDialog(policy) + } + .show() + } + + private fun showOptionalUpdateDialog(policy: AppVersionPolicy) { + if (optionalPromptShown || activity.isFinishing || activity.isDestroyed) { return } + optionalPromptShown = true + + AlertDialog.Builder(activity) + .setTitle("새 버전이 출시되었습니다") + .setMessage(policy.optionalMessage) + .setPositiveButton("업데이트") { _, _ -> + openPlayStore(policy.playStoreUrl) + } + .setNegativeButton("나중에", null) + .show() + } + + private fun openPlayStore(webUrl: String) { + val marketIntent = Intent( + Intent.ACTION_VIEW, + Uri.parse("market://details?id=${activity.packageName}"), + ).apply { + setPackage(PLAY_STORE_PACKAGE) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + try { + activity.startActivity(marketIntent) + } catch (_: ActivityNotFoundException) { + activity.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(webUrl)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + } + } +} diff --git a/Android/app/src/main/kotlin/AppVersionPolicyClient.kt b/Android/app/src/main/kotlin/AppVersionPolicyClient.kt new file mode 100644 index 0000000..86a6ded --- /dev/null +++ b/Android/app/src/main/kotlin/AppVersionPolicyClient.kt @@ -0,0 +1,111 @@ +package pi.ckadmin + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings + +private const val KEY_LATEST_VERSION_CODE = "android_latest_version_code" +private const val KEY_MIN_SUPPORTED_VERSION_CODE = "android_min_supported_version_code" +private const val KEY_LATEST_VERSION_NAME = "android_latest_version_name" +private const val KEY_FORCE_MESSAGE = "android_force_update_message" +private const val KEY_OPTIONAL_MESSAGE = "android_optional_update_message" +private const val KEY_PLAY_STORE_URL = "android_play_store_url" + +private const val DEFAULT_FORCE_MESSAGE = "새 버전이 출시되었습니다. 업데이트 후 이용해주세요." +private const val DEFAULT_OPTIONAL_MESSAGE = "새 버전이 출시되었습니다. 업데이트하시겠습니까?" + +data class AppVersionPolicy( + val latestVersionCode: Long, + val minSupportedVersionCode: Long, + val latestVersionName: String, + val forceMessage: String, + val optionalMessage: String, + val playStoreUrl: String, +) { + fun requirementFor(currentVersionCode: Long): AppUpdateRequirement { + return when { + minSupportedVersionCode > currentVersionCode -> AppUpdateRequirement.FORCED + latestVersionCode > currentVersionCode -> AppUpdateRequirement.OPTIONAL + else -> AppUpdateRequirement.NONE + } + } +} + +enum class AppUpdateRequirement { + NONE, + OPTIONAL, + FORCED, +} + +class AppVersionPolicyClient(private val context: Context) { + private val remoteConfig: FirebaseRemoteConfig = FirebaseRemoteConfig.getInstance() + + init { + remoteConfig.setConfigSettingsAsync( + FirebaseRemoteConfigSettings.Builder() + .setMinimumFetchIntervalInSeconds(60 * 60) + .build() + ) + remoteConfig.setDefaultsAsync( + mapOf( + KEY_LATEST_VERSION_CODE to 0L, + KEY_MIN_SUPPORTED_VERSION_CODE to 0L, + KEY_LATEST_VERSION_NAME to "", + KEY_FORCE_MESSAGE to DEFAULT_FORCE_MESSAGE, + KEY_OPTIONAL_MESSAGE to DEFAULT_OPTIONAL_MESSAGE, + KEY_PLAY_STORE_URL to defaultPlayStoreUrl(), + ) + ) + } + + fun fetchPolicy(onComplete: (AppVersionPolicy?) -> Unit) { + remoteConfig.fetchAndActivate() + .addOnCompleteListener { task -> + if (!task.isSuccessful) { + logger.warning("Remote config update policy fetch failed: ${task.exception}") + onComplete(null) + return@addOnCompleteListener + } + + onComplete(currentPolicy()) + } + } + + fun currentVersionCode(): Long { + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo( + context.packageName, + PackageManager.PackageInfoFlags.of(0), + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, 0) + } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + } + } + + private fun currentPolicy(): AppVersionPolicy { + val playStoreUrl = remoteConfig.getString(KEY_PLAY_STORE_URL) + .ifBlank { defaultPlayStoreUrl() } + + return AppVersionPolicy( + latestVersionCode = remoteConfig.getLong(KEY_LATEST_VERSION_CODE), + minSupportedVersionCode = remoteConfig.getLong(KEY_MIN_SUPPORTED_VERSION_CODE), + latestVersionName = remoteConfig.getString(KEY_LATEST_VERSION_NAME), + forceMessage = remoteConfig.getString(KEY_FORCE_MESSAGE).ifBlank { DEFAULT_FORCE_MESSAGE }, + optionalMessage = remoteConfig.getString(KEY_OPTIONAL_MESSAGE).ifBlank { DEFAULT_OPTIONAL_MESSAGE }, + playStoreUrl = playStoreUrl, + ) + } + + private fun defaultPlayStoreUrl(): String = + "https://play.google.com/store/apps/details?id=${context.packageName}" +} diff --git a/Android/app/src/main/kotlin/Main.kt b/Android/app/src/main/kotlin/Main.kt index 0e80f09..8996b66 100644 --- a/Android/app/src/main/kotlin/Main.kt +++ b/Android/app/src/main/kotlin/Main.kt @@ -12,6 +12,8 @@ import android.app.NotificationManager import android.content.Context import android.os.Build import android.graphics.Color as AndroidColor +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.SystemBarStyle @@ -85,12 +87,20 @@ open class AndroidAppMain: Application { /// AndroidAppMain is initial `androidx.appcompat.app.AppCompatActivity`, and must match `activity android:name` in the AndroidMainfest.xml file. open class MainActivity: AppCompatActivity { + private lateinit var appUpdateCoordinator: AppUpdateCoordinator + private val updateLauncher = registerForActivityResult( + ActivityResultContracts.StartIntentSenderForResult() + ) { result -> + appUpdateCoordinator.onUpdateResult(result.resultCode) + } + constructor() { } override fun onCreate(savedInstanceState: android.os.Bundle?) { super.onCreate(savedInstanceState) logger.info("starting activity") + appUpdateCoordinator = AppUpdateCoordinator(this, updateLauncher) UIApplication.launch(this) enableEdgeToEdge() @@ -111,6 +121,8 @@ open class MainActivity: AppCompatActivity { 1 ) } + + appUpdateCoordinator.checkForUpdates() } override fun onStart() { @@ -120,6 +132,7 @@ open class MainActivity: AppCompatActivity { override fun onResume() { super.onResume() + appUpdateCoordinator.resumeImmediateUpdateIfNeeded() AppDelegate.shared.onResume() } diff --git a/Sources/PiCKAdmin/Features/SelfStudyCheck/Components/SelfStudyCheckComponents.swift b/Sources/PiCKAdmin/Features/SelfStudyCheck/Components/SelfStudyCheckComponents.swift index 1b7cec2..07c39de 100644 --- a/Sources/PiCKAdmin/Features/SelfStudyCheck/Components/SelfStudyCheckComponents.swift +++ b/Sources/PiCKAdmin/Features/SelfStudyCheck/Components/SelfStudyCheckComponents.swift @@ -22,9 +22,16 @@ struct StudentAttendanceCell: View { .pickText(type: .body2, textColor: statusColor(student.status)) .padding(.horizontal, 16) .padding(.vertical, 8) - .background(statusBackgroundColor(student.status)) - .cornerRadius(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(statusBackgroundColor(student.status)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(statusBorderColor(student.status), lineWidth: 1) + ) } + .buttonStyle(.plain) } .padding(.vertical, 12) .padding(.horizontal, 16) @@ -36,13 +43,13 @@ struct StudentAttendanceCell: View { private func statusColor(_ status: String) -> Color { switch status { case "출석": - return .Primary.primary500 + return .Normal.white case "이동": - return .Gray.gray700 + return .Normal.white case "귀가", "외출": - return .Primary.primary400 + return .Error.error case "현체", "취업": - return .Gray.gray600 + return .Gray.gray900 default: return .Normal.black } @@ -51,15 +58,30 @@ struct StudentAttendanceCell: View { private func statusBackgroundColor(_ status: String) -> Color { switch status { case "출석": - return .Primary.primary50 + return .Primary.primary500 case "이동": - return .Gray.gray100 + return .Gray.gray700 case "귀가", "외출": - return .Primary.primary50.opacity(0.5) + return .Error.errorLight case "현체", "취업": - return .Gray.gray100 + return .Gray.gray200 default: return .Gray.gray100 } } + + private func statusBorderColor(_ status: String) -> Color { + switch status { + case "출석": + return .Primary.primary500 + case "이동": + return .Gray.gray700 + case "귀가", "외출": + return .Error.error + case "현체", "취업": + return .Gray.gray600 + default: + return .Gray.gray400 + } + } }