From feed6b4d00595a85e963ebec77c77bf364f14aed Mon Sep 17 00:00:00 2001 From: leejh08 Date: Thu, 23 Apr 2026 20:18:11 +0900 Subject: [PATCH 1/5] omx(team): auto-checkpoint worker-1 [1] --- .../Components/SelfStudyCheckComponents.swift | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) 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 + } + } } From 47308b5567e099e0b3f4c09d44853c2f0f42bf70 Mon Sep 17 00:00:00 2001 From: leejh08 Date: Wed, 6 May 2026 21:11:29 +0900 Subject: [PATCH 2/5] omx(team): auto-checkpoint worker-2 [unknown] --- Android/app/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index e8f9407..37a72e0 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -94,4 +94,6 @@ android { dependencies { implementation(platform("com.google.firebase:firebase-bom:33.7.0")) implementation("com.google.firebase:firebase-messaging-ktx") + implementation("com.google.android.play:app-update:2.1.0") + implementation("com.google.android.play:app-update-ktx:2.1.0") } From 73d8f970e20787f17e4131d22283119fdfdecd69 Mon Sep 17 00:00:00 2001 From: leejh08 Date: Wed, 6 May 2026 21:13:21 +0900 Subject: [PATCH 3/5] Gate Android app entry on remotely managed update policy Android installs do not receive Play updates immediately or universally, so the app needs an entry-time policy gate that can be changed after release. Add a Remote Config backed version policy, wire Play Core immediate updates for forced releases, and fall back to a non-cancelable Play Store prompt when Play Core cannot start the flow.\n\nConstraint: Skip Android UI starts from MainActivity, so update gating must live in the Android entrypoint rather than only Swift routing.\nConstraint: No backend policy endpoint exists yet, so Firebase Remote Config defaults fail open unless min/latest values are configured.\nRejected: Store-link-only dialog | loses native Play in-app update UX when available.\nRejected: Swift-only startup modal | cannot reliably block before Android activity lifecycle update resume handling.\nConfidence: medium\nScope-risk: moderate\nDirective: Do not raise android_min_supported_version_code until the target version is available to all intended Play users.\nTested: skip android build --plain\nNot-tested: Real Play Core update availability on an Internal Testing track. --- Android/app/build.gradle.kts | 1 + .../src/main/kotlin/AppUpdateCoordinator.kt | 159 ++++++++++++++++++ .../src/main/kotlin/AppVersionPolicyClient.kt | 111 ++++++++++++ Android/app/src/main/kotlin/Main.kt | 13 ++ 4 files changed, 284 insertions(+) create mode 100644 Android/app/src/main/kotlin/AppUpdateCoordinator.kt create mode 100644 Android/app/src/main/kotlin/AppVersionPolicyClient.kt diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 37a72e0..df25f17 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -94,6 +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() } From d59c56220880e7a7836e1874ecdc28b5958fa963 Mon Sep 17 00:00:00 2001 From: leejh08 Date: Wed, 6 May 2026 21:14:12 +0900 Subject: [PATCH 4/5] omx(team): auto-checkpoint worker-1 [1] --- .../src/main/kotlin/AppVersionPolicyClient.kt | 237 ++++++++++++------ 1 file changed, 163 insertions(+), 74 deletions(-) diff --git a/Android/app/src/main/kotlin/AppVersionPolicyClient.kt b/Android/app/src/main/kotlin/AppVersionPolicyClient.kt index 86a6ded..cea5ee8 100644 --- a/Android/app/src/main/kotlin/AppVersionPolicyClient.kt +++ b/Android/app/src/main/kotlin/AppVersionPolicyClient.kt @@ -1,110 +1,199 @@ package pi.ckadmin import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo 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, +import android.os.Handler +import android.os.Looper +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors +import kotlin.math.max + +internal data class AndroidUpdatePolicy( + val latestVersionCode: Int, + val minSupportedVersionCode: Int, + val latestVersionName: String?, val forceMessage: String, val optionalMessage: String, - val playStoreUrl: String, + val playStoreUrl: String +) + +internal sealed interface AndroidUpdateDecision { + data object None : AndroidUpdateDecision + data class Optional(val policy: AndroidUpdatePolicy) : AndroidUpdateDecision + data class Force(val policy: AndroidUpdatePolicy) : AndroidUpdateDecision +} + +internal class AppVersionPolicyClient( + private val context: Context ) { - fun requirementFor(currentVersionCode: Long): AppUpdateRequirement { - return when { - minSupportedVersionCode > currentVersionCode -> AppUpdateRequirement.FORCED - latestVersionCode > currentVersionCode -> AppUpdateRequirement.OPTIONAL - else -> AppUpdateRequirement.NONE + companion object { + const val EXTRA_POLICY_JSON = "pick_update_policy_json" + private const val META_POLICY_JSON = "pi.ckadmin.APP_UPDATE_POLICY_JSON" + private const val META_POLICY_URL = "pi.ckadmin.APP_UPDATE_POLICY_URL" + private const val META_PLAY_STORE_URL = "pi.ckadmin.PLAY_STORE_URL" + private const val DEFAULT_FORCE_MESSAGE = "새 버전이 출시되었습니다. 업데이트 후 이용해주세요." + private const val DEFAULT_OPTIONAL_MESSAGE = "새 버전이 출시되었습니다. 업데이트하시겠습니까?" + private const val CONNECTION_TIMEOUT_MS = 5_000 + private val ioExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "pick-admin-update-policy").apply { + isDaemon = true + } } } -} -enum class AppUpdateRequirement { - NONE, - OPTIONAL, - FORCED, -} + private val mainHandler = Handler(Looper.getMainLooper()) + + fun resolveUpdateDecision( + launchIntent: Intent?, + callback: (AndroidUpdateDecision) -> Unit + ) { + val inlineJson = launchIntent?.getStringExtra(EXTRA_POLICY_JSON)?.takeUnless { it.isBlank() } + ?: manifestMetadataValue(META_POLICY_JSON)?.takeUnless { it.isBlank() } + + if (inlineJson != null) { + val decision = runCatching { + parsePolicy(inlineJson)?.let(::evaluatePolicy) ?: AndroidUpdateDecision.None + }.getOrElse { error -> + logger.warning("failed to parse inline update policy: ${error.message}") + AndroidUpdateDecision.None + } + callback(decision) + return + } + + val policyUrl = manifestMetadataValue(META_POLICY_URL)?.takeUnless { it.isBlank() } + if (policyUrl == null) { + callback(AndroidUpdateDecision.None) + return + } -class AppVersionPolicyClient(private val context: Context) { - private val remoteConfig: FirebaseRemoteConfig = FirebaseRemoteConfig.getInstance() + ioExecutor.execute { + val decision = runCatching { + val payload = fetchPolicyJson(policyUrl) + parsePolicy(payload)?.let(::evaluatePolicy) ?: AndroidUpdateDecision.None + }.getOrElse { error -> + logger.warning("failed to resolve update policy: ${error.message}") + AndroidUpdateDecision.None + } - init { - remoteConfig.setConfigSettingsAsync( - FirebaseRemoteConfigSettings.Builder() - .setMinimumFetchIntervalInSeconds(60 * 60) - .build() + mainHandler.post { + callback(decision) + } + } + } + + private fun evaluatePolicy(policy: AndroidUpdatePolicy): AndroidUpdateDecision { + val currentVersionCode = currentVersionCode() + logger.info( + "app update policy loaded: current=${currentVersionCode}, min=${policy.minSupportedVersionCode}, latest=${policy.latestVersionCode}" ) - 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(), - ) + + return when { + currentVersionCode < policy.minSupportedVersionCode -> AndroidUpdateDecision.Force(policy) + currentVersionCode < policy.latestVersionCode -> AndroidUpdateDecision.Optional(policy) + else -> AndroidUpdateDecision.None + } + } + + private fun parsePolicy(payload: String): AndroidUpdatePolicy? { + val root = JSONObject(payload) + val androidPolicy = if (root.has("android")) root.optJSONObject("android") ?: root else root + + val minSupportedVersionCode = androidPolicy.optInt("minSupportedVersionCode", -1) + val latestVersionCode = androidPolicy.optInt("latestVersionCode", -1) + if (minSupportedVersionCode < 0 && latestVersionCode < 0) { + return null + } + + val normalizedLatestVersionCode = max(latestVersionCode, minSupportedVersionCode) + val normalizedMinSupportedVersionCode = if (minSupportedVersionCode >= 0) { + minSupportedVersionCode + } else { + normalizedLatestVersionCode + } + + return AndroidUpdatePolicy( + latestVersionCode = normalizedLatestVersionCode, + minSupportedVersionCode = normalizedMinSupportedVersionCode, + latestVersionName = androidPolicy.optString("latestVersionName").takeUnless { it.isBlank() }, + forceMessage = androidPolicy.optString("forceMessage") + .takeUnless { it.isBlank() } + ?: DEFAULT_FORCE_MESSAGE, + optionalMessage = androidPolicy.optString("optionalMessage") + .takeUnless { it.isBlank() } + ?: DEFAULT_OPTIONAL_MESSAGE, + playStoreUrl = androidPolicy.optString("playStoreUrl") + .takeUnless { it.isBlank() } + ?: manifestMetadataValue(META_PLAY_STORE_URL) + ?.takeUnless { it.isBlank() } + ?: 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 - } + private fun fetchPolicyJson(policyUrl: String): String { + val connection = URL(policyUrl).openConnection() as HttpURLConnection + return try { + connection.requestMethod = "GET" + connection.connectTimeout = CONNECTION_TIMEOUT_MS + connection.readTimeout = CONNECTION_TIMEOUT_MS + connection.useCaches = false + connection.instanceFollowRedirects = true + + val responseCode = connection.responseCode + if (responseCode !in 200..299) { + throw IllegalStateException("policy request failed with HTTP ${responseCode}") + } - onComplete(currentPolicy()) + connection.inputStream.bufferedReader().use { reader -> + reader.readText() } + } finally { + connection.disconnect() + } } - fun currentVersionCode(): Long { + private fun manifestMetadataValue(key: String): String? = + runCatching { + val applicationInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getApplicationInfo( + context.packageName, + PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()) + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA) + } + + applicationInfo.metaData?.get(key)?.toString() + }.getOrNull() + + private fun currentVersionCode(): Int { val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.packageManager.getPackageInfo( context.packageName, - PackageManager.PackageInfoFlags.of(0), + 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 + return packageInfo.safeVersionCode() + } + + private fun PackageInfo.safeVersionCode(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + longVersionCode.toInt() } else { @Suppress("DEPRECATION") - packageInfo.versionCode.toLong() + versionCode } - } - - 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}" From 06b6c0e6ed3838252351df3265aa0b9960d49b9f Mon Sep 17 00:00:00 2001 From: leejh08 Date: Wed, 6 May 2026 21:15:03 +0900 Subject: [PATCH 5/5] Keep Android update policy aligned with the coordinator contract A team auto-checkpoint replaced the Remote Config policy client with a different manifest/HTTP contract after the coordinator was already wired to fetchPolicy/currentVersionCode. Restore the policy client shape that the Android update coordinator builds against so the feature remains internally consistent.\n\nConstraint: AppUpdateCoordinator depends on AppVersionPolicy, AppUpdateRequirement, fetchPolicy, and currentVersionCode.\nRejected: Rewrite the coordinator around manifest metadata in this pass | it would diverge from the planned Remote Config operating model and duplicate already verified code.\nConfidence: high\nScope-risk: narrow\nDirective: Keep policy source and coordinator API changes in the same commit when changing update-gate architecture.\nTested: skip android build --plain\nNot-tested: Remote Config values fetched from production Firebase. --- .../src/main/kotlin/AppVersionPolicyClient.kt | 237 ++++++------------ 1 file changed, 74 insertions(+), 163 deletions(-) diff --git a/Android/app/src/main/kotlin/AppVersionPolicyClient.kt b/Android/app/src/main/kotlin/AppVersionPolicyClient.kt index cea5ee8..86a6ded 100644 --- a/Android/app/src/main/kotlin/AppVersionPolicyClient.kt +++ b/Android/app/src/main/kotlin/AppVersionPolicyClient.kt @@ -1,199 +1,110 @@ package pi.ckadmin import android.content.Context -import android.content.Intent -import android.content.pm.ApplicationInfo -import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build -import android.os.Handler -import android.os.Looper -import org.json.JSONObject -import java.net.HttpURLConnection -import java.net.URL -import java.util.concurrent.Executors -import kotlin.math.max - -internal data class AndroidUpdatePolicy( - val latestVersionCode: Int, - val minSupportedVersionCode: Int, - val latestVersionName: String?, +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 -) - -internal sealed interface AndroidUpdateDecision { - data object None : AndroidUpdateDecision - data class Optional(val policy: AndroidUpdatePolicy) : AndroidUpdateDecision - data class Force(val policy: AndroidUpdatePolicy) : AndroidUpdateDecision -} - -internal class AppVersionPolicyClient( - private val context: Context + val playStoreUrl: String, ) { - companion object { - const val EXTRA_POLICY_JSON = "pick_update_policy_json" - private const val META_POLICY_JSON = "pi.ckadmin.APP_UPDATE_POLICY_JSON" - private const val META_POLICY_URL = "pi.ckadmin.APP_UPDATE_POLICY_URL" - private const val META_PLAY_STORE_URL = "pi.ckadmin.PLAY_STORE_URL" - private const val DEFAULT_FORCE_MESSAGE = "새 버전이 출시되었습니다. 업데이트 후 이용해주세요." - private const val DEFAULT_OPTIONAL_MESSAGE = "새 버전이 출시되었습니다. 업데이트하시겠습니까?" - private const val CONNECTION_TIMEOUT_MS = 5_000 - private val ioExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "pick-admin-update-policy").apply { - isDaemon = true - } - } - } - - private val mainHandler = Handler(Looper.getMainLooper()) - - fun resolveUpdateDecision( - launchIntent: Intent?, - callback: (AndroidUpdateDecision) -> Unit - ) { - val inlineJson = launchIntent?.getStringExtra(EXTRA_POLICY_JSON)?.takeUnless { it.isBlank() } - ?: manifestMetadataValue(META_POLICY_JSON)?.takeUnless { it.isBlank() } - - if (inlineJson != null) { - val decision = runCatching { - parsePolicy(inlineJson)?.let(::evaluatePolicy) ?: AndroidUpdateDecision.None - }.getOrElse { error -> - logger.warning("failed to parse inline update policy: ${error.message}") - AndroidUpdateDecision.None - } - callback(decision) - return - } - - val policyUrl = manifestMetadataValue(META_POLICY_URL)?.takeUnless { it.isBlank() } - if (policyUrl == null) { - callback(AndroidUpdateDecision.None) - return - } - - ioExecutor.execute { - val decision = runCatching { - val payload = fetchPolicyJson(policyUrl) - parsePolicy(payload)?.let(::evaluatePolicy) ?: AndroidUpdateDecision.None - }.getOrElse { error -> - logger.warning("failed to resolve update policy: ${error.message}") - AndroidUpdateDecision.None - } - - mainHandler.post { - callback(decision) - } - } - } - - private fun evaluatePolicy(policy: AndroidUpdatePolicy): AndroidUpdateDecision { - val currentVersionCode = currentVersionCode() - logger.info( - "app update policy loaded: current=${currentVersionCode}, min=${policy.minSupportedVersionCode}, latest=${policy.latestVersionCode}" - ) - + fun requirementFor(currentVersionCode: Long): AppUpdateRequirement { return when { - currentVersionCode < policy.minSupportedVersionCode -> AndroidUpdateDecision.Force(policy) - currentVersionCode < policy.latestVersionCode -> AndroidUpdateDecision.Optional(policy) - else -> AndroidUpdateDecision.None + minSupportedVersionCode > currentVersionCode -> AppUpdateRequirement.FORCED + latestVersionCode > currentVersionCode -> AppUpdateRequirement.OPTIONAL + else -> AppUpdateRequirement.NONE } } +} - private fun parsePolicy(payload: String): AndroidUpdatePolicy? { - val root = JSONObject(payload) - val androidPolicy = if (root.has("android")) root.optJSONObject("android") ?: root else root +enum class AppUpdateRequirement { + NONE, + OPTIONAL, + FORCED, +} - val minSupportedVersionCode = androidPolicy.optInt("minSupportedVersionCode", -1) - val latestVersionCode = androidPolicy.optInt("latestVersionCode", -1) - if (minSupportedVersionCode < 0 && latestVersionCode < 0) { - return null - } +class AppVersionPolicyClient(private val context: Context) { + private val remoteConfig: FirebaseRemoteConfig = FirebaseRemoteConfig.getInstance() - val normalizedLatestVersionCode = max(latestVersionCode, minSupportedVersionCode) - val normalizedMinSupportedVersionCode = if (minSupportedVersionCode >= 0) { - minSupportedVersionCode - } else { - normalizedLatestVersionCode - } - - return AndroidUpdatePolicy( - latestVersionCode = normalizedLatestVersionCode, - minSupportedVersionCode = normalizedMinSupportedVersionCode, - latestVersionName = androidPolicy.optString("latestVersionName").takeUnless { it.isBlank() }, - forceMessage = androidPolicy.optString("forceMessage") - .takeUnless { it.isBlank() } - ?: DEFAULT_FORCE_MESSAGE, - optionalMessage = androidPolicy.optString("optionalMessage") - .takeUnless { it.isBlank() } - ?: DEFAULT_OPTIONAL_MESSAGE, - playStoreUrl = androidPolicy.optString("playStoreUrl") - .takeUnless { it.isBlank() } - ?: manifestMetadataValue(META_PLAY_STORE_URL) - ?.takeUnless { it.isBlank() } - ?: defaultPlayStoreUrl() + 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(), + ) ) } - private fun fetchPolicyJson(policyUrl: String): String { - val connection = URL(policyUrl).openConnection() as HttpURLConnection - return try { - connection.requestMethod = "GET" - connection.connectTimeout = CONNECTION_TIMEOUT_MS - connection.readTimeout = CONNECTION_TIMEOUT_MS - connection.useCaches = false - connection.instanceFollowRedirects = true - - val responseCode = connection.responseCode - if (responseCode !in 200..299) { - throw IllegalStateException("policy request failed with HTTP ${responseCode}") - } + 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 + } - connection.inputStream.bufferedReader().use { reader -> - reader.readText() + onComplete(currentPolicy()) } - } finally { - connection.disconnect() - } } - private fun manifestMetadataValue(key: String): String? = - runCatching { - val applicationInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.packageManager.getApplicationInfo( - context.packageName, - PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()) - ) - } else { - @Suppress("DEPRECATION") - context.packageManager.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA) - } - - applicationInfo.metaData?.get(key)?.toString() - }.getOrNull() - - private fun currentVersionCode(): Int { + fun currentVersionCode(): Long { val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.packageManager.getPackageInfo( context.packageName, - PackageManager.PackageInfoFlags.of(0) + PackageManager.PackageInfoFlags.of(0), ) } else { @Suppress("DEPRECATION") context.packageManager.getPackageInfo(context.packageName, 0) } - return packageInfo.safeVersionCode() - } - - private fun PackageInfo.safeVersionCode(): Int = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - longVersionCode.toInt() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode } else { @Suppress("DEPRECATION") - versionCode + 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}"