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

Migrating FOSDEM Companion to Kotlin

Migrating FOSDEM Companion to Kotlin

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

February 02, 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. 5. Android KTX “A set of Kotlin extensions that are

    included with Android Jetpack and other Android libraries.”
  20. 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)!! }
  21. // 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
  22. 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) } }
  23. 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) } }
  24. 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
  25. Now: KTX functions for Spannable val clickablePersonsSummary = buildSpannedString {

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

    for (person in persons) { if (length != 0) { append(", ") } inSpans(PersonClickableSpan(person)) { append(person.name) } } }
  27. 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) }
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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() } } }
  33. 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() } } }
  34. The case of the delayed drawer close navigationView.setNavigationItemSelectedListener( menuItem ->

    { pendingNavigationMenuItem = menuItem; drawerLayout.closeDrawer(navigationView); return true; }); Callback #1
  35. 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
  36. @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
  37. 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) }
  38. 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
  39. 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() } } }
  40. 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) } } true }
  41. 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) } } true } KTX extensions
  42. 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) } } true }