Final Results

Our final res/values/strings.xml file should look like:

<resources>
  <string name="app_name">ToDo</string>
  <string name="msg_empty">Click the + icon to add a todo item!</string>
  <string name="msg_empty_filtered">Click the + icon to add a todo item, or change your filter to show other items</string>
  <string name="menu_about">About</string>
  <string name="is_completed">Item is completed</string>
  <string name="created_on">Created on:</string>
  <string name="menu_edit">Edit</string>
  <string name="desc">Description</string>
  <string name="notes">Notes</string>
  <string name="menu_save">Save</string>
  <string name="menu_add">Add</string>
  <string name="menu_delete">Delete</string>
  <string name="menu_filter">Filter</string>
  <string name="menu_filter_all">All</string>
  <string name="menu_filter_completed">Completed</string>
  <string name="menu_filter_outstanding">Outstanding</string>
  <string name="oops">Sorry! Something went wrong!</string>
  <string name="report_template"><![CDATA[<h1>To-Do Items</h1>

<h2></h2>
<p><b>COMPLETED</b> &mdash; Created on: </p>
<p></p>

]]></string>
  <string name="menu_share">Share</string>
  <string name="pref_url_title">Web service URL</string>
  <string name="web_service_url_key">webServiceUrl</string>
  <string name="web_service_url_default">https://commonsware.com/AndExplore/2.0/items.json</string>
  <string name="settings">Settings</string>
  <string name="menu_import">Import</string>
  <string name="cancel">Cancel</string>
  <string name="retry">Retry</string>
  <string name="import_error_title">Import Failure</string>
  <string name="import_error_message">Something went wrong with the import!</string>
  <string name="pref_import_title">Import periodically</string>
  <string name="import_key">doPeriodicImport</string>
</resources>

And our updated res/xml/prefs.xml should resemble:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto">

  <EditTextPreference
    android:key="@string/web_service_url_key"
    android:selectAllOnFocus="true"
    android:title="@string/pref_url_title"
    app:defaultValue="@string/web_service_url_default" />
  <SwitchPreference
    android:defaultValue="false"
    android:key="@string/import_key"
    android:title="@string/pref_import_title" />
</PreferenceScreen>

The revised PrefsRepository should contain:

package com.commonsware.todo.repo

import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import com.commonsware.todo.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.withContext

class PrefsRepository(context: Context) {
  private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
  private val webServiceUrlKey = context.getString(R.string.web_service_url_key)
  private val defaultWebServiceUrl =
    context.getString(R.string.web_service_url_default)
  private val importKey = context.getString(R.string.import_key)

  suspend fun loadWebServiceUrl(): String = withContext(Dispatchers.IO) {
    prefs.getString(webServiceUrlKey, defaultWebServiceUrl)
      ?: defaultWebServiceUrl
  }

  fun observeImportChanges() = channelFlow {
    val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
      if (importKey == key) {
        offer(prefs.getBoolean(importKey, false))
      }
    }

    prefs.registerOnSharedPreferenceChangeListener(listener)
    awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }
  }
}

The latest app/build.gradle should resemble:

plugins {
  id 'com.android.application'
  id 'kotlin-android'
  id 'androidx.navigation.safeargs.kotlin'
  id 'kotlin-kapt'
}

android {
  compileSdk 31

  defaultConfig {
    applicationId "com.commonsware.todo"
    minSdk 21
    targetSdk 31
    versionCode 1
    versionName "1.0"

    testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
  }

  buildTypes {
    release {
      minifyEnabled false
      proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
  }

  buildFeatures {
    viewBinding true
  }

  compileOptions {
    coreLibraryDesugaringEnabled true
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }

  kotlinOptions {
    jvmTarget = '1.8'
  }

  packagingOptions {
    exclude 'META-INF/AL2.0'
    exclude 'META-INF/LGPL2.1'
  }
}

dependencies {
  implementation 'androidx.core:core-ktx:1.6.0'
  implementation 'androidx.appcompat:appcompat:1.3.1'
  implementation 'androidx.constraintlayout:constraintlayout:2.1.0'
  implementation "androidx.recyclerview:recyclerview:1.2.1"
  implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
  implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
  implementation "androidx.preference:preference-ktx:1.1.1"
  implementation "androidx.work:work-runtime-ktx:2.6.0"
  implementation 'com.google.android.material:material:1.4.0'
  implementation "io.insert-koin:koin-android:$koin_version"
  implementation "com.github.jknack:handlebars:4.1.2"
  implementation "androidx.room:room-runtime:$room_version"
  implementation "androidx.room:room-ktx:$room_version"
  implementation "com.squareup.okhttp3:okhttp:4.9.1"
  implementation "com.squareup.moshi:moshi:$moshi_version"
  kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version"
  kapt "androidx.room:room-compiler:$room_version"
  coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
  testImplementation 'junit:junit:4.13.2'
  testImplementation "org.mockito:mockito-inline:3.12.1"
  testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
  testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1'
  androidTestImplementation 'androidx.test.ext:junit:1.1.3'
  androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
  androidTestImplementation "androidx.arch.core:core-testing:2.1.0"
  androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1'
}

Our new ImportWorker should look like:

package com.commonsware.todo.repo

import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

class ImportWorker(context: Context, params: WorkerParameters) :
  CoroutineWorker(context, params), KoinComponent {

  private val repo: ToDoRepository by inject()
  private val prefs: PrefsRepository by inject()

  override suspend fun doWork() = try {
    repo.importItems(prefs.loadWebServiceUrl())

    Result.success()
  } catch (ex: Exception) {
    Log.e("ToDo", "Exception importing items in doWork()", ex)
    Result.failure()
  }
}

And our altered ToDoApp should contain:

package com.commonsware.todo

import android.app.Application
import android.text.format.DateUtils
import androidx.work.*
import com.commonsware.todo.repo.*
import com.commonsware.todo.report.RosterReport
import com.commonsware.todo.ui.SingleModelMotor
import com.commonsware.todo.ui.roster.RosterMotor
import com.github.jknack.handlebars.Handlebars
import com.github.jknack.handlebars.Helper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidApplication
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.context.startKoin
import org.koin.core.qualifier.named
import org.koin.dsl.module
import java.time.Instant
import java.util.concurrent.TimeUnit

private const val TAG_IMPORT_WORK = "doPeriodicImport"

class ToDoApp : Application(), KoinComponent {
  private val koinModule = module {
    single(named("appScope")) { CoroutineScope(SupervisorJob()) }
    single { ToDoDatabase.newInstance(androidContext()) }
    single {
      ToDoRepository(
        get<ToDoDatabase>().todoStore(),
        get(named("appScope")),
        get()
      )
    }
    single {
      Handlebars().apply {
        registerHelper("dateFormat", Helper<Instant> { value, _ ->
          DateUtils.getRelativeDateTimeString(
            androidContext(),
            value.toEpochMilli(),
            DateUtils.MINUTE_IN_MILLIS,
            DateUtils.WEEK_IN_MILLIS, 0
          )
        })
      }
    }
    single { RosterReport(androidContext(), get(), get(named("appScope"))) }
    single { OkHttpClient.Builder().build() }
    single { ToDoRemoteDataSource(get()) }
    single { PrefsRepository(androidContext()) }
    viewModel {
      RosterMotor(
        get(),
        get(),
        androidApplication(),
        get(named("appScope")),
        get()
      )
    }
    viewModel { (modelId: String) -> SingleModelMotor(get(), modelId) }
  }

  override fun onCreate() {
    super.onCreate()

    startKoin {
      androidLogger()
      androidContext(this@ToDoApp)
      modules(koinModule)
    }

    scheduleWork()
  }

  private fun scheduleWork() {
    val prefs: PrefsRepository by inject()
    val appScope: CoroutineScope by inject(named("appScope"))
    val workManager = WorkManager.getInstance(this)

    appScope.launch {
      prefs.observeImportChanges().collect {
        if (it) {
          val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
          val request =
            PeriodicWorkRequestBuilder<ImportWorker>(15, TimeUnit.MINUTES)
              .setConstraints(constraints)
              .addTag(TAG_IMPORT_WORK)
              .build()

          workManager.enqueueUniquePeriodicWork(
            TAG_IMPORT_WORK,
            ExistingPeriodicWorkPolicy.REPLACE,
            request
          )
        } else {
          workManager.cancelAllWorkByTag(TAG_IMPORT_WORK)
        }
      }
    }
  }
}

Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.