Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Migrating FOSDEM Companion to Kotlin - Extended Version

Migrating FOSDEM Companion to Kotlin - Extended Version

Presentation given at GDG Brussels in June 2020.

FOSDEM Companion is currently the most used mobile application at FOSDEM. It has been around since 2014 and is updated every year. In 2020, it's finally made the big leap to Kotlin!

The app has been entirely rewritten using the Kotlin programming language. This talk will cover the conversion process, and how the new code makes use of language features and APIs that are not available in Java to become more than a simple Java conversion.

For example, we'll talk about:

- How Data classes and Parcelize remove a lot of boilerplate code in the model classes
- How immutability and null safety have been enforced in the code with minimal effort
- How KTX makes Android framework and Jetpack APIs more Kotlin-friendly
- Coroutines integration in the app.

The talk will be illustrated by many code examples.

Christophe Beyls

June 17, 2020
Tweet

More Decks by Christophe Beyls

Other Decks in Programming

Transcript

  1. FOSDEM Companion • Most popular FOSDEM Schedule app for Android

    • Source code available on GitHub https://github.com/cbeyls/fosdem-companion-android • Available from: 3500-4000 active users no usage data
  2. History / Changelog 1/2 2015 Material Design Lightning talk at

    FOSDEM 2016 Vector Drawables 2010 Original fosdem-android app release 2014 First release of FOSDEM Companion on Google Play Store and F-Droid. Uses Appcompat, fragments and loaders everywhere [minSDK 7]
  3. 2/2 2019 AndroidX, new database layer (Room), Material components library,

    Preferences library [minSDK 16] 2020 Dark theme, OkHttp, ViewPager2, fully rewritten in Kotlin. [minSDK 17] 2017 Animated Vector Drawables, RecyclerView New feature: Bookmarks export 2018 Architecture components (ViewModel & LiveData) New feature: Room status [minSDK 15]
  4. Code rewrite process Fragments Activities Custom widgets Remote data source

    & Parsers Database Layer Adapters ViewModels Services & ContentProviders Utility classes Model & Entities
  5. public static String remove(String str, final char remove) { if

    (TextUtils.isEmpty(str) || str.indexOf(remove) == -1) { return str; } final char[] chars = str.toCharArray(); int pos = 0; for (int i = 0; i < chars.length; i++) { if (chars[i] != remove) { chars[pos++] = chars[i]; } } return new String(chars, 0, pos); } StringUtils.java
  6. Extensions functions + Kotlin standard library fun String.remove(remove: Char): String

    { return if (remove !in this) { this } else { filterNot { it == remove } } }
  7. public class DownloadScheduleResult { private static final DownloadScheduleResult RESULT_ERROR =

    new DownloadScheduleResult(0); private static final DownloadScheduleResult RESULT_UP_TO_DATE = new DownloadScheduleResult(0); private final int eventsCount; private DownloadScheduleResult(int eventsCount) { this.eventsCount = eventsCount; } public static DownloadScheduleResult success(int eventsCount) { return new DownloadScheduleResult(eventsCount); } public static DownloadScheduleResult error() { return RESULT_ERROR; } public static DownloadScheduleResult upToDate() { return RESULT_UP_TO_DATE; } public boolean isSuccess() { return this != RESULT_ERROR && this != RESULT_UP_TO_DATE; } public boolean isError() { return this == RESULT_ERROR; } public boolean isUpToDate() { return this == RESULT_UP_TO_DATE; } public int getEventsCount() { return eventsCount; } }
  8. Sealed classes sealed class DownloadScheduleResult { class Success(val eventsCount: Int)

    : DownloadScheduleResult() object Error : DownloadScheduleResult() object UpToDate : DownloadScheduleResult() }
  9. @Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description"

    + ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type" + ", b.event_id IS NOT NULL AS is_bookmarked" + " FROM events e" + " JOIN events_titles et ON e.id = et.`rowid`" + " JOIN days d ON e.day_index = d.`index`" + " JOIN tracks t ON e.track_id = t.id" + " LEFT JOIN events_persons ep ON e.id = ep.event_id" + " LEFT JOIN persons p ON ep.person_id = p.`rowid`" + " LEFT JOIN bookmarks b ON e.id = b.event_id" + " WHERE e.day_index = :day AND e.track_id = :track" + " GROUP BY e.id" + " ORDER BY e.start_time ASC") public abstract LiveData<List<StatusEvent>> getEvents(Day day, Track track); ScheduleDao.java
  10. Multiline strings @Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle,

    e.abstract, e.description, GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type, b.event_id IS NOT NULL AS is_bookmarked FROM events e JOIN events_titles et ON e.id = et.`rowid` JOIN days d ON e.day_index = d.`index` JOIN tracks t ON e.track_id = t.id LEFT JOIN events_persons ep ON e.id = ep.event_id LEFT JOIN persons p ON ep.person_id = p.`rowid` LEFT JOIN bookmarks b ON e.id = b.event_id WHERE e.day_index = :day AND e.track_id = :track GROUP BY e.id ORDER BY e.start_time ASC""") abstract fun getEvents(day: Day, track: Track): LiveData<List<StatusEvent>>
  11. public class Link implements Parcelable { public static final String

    TABLE_NAME = "links"; @PrimaryKey(autoGenerate = true) private long id; @ColumnInfo(name = "event_id") private long eventId; @NonNull private String url; private String description; public Link() { } public long getId() { return id; } public void setId(long id) { this.id = id; } public long getEventId() { return eventId; } public void setEventId(long eventId) { this.eventId = eventId; }
  12. @NonNull public String getUrl() { return url; } public void

    setUrl(@NonNull String url) { this.url = url; } public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } @Override public int hashCode() { return url.hashCode(); } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; Link other = (Link) obj; return url.equals(other.url); }
  13. @Override public int describeContents() { return 0; } @Override public

    void writeToParcel(Parcel out, int flags) { out.writeLong(id); out.writeLong(eventId); out.writeString(url); out.writeString(description); } public static final Parcelable.Creator<Link> CREATOR = new Parcelable.Creator<Link>() { public Link createFromParcel(Parcel in) { return new Link(in); } public Link[] newArray(int size) { return new Link[size]; } }; Link(Parcel in) { id = in.readLong(); eventId = in.readLong(); url = in.readString(); description = in.readString(); } }
  14. Data class + Parcelize = @Parcelize data class Link( @PrimaryKey(autoGenerate

    = true) val id: Long = 0L, @ColumnInfo(name = "event_id") val eventId: Long, val url: String, val description: String? ) : Parcelable { companion object { const val TABLE_NAME = "links" } }
  15. void bind(@NonNull Event event, boolean isBookmarked) { Context context =

    itemView.getContext(); this.event = event; time.setText(timeDateFormat.format(event.getStartTime())); title.setText(event.getTitle()); Drawable bookmarkDrawable = isBookmarked ? AppCompatResources.getDrawable(context, R.drawable.ic_bookmark_24dp) : null; TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(title, null, null, bookmarkDrawable, null); ... NullPointerException
  16. Null safety fun bind(event: Event, isBookmarked: Boolean) { val context

    = itemView.context this.event = event time.text = event.startTime?.let { timeDateFormat.format(it) } title.text = event.title val bookmarkDrawable = if (isBookmarked) AppCompatResources.getDrawable(context, R.drawable.ic_bookmark_24dp) else null TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(title, null, null, bookmarkDrawable, null) ...
  17. Prefer non-null and read-only fields When a field is non-null:

    • You don’t have to check for nullability ... • … but you have to assign an initial value: ◦ Immediately val a = x() ◦ Later: ▪ lateinit var a: MyType (☠ nullable in disguise) ▪ val a: MyType by lazy { x() }
  18. A good way to read Fragment arguments class EventDetailsFragment :

    Fragment() { private val viewModel: EventDetailsViewModel by viewModels() private var holder: ViewHolder? = null val event by lazy<Event>(LazyThreadSafetyMode.NONE) { requireArguments().getParcelable(ARG_EVENT) } ... }
  19. Under the hood of a delegated property public final class

    Example { @NotNull private final Delegate p$delegate = new Delegate(); // $FF: synthetic field static final KProperty[] $$delegatedProperties = new KProperty[]{(KProperty)Reflection.mutableProperty1( new MutablePropertyReference1Impl( Reflection.getOrCreateKotlinClass(Example.class), "p", "getP()Ljava/lang/String;" ) )}; @NotNull public final String getP() { return this.p$delegate.getValue(this, $$delegatedProperties[0]); } }
  20. Under the hood of a delegated property public final class

    Example { @NotNull private final Delegate p$delegate = new Delegate(); // $FF: synthetic field static final KProperty[] $$delegatedProperties = new KProperty[]{(KProperty)Reflection.mutableProperty1( new MutablePropertyReference1Impl( Reflection.getOrCreateKotlinClass(Example.class), "p", "getP()Ljava/lang/String;" ) )}; @NotNull public final String getP() { return this.p$delegate.getValue(this, $$delegatedProperties[0]); } }
  21. Lazy: a lighter delegated property (since Kotlin 1.3.70) public final

    class EventDetailsFragment extends Fragment { @NotNull private final Lazy event$delegate = LazyKt.lazy(LazyThreadSafetyMode.NONE, (Function0)(new Function0() { @NotNull public final Event invoke() { Parcelable var10000 = EventDetailsFragment.this.requireArguments().getParcelable("event"); if (var10000 == null) { Intrinsics.throwNpe(); } return (Event)var10000; } })); @NotNull public final Event getEvent() { return (Event)this.event$delegate.getValue(); } }
  22. A custom Lazy implementation fun <T : Any> Fragment.viewLifecycleLazy(initializer: ()

    -> T): Lazy<T> = ViewLifecycleLazy(this, initializer) private class ViewLifecycleLazy<T : Any>(private val fragment: Fragment, private val initializer: () -> T) : Lazy<T>, LifecycleEventObserver { private var cached: T? = null override val value: T get() { return cached ?: run { val newValue = initializer() cached = newValue fragment.viewLifecycleOwner.lifecycle.addObserver(this) newValue } } override fun isInitialized() = cached != null override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { if (event == Lifecycle.Event.ON_DESTROY) { cached = null } } }
  23. A custom Lazy implementation fun <T : Any> Fragment.viewLifecycleLazy(initializer: ()

    -> T): Lazy<T> = ViewLifecycleLazy(this, initializer) private class ViewLifecycleLazy<T : Any>(private val fragment: Fragment, private val initializer: () -> T) : Lazy<T>, LifecycleEventObserver { private var cached: T? = null override val value: T get() { return cached ?: run { val newValue = initializer() cached = newValue fragment.viewLifecycleOwner.lifecycle.addObserver(this) newValue } } override fun isInitialized() = cached != null override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { if (event == Lifecycle.Event.ON_DESTROY) { cached = null } } }
  24. A custom Lazy implementation class ExampleFragment : Fragment(R.layout.fragment_example) { private

    class ViewHolder(view: View) { val message: TextView = view.findViewById(R.id.message) } private val viewHolder: ViewHolder by viewLifecycleLazy { ViewHolder(requireView()) } ... }
  25. 5. Android KTX “A set of Kotlin extensions that are

    included with Android Jetpack and other Android libraries.”
  26. KTX Delegated properties for ViewModel class EventDetailsFragment : Fragment() {

    private val viewModel: EventDetailsViewModel by viewModels() private var holder: ViewHolder? = null val event by lazy<Event>(LazyThreadSafetyMode.NONE) { requireArguments().getParcelable(ARG_EVENT)!! }
  27. // Auto refresh during the day passed as argument private

    final LiveData<Boolean> scheduler = Transformations.switchMap(dayTrackLiveData, dayTrack -> { final long dayStart = dayTrack.first.getDate().getTime(); return LiveDataFactory.scheduler(dayStart, dayStart + DateUtils.DAY_IN_MILLIS); }); private final LiveData<Long> currentTime = Transformations.switchMap(scheduler, isOn -> { if (isOn) { return Transformations.map( LiveDataFactory.interval(REFRESH_TIME_INTERVAL, TimeUnit.MILLISECONDS), count -> System.currentTimeMillis() ); } return new MutableLiveData<>(-1L); }); Then: LiveData transformations methods
  28. Now: KTX extension functions for LiveData val currentTime: LiveData<Long> =

    dayTrackLiveData .switchMap { (day, _) -> // Auto refresh during the day passed as argument val dayStart = day.date.time LiveDataFactory.scheduler(dayStart, dayStart + DateUtils.DAY_IN_MILLIS) } .switchMap { isOn -> if (isOn) { LiveDataFactory.interval(REFRESH_TIME_INTERVAL, TimeUnit.MILLISECONDS) .map { System.currentTimeMillis() } } else { MutableLiveData(-1L) } }
  29. Now: KTX extension functions for LiveData val currentTime: LiveData<Long> =

    dayTrackLiveData .switchMap { (day, _) -> // Auto refresh during the day passed as argument val dayStart = day.date.time LiveDataFactory.scheduler(dayStart, dayStart + DateUtils.DAY_IN_MILLIS) } .switchMap { isOn -> if (isOn) { LiveDataFactory.interval(REFRESH_TIME_INTERVAL, TimeUnit.MILLISECONDS) .map { System.currentTimeMillis() } } else { MutableLiveData(-1L) } }
  30. final SpannableStringBuilder sb = new SpannableStringBuilder(); int length = 0;

    for (Person person : persons) { if (length != 0) { sb.append(", "); } final String name = person.getName(); sb.append(name); length = sb.length(); sb.setSpan(new PersonClickableSpan(person), length - name.length(), length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); } final CharSequence clickablePersonsSummary = sb.toString(); Then: SpannableStringBuilder hell
  31. Now: KTX functions for Spannable val clickablePersonsSummary = buildSpannedString {

    for (person in persons) { if (length != 0) { append(", ") } inSpans(PersonClickableSpan(person)) { append(person.name) } } }
  32. Now: KTX functions for Spannable val clickablePersonsSummary = buildSpannedString {

    for (person in persons) { if (length != 0) { append(", ") } inSpans(PersonClickableSpan(person)) { append(person.name) } } }
  33. public abstract class IterableAbstractPullParser<T> extends AbstractPullParser<Iterable<T>> { private class ParserIterator

    implements Iterator<T> { // Removed for brevity. Calls parseHeader(), parseNext() and parseFooter() } @Override protected Iterable<T> parse(final XmlPullParser parser) throws Exception { return () -> new ParserIterator(parser); } protected abstract boolean parseHeader(XmlPullParser parser) throws Exception; protected abstract T parseNext(XmlPullParser parser) throws Exception; protected void parseFooter(XmlPullParser parser) throws Exception { while (!isEndDocument()) { parser.next(); } } } Then: complex streaming parser
  34. public abstract class IterableAbstractPullParser<T> extends AbstractPullParser<Iterable<T>> { private class ParserIterator

    implements Iterator<T> { // Removed for brevity. Calls parseHeader(), parseNext() and parseFooter() } @Override protected Iterable<T> parse(final XmlPullParser parser) throws Exception { return () -> new ParserIterator(parser); } protected abstract boolean parseHeader(XmlPullParser parser) throws Exception; protected abstract T parseNext(XmlPullParser parser) throws Exception; protected void parseFooter(XmlPullParser parser) throws Exception { while (!isEndDocument()) { parser.next(); } } } Then: complex streaming parser
  35. Now: Kotlin Sequence builder override fun parse(source: XmlPullParser): Sequence<DetailedEvent> {

    return sequence { while (!parser.isEndDocument) { if (parser.isStartTag("schedule")) { while (!parser.isNextEndTag("schedule")) { if (parser.isStartTag) { when (parser.name) { "event" -> yield(parseEvent(parser)) else -> parser.skipToEndTag() } } } } parser.next() } } }
  36. Now: Kotlin Sequence builder override fun parse(parser: XmlPullParser): Sequence<DetailedEvent> {

    return sequence { while (!parser.isEndDocument) { if (parser.isStartTag("schedule")) { while (!parser.isNextEndTag("schedule")) { if (parser.isStartTag) { when (parser.name) { "event" -> yield(parseEvent(parser)) else -> parser.skipToEndTag() } } } } parser.next() } } }
  37. Easy and safe thread switching @MainThread private suspend fun downloadScheduleInternal(context:

    Context) { _downloadScheduleProgress.value = -1 val res = withContext(Dispatchers.IO) { ... } _downloadScheduleProgress.value = 100 _downloadScheduleResult.value = SingleEvent(res) }
  38. Easy and safe thread switching @MainThread private suspend fun downloadScheduleInternal(context:

    Context) { _downloadScheduleProgress.value = -1 val res = withContext(Dispatchers.IO) { ... } _downloadScheduleProgress.value = 100 _downloadScheduleResult.value = SingleEvent(res) } background thread
  39. Context preservation @MainThread private suspend fun downloadScheduleInternal(context: Context) { _downloadScheduleProgress.value

    = -1 val res = withContext(Dispatchers.IO) { ... } _downloadScheduleProgress.value = 100 _downloadScheduleResult.value = SingleEvent(res) } main thread
  40. public LiveData<EventDetails> getEventDetails(final Event event) { final MutableLiveData<EventDetails> result =

    new MutableLiveData<>(); appDatabase.getQueryExecutor().execute(() -> { final List<Person> persons = getPersons(event); final List<Link> links = getLinks(event); result.postValue(new EventDetails(persons, links)); }); return result; } Then: loading details in sequence
  41. public LiveData<EventDetails> getEventDetails(final Event event) { final MutableLiveData<EventDetails> result =

    new MutableLiveData<>(); appDatabase.getQueryExecutor().execute(() -> { final List<Person> persons = getPersons(event); final List<Link> links = getLinks(event); result.postValue(new EventDetails(persons, links)); }); return result; } Then: loading details in sequence
  42. Now: loading details in parallel fun getEventDetails(event: Event): LiveData<EventDetails> {

    return liveData { // Load persons and links in parallel coroutineScope { val persons = async { getPersons(event) } val links = async { getLinks(event) } emit(EventDetails(persons.await(), links.await())) } } }
  43. Now: loading details in parallel fun getEventDetails(event: Event): LiveData<EventDetails> {

    return liveData { // Load persons and links in parallel coroutineScope { val persons = async { getPersons(event) } val links = async { getLinks(event) } emit(EventDetails(persons.await(), links.await())) } } } LiveData coroutines builder (KTX)
  44. Now: loading details in parallel fun getEventDetails(event: Event): LiveData<EventDetails> {

    return liveData { // Load persons and links in parallel coroutineScope { val persons = async { getPersons(event) } val links = async { getLinks(event) } emit(EventDetails(persons.await(), links.await())) } } }
  45. /** * Builds a LiveData instance which loads and refreshes

    the Room statuses during the event. */ private fun buildLiveRoomStatusesLiveData(): LiveData<Map<String, RoomStatus>> { var nextRefreshTime = 0L var expirationTime = Long.MAX_VALUE var retryAttempt = 0 return liveData { var now = SystemClock.elapsedRealtime() var nextRefreshDelay = nextRefreshTime - now if (now > expirationTime && latestValue?.isEmpty() == false) { // When the data expires, replace it with an empty value emit(emptyMap()) } while (true) { if (nextRefreshDelay > 0) { delay(nextRefreshDelay) } nextRefreshDelay = try { val response = HttpUtils.get(FosdemUrls.rooms) { body, _ -> RoomStatusesParser().parse(body.source()) } now = SystemClock.elapsedRealtime() retryAttempt = 0 expirationTime = now + ROOM_STATUS_EXPIRATION_DELAY emit(response.body) ROOM_STATUS_REFRESH_DELAY } catch (e: Exception) { if (e is CancellationException) { throw e } now = SystemClock.elapsedRealtime() if (now > expirationTime && latestValue?.isEmpty() == false) { emit(emptyMap()) } // Use exponential backoff for retries val multiplier = 2.0.pow(retryAttempt).toLong() retryAttempt++ (ROOM_STATUS_FIRST_RETRY_DELAY * multiplier) .coerceAtMost(ROOM_STATUS_REFRESH_DELAY) } nextRefreshTime = now + nextRefreshDelay } } } A more challenging example of LiveData coroutines builder FosdemApi.kt fun buildLiveRoomStatusesLiveData()
  46. private fun buildLiveRoomStatusesLiveData(): LiveData<Map<String, RoomStatus>> { var nextRefreshTime = 0L

    var expirationTime = Long.MAX_VALUE var retryAttempt = 0 return liveData { var now = SystemClock.elapsedRealtime() var nextRefreshDelay = nextRefreshTime - now if (now > expirationTime && latestValue?.isEmpty() == false) { // When the data expires, replace it with an empty value emit(emptyMap()) } while (true) { // 1. Wait for nextRefreshDelay // 2. Load data from network and emit new value // 3. Update nextRefreshDelay and global state } } }
  47. private fun buildLiveRoomStatusesLiveData(): LiveData<Map<String, RoomStatus>> { var nextRefreshTime = 0L

    var expirationTime = Long.MAX_VALUE var retryAttempt = 0 return liveData { var now = SystemClock.elapsedRealtime() var nextRefreshDelay = nextRefreshTime - now if (now > expirationTime && latestValue?.isEmpty() == false) { // When the data expires, replace it with an empty value emit(emptyMap()) } while (true) { // 1. Wait for nextRefreshDelay // 2. Load data from network and emit new value // 3. Update nextRefreshDelay and global state } } } Global state
  48. private fun buildLiveRoomStatusesLiveData(): LiveData<Map<String, RoomStatus>> { var nextRefreshTime = 0L

    var expirationTime = Long.MAX_VALUE var retryAttempt = 0 return liveData { var now = SystemClock.elapsedRealtime() var nextRefreshDelay = nextRefreshTime - now if (now > expirationTime && latestValue?.isEmpty() == false) { // When the data expires, replace it with an empty value emit(emptyMap()) } while (true) { // 1. Wait for nextRefreshDelay // 2. Load data from network and emit new value // 3. Update nextRefreshDelay and global state } } } Coroutine
  49. while (true) { // 1. Wait for nextRefreshDelay // 2.

    Load data from network and emit new value // 3. Update nextRefreshDelay and global state }
  50. if (nextRefreshDelay > 0) { delay(nextRefreshDelay) } nextRefreshDelay = try

    { val response = HttpUtils.get(FosdemUrls.rooms) { body, _ -> RoomStatusesParser().parse(body.source()) } now = SystemClock.elapsedRealtime() retryAttempt = 0 expirationTime = now + ROOM_STATUS_EXPIRATION_DELAY emit(response.body) ROOM_STATUS_REFRESH_DELAY } catch (e: Exception) { if (e is CancellationException) { throw e } now = SystemClock.elapsedRealtime() if (now > expirationTime && latestValue?.isEmpty() == false) { emit(emptyMap()) } // Use exponential backoff for retries val multiplier = 2.0.pow(retryAttempt).toLong() retryAttempt++ (ROOM_STATUS_FIRST_RETRY_DELAY * multiplier) .coerceAtMost(ROOM_STATUS_REFRESH_DELAY) } nextRefreshTime = now + nextRefreshDelay 1. Wait for nextRefreshDelay
  51. if (nextRefreshDelay > 0) { delay(nextRefreshDelay) } nextRefreshDelay = try

    { val response = HttpUtils.get(FosdemUrls.rooms) { body, _ -> RoomStatusesParser().parse(body.source()) } now = SystemClock.elapsedRealtime() retryAttempt = 0 expirationTime = now + ROOM_STATUS_EXPIRATION_DELAY emit(response.body) ROOM_STATUS_REFRESH_DELAY } catch (e: Exception) { if (e is CancellationException) { throw e } now = SystemClock.elapsedRealtime() if (now > expirationTime && latestValue?.isEmpty() == false) { emit(emptyMap()) } // Use exponential backoff for retries val multiplier = 2.0.pow(retryAttempt).toLong() retryAttempt++ (ROOM_STATUS_FIRST_RETRY_DELAY * multiplier) .coerceAtMost(ROOM_STATUS_REFRESH_DELAY) } nextRefreshTime = now + nextRefreshDelay 2. Load data from network and emit() new value
  52. if (nextRefreshDelay > 0) { delay(nextRefreshDelay) } nextRefreshDelay = try

    { val response = HttpUtils.get(FosdemUrls.rooms) { body, _ -> RoomStatusesParser().parse(body.source()) } now = SystemClock.elapsedRealtime() retryAttempt = 0 expirationTime = now + ROOM_STATUS_EXPIRATION_DELAY emit(response.body) ROOM_STATUS_REFRESH_DELAY } catch (e: Exception) { if (e is CancellationException) { throw e } now = SystemClock.elapsedRealtime() if (now > expirationTime && latestValue?.isEmpty() == false) { emit(emptyMap()) } // Use exponential backoff for retries val multiplier = 2.0.pow(retryAttempt).toLong() retryAttempt++ (ROOM_STATUS_FIRST_RETRY_DELAY * multiplier) .coerceAtMost(ROOM_STATUS_REFRESH_DELAY) } nextRefreshTime = now + nextRefreshDelay 3. Update nextRefreshDelay and global state
  53. The case of the delayed drawer close navigationView.setNavigationItemSelectedListener( menuItem ->

    { pendingNavigationMenuItem = menuItem; drawerLayout.closeDrawer(navigationView); return true; }); Callback #1
  54. drawerLayout.addDrawerListener(new DrawerLayout.SimpleDrawerListener() { @Override public void onDrawerClosed(View drawerView) { if

    (pendingNavigationMenuItem != null) { handleNavigationMenuItem(pendingNavigationMenuItem); pendingNavigationMenuItem = null; } } }); The case of the delayed drawer close Callback #2
  55. @Override protected void onSaveInstanceState(@NonNull Bundle outState) { // Ensure no

    fragment transaction occurs after onSaveInstanceState() if (pendingNavigationMenuItem != null) { pendingNavigationMenuItem = null; if (currentSection != null) { navigationView.setCheckedItem(currentSection.getMenuItemId()); } } super.onSaveInstanceState(outState); } The case of the delayed drawer close Callback #3
  56. Suspending over DrawerLayout suspend fun DrawerLayout.awaitCloseDrawer(drawerView: View) = suspendCancellableCoroutine<Unit> {

    continuation -> val listener = object : DrawerLayout.SimpleDrawerListener() { ... } continuation.invokeOnCancellation { removeDrawerListener(listener) } addDrawerListener(listener) closeDrawer(drawerView) }
  57. suspend fun DrawerLayout.awaitCloseDrawer(drawerView: View) = suspendCancellableCoroutine<Unit> { continuation -> continuation.invokeOnCancellation

    { removeDrawerListener(listener) } addDrawerListener(listener) closeDrawer(drawerView) } val listener = object : DrawerLayout.SimpleDrawerListener() { ... } Suspending over DrawerLayout
  58. Suspending over DrawerLayout val listener = object : DrawerLayout.SimpleDrawerListener() {

    override fun onDrawerClosed(drawerView: View) { removeDrawerListener(this) continuation.resume(Unit) } override fun onDrawerStateChanged(newState: Int) { if (newState == DrawerLayout.STATE_DRAGGING) { continuation.cancel() } } }
  59. Now: single callback launching a coroutine navigationView.setNavigationItemSelectedListener { menuItem: MenuItem

    -> lifecycleScope.launchWhenStarted { try { drawerLayout.awaitCloseDrawer(navigationView) handleNavigationMenuItem(menuItem) } catch (e: CancellationException) { // reset the menu to the current selection navigationView.setCheckedItem(currentSection.menuItemId) throw e } } true }
  60. Now: single callback launching a coroutine navigationView.setNavigationItemSelectedListener { menuItem: MenuItem

    -> lifecycleScope.launchWhenStarted { try { drawerLayout.awaitCloseDrawer(navigationView) handleNavigationMenuItem(menuItem) } catch (e: CancellationException) { // reset the menu to the current selection navigationView.setCheckedItem(currentSection.menuItemId) throw e } } true } KTX extensions
  61. Now: single callback launching a coroutine navigationView.setNavigationItemSelectedListener { menuItem: MenuItem

    -> lifecycleScope.launchWhenStarted { try { drawerLayout.awaitCloseDrawer(navigationView) handleNavigationMenuItem(menuItem) } catch (e: CancellationException) { // reset the menu to the current selection navigationView.setCheckedItem(currentSection.menuItemId) throw e } } true }
  62. buildTypes { release { minifyEnabled true shrinkResources true proguardFiles 'proguard-defaults.txt',

    'proguard-rules.pro' kotlinOptions { freeCompilerArgs = [ '-Xno-param-assertions', '-Xno-call-assertions', '-Xno-receiver-assertions' ] } } } Optimize your release builds for Kotlin