Young Devs Bin
๐Ÿ”ง Dev Log

Just3: Add to Calendar โ€” Five Bugs, One Button

ยท6 min readยท#android#kotlin#compose#just3#coroutines#permissions

After the previous session fixed the silent calendar failure and swipe-to-delete, "Add to Calendar" still didn't work on a real device. What followed was a multi-layer debugging session. The button had five distinct bugs, each masking the next.


Bug 1: ContentResolver on the Main Thread

CalendarRepositoryImpl.createEvent() was a suspend fun but didn't specify a dispatcher. Called from viewModelScope.launch (which defaults to Dispatchers.Main), every ContentResolver operation โ€” the calendar query, the event insert, the reminder insert โ€” ran on the UI thread.

// Before: runs on Dispatchers.Main by default
override suspend fun createEvent(...): Long? {
    val calendarId = findPrimaryCalendarId() ?: return null
    val eventUri = resolver.insert(CalendarContract.Events.CONTENT_URI, eventValues)
    ...
}

ContentResolver operations are blocking calls. On a slow device or with a large calendar, running them on the main thread freezes the UI.

Fix: wrap the entire body in withContext(Dispatchers.IO):

override suspend fun createEvent(...): Long? = withContext(Dispatchers.IO) {
    val calendarId = findPrimaryCalendarId() ?: return@withContext null
    val eventUri = resolver.insert(CalendarContract.Events.CONTENT_URI, eventValues)
    ...
}

Same fix for readEventsForDate(). The interface method also became suspend so the compiler enforces that callers need a coroutine context.


Bug 2: SharedFlow Could Drop Events

The ViewModel exposed events through MutableSharedFlow<Event>() โ€” no replay, no buffer:

private val _events = MutableSharedFlow<Event>()

A SharedFlow with replay = 0 and no extraBufferCapacity drops events emitted while no collector is active. The permission system dialog is an overlay that can briefly interrupt the Compose lifecycle. If LaunchedEffect's collector was paused at the exact moment SaveComplete was emitted, the event disappeared and nothing happened.

Fix:

private val _events = MutableSharedFlow<Event>(extraBufferCapacity = 1)

extraBufferCapacity = 1 buffers one event even when there's no active subscriber, so it survives a brief collector pause.


Bug 3: Navigated to the Wrong Screen

After SaveComplete, the code navigated to the Review screen:

// Before
Big3ViewModel.Event.SaveComplete ->
    navController.navigate(Screen.Review.route) {
        popUpTo(Screen.BrainDump.route)
    }

The intended UX is to land on the in-app Calendar screen so the user can immediately see the time blocks they just scheduled.

Fix: change the destination:

Big3ViewModel.Event.SaveComplete ->
    navController.navigate(Screen.Calendar.route) {
        popUpTo(Screen.BrainDump.route)
    }

Bug 4: Stale Permission State on Android 14

This was the trickiest one. The logcat showed:

V  No requestable permission in the request.

This is Android's internal verbose log from Activity.requestPermissions(). It appears when the system filters out already-granted permissions from the request and finds nothing left to ask for โ€” then immediately delivers the result with an empty grant results array.

The original event-based flow looked like this:

Button tap
  โ†’ ViewModel emits RequestCalendarPermission
  โ†’ LaunchedEffect collects it
  โ†’ checkSelfPermission โ†’ DENIED (stale)
  โ†’ permissionLauncher.launch([WRITE_CALENDAR])
  โ†’ Android: "No requestable permission" โ†’ fires callback immediately
  โ†’ Callback: re-checks checkSelfPermission โ†’ still DENIED (stale)
  โ†’ saveToCalendar() never called
  โ†’ Nothing happens

On Android 14, there's a window where checkSelfPermission() returns DENIED for a permission the system already considers granted. The two states are briefly out of sync. Re-checking inside the callback means you skip the action every time.

There were two separate issues compounding this:

Issue A โ€” Indirect event flow. Routing permission handling through a SharedFlow event added an extra async hop between the button tap and the actual permission check. This made the timing window wider.

Fix A: move the permission check directly into the button's onClick. No event, no extra dispatch:

fun requestPermissionsAndSave() {
    val notGranted = calendarPermissions.filter {
        ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
    }.toTypedArray()
    if (notGranted.isEmpty()) {
        viewModel.saveToCalendar()
    } else {
        try {
            permissionLauncher.launch(notGranted)
        } catch (_: Exception) {
            // System threw โ€” treat as "effectively granted", attempt save
            viewModel.saveToCalendar()
        }
    }
}

Issue B โ€” Re-checking permissions in the callback. The result callback originally re-ran checkSelfPermission to decide whether to call saveToCalendar(). Since that re-check could still return stale DENIED, the action was never taken.

Fix B: don't re-check. When the callback fires, the system has already resolved the grant state. Just proceed:

val permissionLauncher = rememberLauncherForActivityResult(
    ActivityResultContracts.RequestMultiplePermissions()
) { _ ->
    // The system fires this callback only after resolving grant state.
    // Re-checking checkSelfPermission here can return stale results on Android 14.
    viewModel.saveToCalendar()
}

Bug 5: Null eventId Aborted the Entire Save

After all the above fixes, the button finally reached saveToCalendar() โ€” and immediately showed "No calendar account found."

The previous session's fix for the silent null had added a hard bail-out:

for (config in configs) {
    val eventId = calendarRepository.createEvent(...)
    if (eventId == null) {
        _events.emit(Event.Error("No calendar found."))
        return@launch  // โ† aborts everything
    }
    timeBlockRepository.save(TimeBlock(..., calendarEventId = eventId))
}

If createEvent() returned null โ€” because the device calendar account wasn't set up, or the ContentResolver query returned an empty cursor โ€” the coroutine exited immediately. The Room database save never ran. The user was stuck on the Time Box screen with an error, even though their data could have been saved perfectly well in the local database.

The device calendar write and the Room write serve different purposes:

  • Room is the source of truth for the in-app Calendar screen
  • Device calendar is for external sync (Google Calendar etc.)

They shouldn't be coupled. Failing to sync externally shouldn't prevent saving locally.

Fix: treat the device calendar write as best-effort. Save to Room regardless:

for (config in configs) {
    // null here means no calendar account โ€” that's fine, calendarEventId stays null
    val eventId = calendarRepository.createEvent(...)
    timeBlockRepository.save(
        TimeBlock(
            brainDumpItemId = config.item.id,
            date = today,
            startTime = config.startMillis,
            endTime = config.endMillis,
            calendarEventId = eventId,  // null if no device calendar
        )
    )
}
_events.emit(Event.SaveComplete)

Now the Calendar screen always shows the time blocks. Device calendar sync happens when it can.


What I Learned

ContentResolver operations must run on a background thread. Always withContext(Dispatchers.IO) inside suspend funs that call resolver.query() or resolver.insert(). The compiler won't warn you โ€” it's on you to remember.

Don't re-check permission state inside a permission result callback. The callback is the authority. If you re-check checkSelfPermission there, you're racing against an OS state update that may not have propagated yet, especially on Android 14.

Decouple local state from remote/external sync. Saving to a local database and syncing to an external system are independent operations. A sync failure should never prevent the local save. Model them separately and handle failures at the right layer.

One bug can mask another. When a button does "nothing," there's often a chain: the first bug prevents the second from ever running, so you only see the first failure. Fix it and suddenly the next bug surfaces. Fix five and the feature works.