Slide 1

Slide 1 text

Migrating FOSDEM Companion to Kotlin Christophe Beyls - @BladeCoder GDG Brussels - June 2020

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

1. History / Changelog 2014 - 2020

Slide 4

Slide 4 text

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]

Slide 5

Slide 5 text

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]

Slide 6

Slide 6 text

Code rewrite process Fragments Activities Custom widgets Remote data source & Parsers Database Layer Adapters ViewModels Services & ContentProviders Utility classes Model & Entities

Slide 7

Slide 7 text

Android Studio code refactoring help

Slide 8

Slide 8 text

2. Cleaning up the code Examples of Kotlin features

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

Extensions functions + Kotlin standard library fun String.remove(remove: Char): String { return if (remove !in this) { this } else { filterNot { it == remove } } }

Slide 11

Slide 11 text

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; } }

Slide 12

Slide 12 text

Sealed classes sealed class DownloadScheduleResult { class Success(val eventsCount: Int) : DownloadScheduleResult() object Error : DownloadScheduleResult() object UpToDate : DownloadScheduleResult() }

Slide 13

Slide 13 text

@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> getEvents(Day day, Track track); ScheduleDao.java

Slide 14

Slide 14 text

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>

Slide 15

Slide 15 text

3. Simplifying model classes “The best code is no code”

Slide 16

Slide 16 text

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; }

Slide 17

Slide 17 text

@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); }

Slide 18

Slide 18 text

@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 CREATOR = new Parcelable.Creator() { 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(); } }

Slide 19

Slide 19 text

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" } }

Slide 20

Slide 20 text

https://medium.com/@BladeCoder More about Parcelize

Slide 21

Slide 21 text

4. Null safety

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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) ...

Slide 24

Slide 24 text

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() }

Slide 25

Slide 25 text

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(LazyThreadSafetyMode.NONE) { requireArguments().getParcelable(ARG_EVENT) } ... }

Slide 26

Slide 26 text

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]); } }

Slide 27

Slide 27 text

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]); } }

Slide 28

Slide 28 text

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(); } }

Slide 29

Slide 29 text

A custom Lazy implementation fun Fragment.viewLifecycleLazy(initializer: () -> T): Lazy = ViewLifecycleLazy(this, initializer) private class ViewLifecycleLazy(private val fragment: Fragment, private val initializer: () -> T) : Lazy, 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 } } }

Slide 30

Slide 30 text

A custom Lazy implementation fun Fragment.viewLifecycleLazy(initializer: () -> T): Lazy = ViewLifecycleLazy(this, initializer) private class ViewLifecycleLazy(private val fragment: Fragment, private val initializer: () -> T) : Lazy, 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 } } }

Slide 31

Slide 31 text

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()) } ... }

Slide 32

Slide 32 text

5. Android KTX “A set of Kotlin extensions that are included with Android Jetpack and other Android libraries.”

Slide 33

Slide 33 text

KTX Delegated properties for ViewModel class EventDetailsFragment : Fragment() { private val viewModel: EventDetailsViewModel by viewModels() private var holder: ViewHolder? = null val event by lazy(LazyThreadSafetyMode.NONE) { requireArguments().getParcelable(ARG_EVENT)!! }

Slide 34

Slide 34 text

// Auto refresh during the day passed as argument private final LiveData scheduler = Transformations.switchMap(dayTrackLiveData, dayTrack -> { final long dayStart = dayTrack.first.getDate().getTime(); return LiveDataFactory.scheduler(dayStart, dayStart + DateUtils.DAY_IN_MILLIS); }); private final LiveData 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

Slide 35

Slide 35 text

Now: KTX extension functions for LiveData val currentTime: LiveData = 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) } }

Slide 36

Slide 36 text

Now: KTX extension functions for LiveData val currentTime: LiveData = 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) } }

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

6. Coroutines

Slide 41

Slide 41 text

public abstract class IterableAbstractPullParser extends AbstractPullParser> { private class ParserIterator implements Iterator { // Removed for brevity. Calls parseHeader(), parseNext() and parseFooter() } @Override protected Iterable 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

Slide 42

Slide 42 text

public abstract class IterableAbstractPullParser extends AbstractPullParser> { private class ParserIterator implements Iterator { // Removed for brevity. Calls parseHeader(), parseNext() and parseFooter() } @Override protected Iterable 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

Slide 43

Slide 43 text

Now: Kotlin Sequence builder override fun parse(source: XmlPullParser): Sequence { 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() } } }

Slide 44

Slide 44 text

Now: Kotlin Sequence builder override fun parse(parser: XmlPullParser): Sequence { 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() } } }

Slide 45

Slide 45 text

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) }

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

Now: loading details in parallel fun getEventDetails(event: Event): LiveData { 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())) } } }

Slide 51

Slide 51 text

Now: loading details in parallel fun getEventDetails(event: Event): LiveData { 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)

Slide 52

Slide 52 text

Now: loading details in parallel fun getEventDetails(event: Event): LiveData { 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())) } } }

Slide 53

Slide 53 text

/** * Builds a LiveData instance which loads and refreshes the Room statuses during the event. */ private fun buildLiveRoomStatusesLiveData(): LiveData> { 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()

Slide 54

Slide 54 text

private fun buildLiveRoomStatusesLiveData(): LiveData> { 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 } } }

Slide 55

Slide 55 text

private fun buildLiveRoomStatusesLiveData(): LiveData> { 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

Slide 56

Slide 56 text

private fun buildLiveRoomStatusesLiveData(): LiveData> { 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

Slide 57

Slide 57 text

while (true) { // 1. Wait for nextRefreshDelay // 2. Load data from network and emit new value // 3. Update nextRefreshDelay and global state }

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

The case of the delayed drawer close navigationView.setNavigationItemSelectedListener( menuItem -> { pendingNavigationMenuItem = menuItem; drawerLayout.closeDrawer(navigationView); return true; }); Callback #1

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

@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

Slide 64

Slide 64 text

https://medium.com/androiddevelopers/suspending-over-views-19de9ebd7020 Using coroutines to simplify UI logic

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

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() } } }

Slide 68

Slide 68 text

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 }

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

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 }

Slide 71

Slide 71 text

Conclusion: a few metrics

Slide 72

Slide 72 text

No content

Slide 73

Slide 73 text

No content

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

https://github.com/cbeyls/fosdem-companion-android/ @BladeCoder https://medium.com/@BladeCoder/ Thank you for watching!