Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
159 changes: 159 additions & 0 deletions Android/app/src/main/kotlin/AppUpdateCoordinator.kt
Original file line number Diff line number Diff line change
@@ -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<IntentSenderRequest>,
) {
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)
}
)
}
}
}
111 changes: 111 additions & 0 deletions Android/app/src/main/kotlin/AppVersionPolicyClient.kt
Original file line number Diff line number Diff line change
@@ -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}"
}
13 changes: 13 additions & 0 deletions Android/app/src/main/kotlin/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -111,6 +121,8 @@ open class MainActivity: AppCompatActivity {
1
)
}

appUpdateCoordinator.checkForUpdates()
}

override fun onStart() {
Expand All @@ -120,6 +132,7 @@ open class MainActivity: AppCompatActivity {

override fun onResume() {
super.onResume()
appUpdateCoordinator.resumeImmediateUpdateIfNeeded()
AppDelegate.shared.onResume()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand All @@ -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
}
}
}