Young Devs Bin
๐Ÿ”ง Dev Log

Just3: Permission Flow Overhaul, Pretendard Font, and Calendar Sync Fix

ยท10 min readยท#android#kotlin#compose#calendarcontract#permissions#just3

What I Fixed

Three things broke the app in ways that were visible immediately on real-device testing:

  1. Permission flow โ€” Calendar permission showed "permanently denied" on first launch. Notification and alarm permissions weren't handled at all.
  2. Font โ€” Default system font. Switched to Pretendard Variable, but the first approach (Google Fonts API) didn't work.
  3. Duplicate calendar events โ€” Saving different tasks on the same day stacked events instead of replacing them. One day could accumulate 6, 9, or more events.

Permission Flow

The Bug

On first launch, tapping "Grant Permission" in the Calendar screen immediately showed a "permanently denied" dialog โ€” before the user had ever seen the system permission dialog.

The cause is a well-known Android quirk: shouldShowRequestPermissionRationale() returns false for both the never-asked state and the permanently denied state. There is no API to distinguish them before showing the system dialog.

The original code checked shouldShowRationale before launching the permission request. First launch โ†’ false โ†’ assumed permanently denied โ†’ showed settings dialog. Wrong.

The Fix: Only Check After the System Dialog

The rule is simple: check shouldShowRequestPermissionRationale() inside the ActivityResultLauncher callback, not before it. By the time the callback fires, the system dialog has already been shown โ€” so false now reliably means "permanently denied."

val permissionLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.RequestMultiplePermissions()
) { results ->
    val allGranted = results.values.all { it }
    if (allGranted) {
        viewModel.refreshCalendarSync()
    } else {
        val permanentlyDenied = results.filter { !it.value }.keys.none { permission ->
            activity?.shouldShowRequestPermissionRationale(permission) == true
        }
        if (permanentlyDenied) showSettingsDialog = true
    }
}

The full flow per permission category:

StateBehavior
Never askedpermissionLauncher.launch(...) directly
Denied once (can ask again)Show rationale dialog โ†’ confirm โ†’ permissionLauncher.launch(...)
Permanently deniedShow settings dialog โ†’ "Open Settings" โ†’ ACTION_APPLICATION_DETAILS_SETTINGS
fun handleGrantClick() {
    val denied = calendarPermissions.filter {
        ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
    }
    if (denied.isEmpty()) { viewModel.refreshCalendarSync(); return }
 
    val shouldRationale = denied.any {
        activity?.shouldShowRequestPermissionRationale(it) == true
    }
    if (shouldRationale) showRationaleDialog = true
    else permissionLauncher.launch(denied.toTypedArray())
}

One more thing: the previous code had a LaunchedEffect(Unit) in CalendarScreen that auto-launched the permission request on entry. That's what caused the dialog to appear the moment the user navigated to the screen. Removed โ€” the flow now only starts when the user taps "Grant Permission."

Three Independent Permissions

Calendar, notifications, and alarms are now completely separate. Each is requested at the point where the user actually needs it.

READ_CALENDAR / WRITE_CALENDAR โ€” triggered by "Grant Permission" in Calendar screen, or by "Add to Calendar" in Time Box screen.

POST_NOTIFICATIONS (Android 13+) โ€” triggered lazily when the user taps + Add Reminder on a specific task. Not requested on save if no task has a reminder configured.

fun handleAddReminder(itemId: Long, minutes: Int) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        val granted = ContextCompat.checkSelfPermission(
            context, Manifest.permission.POST_NOTIFICATIONS
        ) == PackageManager.PERMISSION_GRANTED
 
        if (!granted) {
            pendingReminderAction = itemId to minutes
            val shouldRationale = activity?.shouldShowRequestPermissionRationale(
                Manifest.permission.POST_NOTIFICATIONS
            ) == true
            if (shouldRationale) showNotifRationaleDialog = true
            else notifForReminderLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
            return
        }
    }
    viewModel.addReminderTime(itemId, minutes)
}

The pendingReminderAction holds the (itemId, minutes) pair. After the launcher callback fires (granted or denied), the pending action is consumed and viewModel.addReminderTime() is called regardless โ€” the user tried to add a reminder, so we add it. The actual notification just won't fire if permission was denied.

SCHEDULE_EXACT_ALARM (Android 12+) โ€” triggered by + Add Alarm. This one cannot go through ActivityResultContracts.RequestPermission(). The only way to get it is Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM. So the "dialog" here is just a settings redirect โ€” no system permission dialog stage.

fun handleAddAlarm(itemId: Long, minutes: Int) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        val canSchedule = alarmManager?.canScheduleExactAlarms() != false
        if (!canSchedule) {
            pendingAlarmAction = itemId to minutes
            showAlarmSettingsDialog = true
            return
        }
    }
    viewModel.addAlarmTime(itemId, minutes)
}

Toast โ†’ Custom Dialog

The error state was a Toast saying "Add calendar in Settings." Replaced with a PermissionDialog composable โ€” a Dialog wrapping a Card with consistent layout across all three permission categories:

Dialog(onDismissRequest = onDismiss) {
    Card(shape = RoundedCornerShape(24.dp)) {
        Column(
            modifier = Modifier.padding(24.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            Icon(Icons.Rounded.Warning, contentDescription = null, modifier = Modifier.size(36.dp))
            Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
            Text(body, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center)
            Spacer(Modifier.height(4.dp))
            Button(onClick = onConfirm, modifier = Modifier.fillMaxWidth()) { Text(confirmLabel) }
            TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.common_cancel)) }
        }
    }
}

Spacer(4.dp) before the buttons matters โ€” without it, the Button and TextButton were visually jammed together.


Font: Pretendard Variable

Why Google Fonts API Didn't Work

The plan was to use ui-text-google-fonts and load Nunito via the provider:

val NunitoFontFamily = FontFamily(
    Font(GoogleFont("Nunito"), provider, FontWeight.Normal),
    Font(GoogleFont("Nunito"), provider, FontWeight.Bold),
)

Two problems:

  1. Missing cert array. ui-text-google-fonts 1.8.2 ships with an empty values.xml. R.array.com_google_android_gms_fonts_certs doesn't exist unless you manually create font_certs.xml with the full Google Fonts provider certificate bundle.

  2. Async download. The Google Fonts provider downloads the font from the network on first launch. The fallback is the system font. The user sees the default font on first cold start and the custom font on subsequent ones. Not acceptable.

Bundled Variable Font

Switched to Pretendard Variable โ€” a single .ttf file (6.4 MB) that covers weights 100โ€“900 and includes both Korean and Latin glyphs. Loaded from res/font/ immediately on first frame.

Variable fonts in Compose require FontVariation.Settings to pin a specific axis value per weight entry:

@OptIn(ExperimentalTextApi::class)
val PretendardFontFamily = FontFamily(
    Font(
        resId = R.font.pretendard_variable,
        weight = FontWeight.Normal,
        variationSettings = FontVariation.Settings(FontVariation.weight(400)),
    ),
    Font(
        resId = R.font.pretendard_variable,
        weight = FontWeight.Medium,
        variationSettings = FontVariation.Settings(FontVariation.weight(500)),
    ),
    Font(
        resId = R.font.pretendard_variable,
        weight = FontWeight.SemiBold,
        variationSettings = FontVariation.Settings(FontVariation.weight(600)),
    ),
    Font(
        resId = R.font.pretendard_variable,
        weight = FontWeight.Bold,
        variationSettings = FontVariation.Settings(FontVariation.weight(700)),
    ),
    Font(
        resId = R.font.pretendard_variable,
        weight = FontWeight.ExtraBold,
        variationSettings = FontVariation.Settings(FontVariation.weight(800)),
    ),
)

All 15 Material 3 text styles wired to PretendardFontFamily via a PretendardTypography object, applied in Just3Theme:

Just3Theme(
    typography = PretendardTypography,
    content = content,
)

Calendar Sync: Duplicate Event Fix

The Problem

Saving Top 3 โ†’ going back โ†’ selecting three different tasks โ†’ saving again resulted in 6 events for the day. Doing it a third time gave 9.

Root cause had two layers:

Layer 1 โ€” Silent updateEvent failure. The previous save flow: find existing TimeBlock โ†’ updateEvent() with stored calendarEventId โ†’ if updateEvent returned false, create a new event. The false case happened when the event was deleted externally (e.g., user deleted it in Google Calendar). The new event was created and its ID saved to Room, but the old one was already gone โ€” fine. But if updateEvent failed for any other reason (ContentProvider hiccup, wrong calendar ID), the old event was orphaned in the calendar while Room got updated with the new ID. On the next save, same thing โ€” another orphan.

Layer 2 โ€” Orphaned events from before Room tracking. Older versions of the app created calendar events without reliable Room-level tracking of their IDs. Those events exist in the device calendar with no corresponding TimeBlock row. No amount of "delete stale blocks from Room" can reach them because there's nothing in Room to find.

The Fix: Delete All, Then Recreate

Instead of trying to update in place, the save flow now unconditionally:

  1. Loads all TimeBlock rows for today from Room. Deletes each from the calendar (by stored calendarEventId) and deletes the Room row.
  2. Queries the device calendar directly for any events tagged DESCRIPTION = "just3://task" on today's date and deletes them โ€” catches orphaned events with no Room entry.
  3. Creates 3 fresh events and saves 3 fresh TimeBlock rows.
fun saveToCalendar() {
    viewModelScope.launch {
        val today = LocalDate.now().toString()
        val todayDate = LocalDate.now()
 
        // Step 1: clean Room blocks + their linked calendar events
        for (block in timeBlockRepository.findAllByDate(today)) {
            block.calendarEventId?.let { calendarRepository.deleteEvent(it) }
            timeBlockRepository.delete(block)
        }
 
        // Step 2: clean orphaned Just3 events in the device calendar
        calendarRepository.deleteJust3EventsForDate(todayDate)
 
        // Step 3: recreate
        for (config in _uiState.value.configs) {
            val calEventId = calendarRepository.createEvent(
                config.item.text, config.startMillis, config.endMillis,
            ) ?: throw NoCalendarException()
 
            val timeBlockId = timeBlockRepository.save(
                TimeBlock(
                    brainDumpItemId = config.item.id,
                    date = today,
                    startTime = config.startMillis,
                    endTime = config.endMillis,
                    calendarEventId = calEventId,
                    reminderTimes = config.reminderTimes.joinToString(","),
                    alarmTimes = config.alarmTimes.joinToString(","),
                )
            )
 
            notificationScheduler.scheduleReminders(...)
            notificationScheduler.scheduleAlarms(...)
        }
    }
}

The marker "just3://task" is added to CalendarContract.Events.DESCRIPTION on every createEvent() call:

val values = ContentValues().apply {
    put(CalendarContract.Events.CALENDAR_ID, calendarId)
    put(CalendarContract.Events.TITLE, title)
    put(CalendarContract.Events.DTSTART, startMillis)
    put(CalendarContract.Events.DTEND, endMillis)
    put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
    put(CalendarContract.Events.DESCRIPTION, "just3://task")  // marker
}

deleteJust3EventsForDate() queries and bulk-deletes by that marker + DTSTART range:

override suspend fun deleteJust3EventsForDate(date: LocalDate): Unit = withContext(Dispatchers.IO) {
    val zoneId = ZoneId.systemDefault()
    val startMillis = date.atStartOfDay(zoneId).toInstant().toEpochMilli()
    val endMillis = date.plusDays(1).atStartOfDay(zoneId).toInstant().toEpochMilli()
    try {
        resolver.delete(
            CalendarContract.Events.CONTENT_URI,
            "${CalendarContract.Events.DESCRIPTION} = ? AND " +
            "${CalendarContract.Events.DTSTART} >= ? AND " +
            "${CalendarContract.Events.DTSTART} < ?",
            arrayOf("just3://task", startMillis.toString(), endMillis.toString()),
        )
    } catch (_: Exception) { }
}

The tradeoff: every save deletes and recreates instead of updating in place. Calendar event IDs change on each save. In practice this doesn't matter โ€” the user's calendar app reflects the new events immediately and there's no user-visible ID. The simplicity and reliability are worth it.

Default Reminder Removed

Noticed while testing: every new task arrived with a 10-minute reminder pre-set. BrainDumpItem.defaultReminderTimes was "10". Changed to "".

ItemTimeConfig.reminderTimes in the ViewModel had the same problem as a data class default:

// Before
val reminderTimes: List<Int> = listOf(10),
 
// After
val reminderTimes: List<Int> = emptyList(),

The ViewModel always sets this from parseAlertTimes(item.defaultReminderTimes) anyway, but the hardcoded default was a footgun โ€” any code that constructed ItemTimeConfig without explicitly passing reminderTimes would silently get a 10-minute reminder.


Other UX Fixes

Activity heatmap month labels โ€” Month labels were missing above week columns. Added a monthFormatter and injected a Box(14.dp) per week column showing the abbreviated month name when the month changes. Added a matching Spacer(14.dp) at the top of the weekday label column to keep the grid aligned.

Heatmap cell color โ€” Cells were navy on a blue-gray background โ€” effectively invisible. Switched the color from primary to tertiary (gold/amber) to make completion depth readable.

Calendar screen task sort โ€” "My Tasks" list was sorted by startTime. Changed to sort by big3Order so the user's explicit priority order (1 โ†’ 2 โ†’ 3) is preserved in the list view.


What I Learned

shouldShowRequestPermissionRationale() has three states, not two. Everyone knows "granted" and "denied." The tricky one is "never asked" โ€” it returns false, same as permanently denied. The only reliable place to check it is inside the launcher callback, after the system dialog has already been shown. Checking it before is a bug.

Google Fonts API downloads async. If you want the font on frame one, bundle the TTF. The 6 MB is worth it to avoid the fallback-then-switch visual artifact on first launch.

Variable fonts need explicit axis settings per entry. One file covers all weights, but Compose won't interpolate weight automatically โ€” you have to declare each Font(weight = ..., variationSettings = FontVariation.Settings(FontVariation.weight(...))) entry explicitly. Without variationSettings, every weight entry resolves to the font's default axis value (usually 400).

SCHEDULE_EXACT_ALARM is not a runtime permission. You can't call requestPermissions() for it. canScheduleExactAlarms() tells you the current state; if false, the only path is Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM. Trying to pass it to a RequestPermission launcher causes a "no requestable permission" logcat error.

Use DESCRIPTION as a stable identifier for ContentProvider records. When you don't control the primary key (CalendarContract assigns event IDs), tagging records with a custom marker in a string field lets you query and bulk-delete them regardless of what happened to your local DB. Useful for cleanup on reinstall or data loss scenarios.