Skip to content
Merged
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
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ android {
applicationId = "com.sopt.clody"
minSdk = 28
targetSdk = 35
versionCode = 27
versionName = "1.3.0"
versionCode = 28
versionName = "1.4.0"
val kakaoApiKey: String = properties.getProperty("kakao.api.key")
val amplitudeApiKey: String = properties.getProperty("amplitude.api.key")
val googleAdmobAppId: String = properties.getProperty("GOOGLE_ADMOB_APP_ID", "")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import com.sopt.clody.data.remote.datasource.RemoteConfigDataSource
import com.sopt.clody.domain.appupdate.AppUpdateChecker
import com.sopt.clody.domain.model.AppUpdateState
import com.sopt.clody.domain.util.VersionComparator
import java.time.LocalDateTime
import java.time.format.TextStyle
import java.util.Locale
import javax.inject.Inject

class AppUpdateCheckerImpl @Inject constructor(
Expand All @@ -30,4 +33,30 @@ class AppUpdateCheckerImpl @Inject constructor(
}
}
}

override suspend fun isUnderInspection(): Boolean {
val start = remoteConfigDataSource.getInspectionStart() ?: return false
val end = remoteConfigDataSource.getInspectionEnd() ?: return false
val now = LocalDateTime.now()
return now.isAfter(start) && now.isBefore(end)
}
Comment on lines +37 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Consider edge cases in time comparison logic.

The current implementation uses isAfter(start) && isBefore(end), which excludes the exact start and end times. For maintenance windows, you might want to include the boundaries.

Additionally, consider timezone implications when using LocalDateTime.now() if the server and client could be in different timezones.

-        return now.isAfter(start) && now.isBefore(end)
+        return (now.isEqual(start) || now.isAfter(start)) && (now.isEqual(end) || now.isBefore(end))

Or more concisely:

-        return now.isAfter(start) && now.isBefore(end)
+        return !now.isBefore(start) && !now.isAfter(end)

Would you like me to search for more information about timezone best practices for maintenance windows?


🌐 Web query:

Best practices for handling timezones in maintenance windows for mobile apps

💡 Result:

When handling timezones in maintenance windows for mobile apps, it's essential to consider user experience, accuracy, and clarity. Here are some best practices:

  1. Use UTC Internally:

    • Store and manage all maintenance windows in Coordinated Universal Time (UTC) to avoid confusion and simplify calculations.
  2. Convert to Local Time:

    • Convert UTC times to the user's local timezone before displaying maintenance windows.
    • Use the device’s current timezone setting to perform conversions.
  3. Clear Communication:

    • Clearly specify the timezone when displaying maintenance windows if not showing local time.
    • Use relative times where applicable (e.g., "Maintenance starts in 2 hours").
  4. Handle Daylight Saving Time (DST):

    • Use reliable timezone libraries that account for DST changes (e.g., moment-timezone in JavaScript).
    • Test maintenance windows around DST changes carefully.
  5. Send Reminders:

    • Provide notifications or reminders about upcoming maintenance in the local timezone.
    • Allow users to set reminders if possible.
  6. Maintain Consistency:

    • Ensure the displayed timezone matches across all parts of the app (notifications, in-app messages, emails).
  7. Consider User Location Changes:

    • Detect if the user changes their timezone and update the displayed maintenance window times dynamically.
  8. Provide Flexibility:

    • Offer users the option to view maintenance times in UTC or another preferred timezone.
  9. Test Extensively:

    • Test the handling of timezones and maintenance windows on devices set to different timezones, including edge cases like the International Date Line.

Would you like suggestions on specific libraries or tools to implement these best practices in your mobile app?


Ensure inclusive time checks and normalize to UTC

Please update the inspection‐window logic in app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt to:

  • Include the exact start and end times (avoid excluding boundaries).
  • Use UTC internally (e.g. Instant or ZonedDateTime with ZoneOffset.UTC) to eliminate timezone drift.
  • Convert to the device’s local timezone only when displaying to the user.

Locations to update:

  • AppUpdateCheckerImpl.kt, isUnderInspection() method.

Suggested diff:

-    override suspend fun isUnderInspection(): Boolean {
-        val start = remoteConfigDataSource.getInspectionStart() ?: return false
-        val end = remoteConfigDataSource.getInspectionEnd() ?: return false
-        val now = LocalDateTime.now()
-        return now.isAfter(start) && now.isBefore(end)
-    }
+    override suspend fun isUnderInspection(): Boolean {
+        // Treat all times as UTC Instants
+        val start = remoteConfigDataSource.getInspectionStartUtc() ?: return false
+        val end = remoteConfigDataSource.getInspectionEndUtc() ?: return false
+        val now = Instant.now()
+        // inclusive: now ≥ start && now ≤ end
+        return !now.isBefore(start) && !now.isAfter(end)
+    }

If your RemoteConfig returns LocalDateTime, convert it:

val startUtc = start.atZone(ZoneId.systemDefault())
                     .withZoneSameInstant(ZoneOffset.UTC)
                     .toInstant()

And when showing maintenance times in‐app, convert back:

val userLocal = Instant.parse(remoteConfigTime)
                .atZone(ZoneId.systemDefault())

This approach:

  • Guarantees inclusive windows.
  • Centralizes all comparisons in UTC.
  • Avoids DST and device‐timezone inconsistencies.
🤖 Prompt for AI Agents
In
app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt
around lines 37 to 42, update the isUnderInspection() method to perform
inclusive time checks by using isEqual or isAfter for the start time and isEqual
or isBefore for the end time. Convert the start and end LocalDateTime values to
UTC Instants before comparison by first assigning the system default zone then
converting to ZoneOffset.UTC and to Instant. Use these UTC Instants for the time
range check against the current time in UTC. This ensures all comparisons are
timezone-agnostic and inclusive of boundary times.


override fun getInspectionTimeText(): String? {
val start = remoteConfigDataSource.getInspectionStart()
val end = remoteConfigDataSource.getInspectionEnd()
if (start == null || end == null) return null

val startText = formatDateTimeWithDayOfWeek(start)
val endText = formatDateTimeWithDayOfWeek(end)
return "$startText ~ $endText"
}

private fun formatDateTimeWithDayOfWeek(dateTime: LocalDateTime): String {
val dayOfWeek = dateTime.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.KOREAN)
val month = dateTime.monthValue
val day = dateTime.dayOfMonth
val hour = dateTime.hour.toString().padStart(2, '0')

return "$month/$day($dayOfWeek) ${hour}시"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.sopt.clody.data.remote.datasource
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.sopt.clody.BuildConfig
import kotlinx.coroutines.tasks.await
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -20,8 +22,22 @@ class RemoteConfigDataSource @Inject constructor(
fun getMinimumVersion(): String =
remoteConfig.getString(KEY_MINIMUM_VERSION).ifEmpty { BuildConfig.VERSION_NAME }

fun getInspectionStart(): LocalDateTime? =
remoteConfig.getString(KEY_INSPECTION_START).takeIf { it.isNotBlank() }?.let {
runCatching { LocalDateTime.parse(it, formatter) }.getOrNull()
}

fun getInspectionEnd(): LocalDateTime? =
remoteConfig.getString(KEY_INSPECTION_END).takeIf { it.isNotBlank() }?.let {
runCatching { LocalDateTime.parse(it, formatter) }.getOrNull()
}

companion object {
private const val KEY_LATEST_VERSION = "latest_version"
private const val KEY_MINIMUM_VERSION = "min_required_version"
private const val KEY_INSPECTION_START = "inspection_start_android"
private const val KEY_INSPECTION_END = "inspection_end_android"

private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
}
}
2 changes: 1 addition & 1 deletion app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ object AppUpdateModule {
fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig {
val remoteConfig = FirebaseRemoteConfig.getInstance()
val configSettings = FirebaseRemoteConfigSettings.Builder()
.setMinimumFetchIntervalInSeconds(600L)
.setMinimumFetchIntervalInSeconds(0)
.build()
remoteConfig.setConfigSettingsAsync(configSettings)
return remoteConfig
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ import com.sopt.clody.domain.model.AppUpdateState

interface AppUpdateChecker {
suspend fun getAppUpdateState(currentVersion: String): AppUpdateState
suspend fun isUnderInspection(): Boolean
fun getInspectionTimeText(): String?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.sopt.clody.presentation.ui.component.dialog

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.sopt.clody.R
import com.sopt.clody.presentation.utils.base.BasePreview
import com.sopt.clody.presentation.utils.base.ClodyPreview
import com.sopt.clody.ui.theme.ClodyTheme

@Composable
fun InspectionDialog(
inspectionTime: String,
onDismiss: () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnClickOutside = false,
dismissOnBackPress = false,
usePlatformDefaultWidth = false,
),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 24.dp),
contentAlignment = Alignment.Center,
) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = ClodyTheme.colors.white),
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(id = R.drawable.img_inspection_dialog),
contentDescription = null,
)
Spacer(modifier = Modifier.height(20.dp))
Comment on lines +60 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Image lacks an accessibility description

Provide a meaningful contentDescription or explicitly set it to an empty string "" with a comment explaining that it’s decorative.

-                    Image(
-                        painter = painterResource(id = R.drawable.img_inspection_dialog),
-                        contentDescription = null,
-                    )
+                    Image(
+                        painter = painterResource(id = R.drawable.img_inspection_dialog),
+                        contentDescription = stringResource(R.string.desc_inspection_image), // or "" if purely decorative
+                    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Image(
painter = painterResource(id = R.drawable.img_inspection_dialog),
contentDescription = null,
)
Spacer(modifier = Modifier.height(20.dp))
Image(
painter = painterResource(id = R.drawable.img_inspection_dialog),
contentDescription = stringResource(R.string.desc_inspection_image), // or "" if purely decorative
)
Spacer(modifier = Modifier.height(20.dp))
🤖 Prompt for AI Agents
In
app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/InspectionDialog.kt
around lines 59 to 63, the Image composable is missing a contentDescription,
which is important for accessibility. Fix this by providing a meaningful
contentDescription if the image conveys important information, or set
contentDescription to an empty string "" with a comment explaining that the
image is purely decorative.

Text(
text = "보다 안정적인 클로디 서비스를 위해\n시스템 점검 중이에요. 곧 다시 만나요!",
color = ClodyTheme.colors.gray03,
textAlign = TextAlign.Center,
style = ClodyTheme.typography.body3Medium,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "점검시간 : $inspectionTime",
color = ClodyTheme.colors.gray04,
textAlign = TextAlign.Center,
style = ClodyTheme.typography.body3Medium,
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onDismiss,
modifier = Modifier
.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(ClodyTheme.colors.mainYellow),
) {
Text(
text = "확인",
color = ClodyTheme.colors.gray02,
style = ClodyTheme.typography.body3SemiBold,
)
}
}
}
}
}
}

@ClodyPreview
@Composable
private fun PreviewInspectionDialog() {
BasePreview {
InspectionDialog(
inspectionTime = "",
onDismiss = {},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@ import com.airbnb.mvrx.MavericksState
import com.sopt.clody.domain.model.AppUpdateState

class SplashContract {

data class SplashState(
val isUserLoggedIn: Boolean? = null,
val updateState: AppUpdateState? = null,
val showInspectionDialog: Boolean = false,
val inspectionTimeText: String? = null,
) : MavericksState

sealed class SplashIntent {
data class InitSplash(val startIntent: Intent) : SplashIntent()
data class HandleHardUpdate(val isConfirm: Boolean) : SplashIntent()
data object HandleSoftUpdateConfirm : SplashIntent()
data object ClearUpdateState : SplashIntent()
data object DismissInspectionDialog : SplashIntent()
}

sealed interface SplashSideEffect {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import com.sopt.clody.R
import com.sopt.clody.domain.model.AppUpdateState
import com.sopt.clody.presentation.ui.component.dialog.InspectionDialog
import com.sopt.clody.presentation.utils.appupdate.AppUpdateUtils
import com.sopt.clody.presentation.utils.base.BasePreview
import com.sopt.clody.presentation.utils.base.ClodyPreview
Expand Down Expand Up @@ -93,6 +94,14 @@ fun SplashRoute(
else -> {}
}

if (state.showInspectionDialog) {
InspectionDialog(
inspectionTime = state.inspectionTimeText.orEmpty(),
onDismiss = {
viewModel.postIntent(SplashContract.SplashIntent.DismissInspectionDialog)
},
)
}
SplashScreen()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,31 @@ class SplashViewModel @AssistedInject constructor(
is SplashContract.SplashIntent.HandleHardUpdate -> handleHardUpdate(intent)
is SplashContract.SplashIntent.HandleSoftUpdateConfirm -> handleSoftUpdateConfirm()
is SplashContract.SplashIntent.ClearUpdateState -> clearUpdateState()
is SplashContract.SplashIntent.DismissInspectionDialog -> handleDismissInspection()
}
}

private fun handleInitSplash(intent: SplashContract.SplashIntent.InitSplash) {
private suspend fun handleInitSplash(intent: SplashContract.SplashIntent.InitSplash) {
if (intent.startIntent.hasExtra("google.message_id")) {
AmplitudeUtils.trackEvent(AmplitudeConstraints.ALARM)
}
attemptAutoLogin()
if (checkInspectionAndHandle()) return
checkVersionAndNavigate()
attemptAutoLogin()
}

private suspend fun checkInspectionAndHandle(): Boolean {
if (appUpdateChecker.isUnderInspection()) {
val inspectionText = appUpdateChecker.getInspectionTimeText()
setState {
copy(
showInspectionDialog = true,
inspectionTimeText = inspectionText,
)
}
return true
}
return false
}

private fun attemptAutoLogin() {
Expand All @@ -66,20 +82,18 @@ class SplashViewModel @AssistedInject constructor(
setState { copy(isUserLoggedIn = isLoggedIn) }
}

private fun checkVersionAndNavigate() {
viewModelScope.launch {
val updateState = appUpdateChecker.getAppUpdateState(BuildConfig.VERSION_NAME)
setState { copy(updateState = updateState) }
private suspend fun checkVersionAndNavigate() {
val updateState = appUpdateChecker.getAppUpdateState(BuildConfig.VERSION_NAME)
setState { copy(updateState = updateState) }

if (updateState == AppUpdateState.Latest) {
delay(1000)
val isLoggedIn = withState(this@SplashViewModel) { it.isUserLoggedIn }
if (updateState == AppUpdateState.Latest) {
delay(1000)
val isLoggedIn = withState(this@SplashViewModel) { it.isUserLoggedIn }

if (isLoggedIn == true) {
_sideEffects.send(SplashContract.SplashSideEffect.NavigateToHome)
} else {
_sideEffects.send(SplashContract.SplashSideEffect.NavigateToLogin)
}
if (isLoggedIn == true) {
_sideEffects.send(SplashContract.SplashSideEffect.NavigateToHome)
} else {
_sideEffects.send(SplashContract.SplashSideEffect.NavigateToLogin)
}
}
}
Expand All @@ -100,6 +114,11 @@ class SplashViewModel @AssistedInject constructor(
setState { copy(updateState = AppUpdateState.Latest) }
}

private suspend fun handleDismissInspection() {
setState { copy(showInspectionDialog = false) }
_sideEffects.send(SplashContract.SplashSideEffect.FinishApp)
}

@AssistedFactory
interface Factory : AssistedViewModelFactory<SplashViewModel, SplashContract.SplashState> {
override fun create(state: SplashContract.SplashState): SplashViewModel
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.