Slide 1

Slide 1 text

Migrating FOSDEM Companion to Kotlin Christophe Beyls - @BladeCoder FOSDEM 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

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

Slide 27

Slide 27 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 28

Slide 28 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 29

Slide 29 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 30

Slide 30 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 31

Slide 31 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 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

6. Coroutines

Slide 35

Slide 35 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 36

Slide 36 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 37

Slide 37 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 38

Slide 38 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 39

Slide 39 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 40

Slide 40 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 41

Slide 41 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 42

Slide 42 text

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

Slide 43

Slide 43 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 44

Slide 44 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 45

Slide 45 text

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

Slide 46

Slide 46 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 47

Slide 47 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 48

Slide 48 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 49

Slide 49 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) } } true }

Slide 50

Slide 50 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) } } true } KTX extensions

Slide 51

Slide 51 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) } } true }

Slide 52

Slide 52 text

Conclusion: a few metrics

Slide 53

Slide 53 text

No content

Slide 54

Slide 54 text

No content

Slide 55

Slide 55 text

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