In 2016 (which seems like a whole eternity from now :), I wrote an Android app called Look Counter for counting how many times you have switched the screen on, as well as unlocked it. Up until recently I haven’t updated it. The app used such libs as greenDAO , ButterKnife , GoogleAnalytics, and RecyclerView. After the update, only the last one remained 🙂
I’ve decided to completely re-write Look Counter, using the modern tools. The first step was to update all of the dependencies and set project’s target API to 27. It’s a good idea to set yours to 26 as a min, because of Google Play’s new requirements — “Google Play will require that new apps target at least Android 8.0 (API level 26) from August 1, 2018, and that app updates target Android 8.0 from November 1, 2018.”
As the second step I chose to refactor layouts and use ConstraintLayout in order to flatten the hierarchy. It allowed me to make both Calendar and About screens completely flat, which I think is awesome 🙂
Next I wanted to convert Java to Kotlin. I used Android Studio’s Convert Java file to Kotlin file option, and then cleaned up the code. I got rid of !! and used vals instead of vars whenever possible. Also, objects instead of classes, extension methods, etc. In general, simply converting Java to Kotlin is easy, but to make Kotlin code look good and not like some Java-adaption, requires an extra effort.
I also wanted to use some kind of a architecture, to organize logic and views separately. I just have three fragments — Main, Calendar and About in the app, but they had too much unorganized code inside of them. I’ve decided to go with an MVP — simple yet neat. Below is the example of Calendar contract class. You can immediately tell what to expect from the screen, just by looking at the list of methods. All the logic goes to the presenter, and UI rendering goes to the view.
interface CalendarContract { interface Presenter : BaseMvpPresenter<CalendarContract.View> { fun populateList(context: Context?, date: Calendar, screenLookCategory: ScreenLookCategory) } interface View : BaseView { fun setViewTitle(titleMonth: Calendar) fun setWeekLabels(weekLabels: List<String>) fun setDatesToCalendar(dates: List<Pair<Calendar, Int?>>) }
interface BaseView { @LayoutRes fun getContentResource(): Int fun init(view: View, @Nullable state: Bundle?) }
class FragmentAbout : BaseFragment(), AboutContract.View { private lateinit var presenter: AboutPresenter override fun getContentResource() = R.layout.fragment_about override fun init(view: View, state: Bundle?) { presenter = AboutPresenter() presenter.attach(this) view.tv_about_text.text = (getString(R.string.about_text)).formatHtmlCompat() view.tv_about_looks.text = (getString(R.string.about_looks)).formatHtmlCompat() view.tv_about_unlocks.text = (getString(R.string.about_unlocks)).formatHtmlCompat() view.tv_about_me.text = (getString(R.string.about_me)).formatHtmlCompat() view.tv_about_source_code.movementMethod = LinkMovementMethod.getInstance() view.tv_about_source_code.text = (getString(R.string.about_source_code)).formatHtmlCompat() view.b_contact_me.setOnClickListener { context?.let { if (presenter.isAttached()) { presenter.contactMe(it) } } } } override fun onDestroyView() { super.onDestroyView() presenter.detach() } }
Comparing to greenDAO, the integration was fast and simple. The only thing I had problems with was a correct database migration, without losing all the data. Because I had to re-created the same SQL code with Kotlin’s data class and Room’s annotations, as it was with Java and greenDAO. Finally, it worked and the data is persisted between app updates. This is how the data class for DAY_LOOK table looks like:
@Entity(tableName = "DAY_LOOK") data class DayLookEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "_id") var id: Long? = null, @ColumnInfo(name = "DATE") var date: String, @ColumnInfo(name = "SCREENON") var screenon: Int? = null, @ColumnInfo(name = "SCREENOFF") var screenoff: Int? = null, @ColumnInfo(name = "SCREENUNLOCK") var screenunlock: Int? = null ) { constructor() : this( id = null, date = "", screenon = null, screenoff = null, screenunlock = null ) }
Below is the DAO interface with the list of queries that I use. All of them operate on DAY_LOOK table. I wanted inserting to work as a replace/update when the day look entry already exists, hence OnConflictStrategy.REPLACE.
getDayLookByDate() returns Maybe<DayLookEntity> because the entry may or may not exist, so an empty response is acceptable. getAllDayLooks() is the list of all entries in the table, so seemed logical to return a Flowable.
@Dao interface DayLookDbDao { @Query("SELECT * FROM DAY_LOOK WHERE DATE = :dateToSearch LIMIT 1") fun getDayLookByDate(dateToSearch: String?): Maybe<DayLookEntity> @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(dayLook: DayLookEntity) @Query("SELECT * FROM DAY_LOOK") fun getAllDayLooks(): Flowable<List<DayLookEntity>> @Query("DELETE FROM DAY_LOOK") fun deleteAll() }
@Database(entities = [DayLookEntity::class], version = 2) abstract class ScreenCounterDb : RoomDatabase() { private var dbClearFlag = false abstract fun dayLookDao(): DayLookDbDao companion object { @Volatile private var INSTANCE: ScreenCounterDb? = null private const val DB_NAME = "lookcount-db" private val FROM_1_TO_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { // tables haven't changed, we're just moving from // greenDAO impl to Room } } fun getDatabase(context: Context): ScreenCounterDb = INSTANCE ?: synchronized(this) { INSTANCE ?: buildDatabase(context).also { INSTANCE = it } } private fun buildDatabase(context: Context) = Room.databaseBuilder(context.applicationContext, ScreenCounterDb::class.java, DB_NAME) .addMigrations(FROM_1_TO_2) .build() } @Synchronized fun getDayLook(date: String): Maybe<DayLookEntity> { return if (dbClearFlag) { Maybe.just(DayLookEntity(null, date, null, null, null)) } else { dayLookDao().getDayLookByDate(date) } } @Synchronized fun addDayLook(dayLook: DayLookEntity): Disposable { Log.d(C.TAG, "Insert/update daylook into the database.") return Completable.fromAction { dayLookDao().insert(dayLook) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeBy( onComplete = { Log.d(C.TAG, "addDayLook() insert() completed.") }, onError = { Log.e(C.TAG, "addDayLook() error: ${it.printStackTrace()}") }) } @Synchronized fun dropDb() { this.dbClearFlag = false Completable.fromAction { dayLookDao().deleteAll() } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeBy( onError = { Log.e(C.TAG, "dropDb() error: ${it.printStackTrace()}") } ) } @Synchronized fun setDbClearFlag(dbClearFlag: Boolean) { this.dbClearFlag = dbClearFlag } }
After all the above changes, what’s left was the counting service. I had to re-write it to become a foreground one, because really it’s the only way to make it work at all times and not get killed, when the resources are low, like a regular one is. I also had to add a few if’s, because of API differences (remember, there are now notification channels in Oreo? :). Other than that, the service just starts and stops the broadcast receiver which collects screen on/off/user present events and displays a notification in the system bar. Pressing the notification opens up the app and enables the user to check counter values, as well as stop the service manually.
/** * Service which starts a receiver for catching SCREEN_ON, SCREEN_OFF, and USER_PRESENT events. * * @author Antonina */ class LookCounterService : Service() { private val SERVICE_ID = 1 private lateinit var screenLookReceiver: ScreenLookReceiver override fun onBind(intent: Intent?): IBinder? { return null } override fun onCreate() { super.onCreate() registerScreenLookReceiver() } override fun onDestroy() { unregisterReceiver(screenLookReceiver) super.onDestroy() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { startForegroundServiceWithNotification() return super.onStartCommand(intent, flags, startId) } private fun registerScreenLookReceiver() { val filter = IntentFilter(Intent.ACTION_SCREEN_ON) with(filter) { addAction(Intent.ACTION_SCREEN_OFF) addAction(Intent.ACTION_USER_PRESENT) } screenLookReceiver = ScreenLookReceiver() registerReceiver(screenLookReceiver, filter) } private fun startForegroundServiceWithNotification() { val intent = Intent(this, ActivityMain::class.java) val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) val builder: NotificationCompat.Builder if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channelName = getString(R.string.notification_channel_screen_looks) val importance = NotificationManager.IMPORTANCE_LOW val channelId = "LOOK_COUNTS_CHANNEL_ID" val channel = NotificationChannel(channelId, channelName, importance) channel.setShowBadge(false) builder = NotificationCompat.Builder(this, channelId) val notificatioManager = getSystemService(NotificationManager::class.java) notificatioManager.createNotificationChannel(channel) } else { builder = NotificationCompat.Builder(this) } val icon = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) R.mipmap.ic_notification else R.mipmap.ic_launcher with(builder) { setContentTitle(getString(R.string.app_name)) setContentText(getString(R.string.notification_service_description)) setWhen(System.currentTimeMillis()) setSmallIcon(icon) setContentIntent(pendingIntent) priority = NotificationCompat.PRIORITY_DEFAULT setVisibility(NotificationCompat.VISIBILITY_SECRET) } val notification = builder.build() startForeground(SERVICE_ID, notification) } }
Conclusions on re-writing the app
- Using a ConstraintLayout auto-convert option on a complex layout usually does’t work as intended. You often have to fix things manually and set constraints by yourself, as they’re all messed up.
- Applying MVP architecture made the code easier to understand and navigate. It decoupled logic from UI and made things more clear in general.
- Converting Java project to Kotlin is time consuming if you want to do it properly (without !! and by using objects, lateinits, extension functions 🙂 It is very pleasant though — to see how beautiful (and shorter) your code became!
- greenDAO to Room migration went smoothly, but required writing all DB’s code from the scratch. Although classes look differently, underneath is the same SQL as before. Worth mentioning is that all queries are executed on the main thread by default, so I wrapped them into RxKotlin types.
- Changing synchronous calls to asynchronous and using a push data approach instead of a pull one was really hard for me. I knew RxKotlin on a basic level and wasn’t sure what types to use and how to achieve what I wanted. Also, how to wrap DB calls and how to chain a few calls together? Unfortunately, there aren’t many tutorials on RxKotlin yet and those with RxJava demonstrate the usage of the older lib versions. Fortunately, after lots of attempts, I have worked my way through 🙂 I can recommend this project with a good set Rx exercises.
Source code:
screenlookcount – Android app which counts screen looks and unlocks.github.com
Great content! Super high-quality! Keep it up! 🙂