💉Inject LiveData into JobIntentService and How to🔫 Test It

Homan Huang
11 min readApr 27, 2021

It’s 😓hard to get LiveData into the JobIntentService or any service library in a normal way. I assume that you have built your service file as YouIntentService.kt. After you start the service in an activity or fragment, You cannot monitor that service status from UI📵 because it is running in a background thread. In MVVM, you may want to get the LiveData into the service file. The system will keep fetch you an ❌error message that LiveData has not initialed through a function. You may want to inject the LiveData through the constructor. Another error will pop up that service cannot⛔️ take arguments. What are you going to do?

I’ll use Dagger Hilt to inject the LiveData. It’s easy as 123. Let’s try the Hilt method on a fresh project.

— === MenU === —

📦 1. MVVM Package
🖌️ 2.
UI Design
💼 3.
JobIntentService
🖋……………👨‍💻 Start Service: enqueueWork
🖋……………⌛️ End Service
🚪 4. Permission
🖋…………… 📍 AndroidManifest.xml
🖋……………✒️ ui/MainActivity.kt
⌚ 5.
Observables & Hilt
🖋……………🔆 app/ServiceApp.kt
🖋……………🗡 di/LiveDataModule.kt
🖋……………♈️ ui/MainViewModel.kt
🖋……………🔧 service/MyIntentService.kt
🖋……………🔨 Setup observers in ui/MainActivity.kt
🏃🏻 6.
Running Result
☕ 7.
Test Service with Espresso
🖋……………🍬 helper/Constants.kt
🚍 8.
UiAutomator — Stop Test
🖋……………📌 gradle.module
🖋……………🔍 Find Object by ID
🚁 9.
UiAutomator — UserInput Test + Logcat
🖋……………👁‍🗨 LogOutput.kt

📦1. MVVM Package …… → Menu

Gradle requirement:

View Binding:

    buildFeatures {
viewBinding true
}

Dagger Hilt → https://dagger.dev/hilt/

Activity and Fragment Ktx:

//region activity and fragment
// Activity and Fragment
def activity_version = "1.2.1"
implementation "androidx.activity:activity-ktx:$activity_version"
def
fragment_version = "1.3.2"
implementation "androidx.fragment:fragment-ktx:$fragment_version"
debugImplementation "androidx.fragment:fragment-testing:$fragment_version"
//endregion

MVVM

Simple, isn’t it? MVVM is the way to store files and a one-way data access method for UI.

Here are the helper files:

helper/LogHelper.kt

import android.util.Log

const val TAG = "MLOG"
fun
lgd(s:String) = Log.d(TAG, s)
fun lgi(s:String) = Log.i(TAG, s)
fun lge(s:String) = Log.e(TAG, s)
fun lgv(s:String) = Log.v(TAG, s)
fun lgw(s:String) = Log.w(TAG, s)

helper/MessageHelper.kt

import android.content.Context
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import android.widget.Toast.LENGTH_SHORT

fun msg(context: Context, s: String, len: Int) =
if (len > 0) Toast.makeText(context, s, LENGTH_LONG).show()
else Toast.makeText(context, s, LENGTH_SHORT).show()

🖌️ 2. UI Design …… → Menu

By this UI, I can test the message I (et_message)/O(tv_service) with the service. Also, I can manually start and stop the service.

💼 3. JobIntentService …… → Menu

JobIntestService is the improvement version of IntentService. It shall be destroyed with the App destroyed. Don’t worry, let’s test it, soon.

👨‍💻 Start Service: enqueueWork …… → Menu

To start JobIntentService, we need to call this function:

fun enqueueWork(context: Context, work: Intent) {
enqueueWork(context, MyIntentService::class.java, JOB_ID, work)
}

The enqueueWork will take 4 parameters:

  1. Context
  2. Service class
  3. Job ID
  4. Intent

⌛️ End Service …… → Menu

Eng service is easy.

class MyIntentService: JobIntentService() {

init {
instance = this
}
companion object {
private lateinit var instance: MyIntentService

Get itself, and shut it down.

companion object {
private lateinit var instance: MyIntentService
private val JOB_ID = 4343443

fun enqueueWork(context: Context, work: Intent) {...}

fun stopService() {
lgd("MyIntentService: Service is stopping...")
instance.stopSelf()
}
}

🚪 4. Permission …… → Menu

The service requires Wake_Lock permission.

📍 AndroidManifest.xml …… → Menu

<uses-permission android:name="android.permission.WAKE_LOCK" /><application
...
<activity android:name=".ui.MainActivity">
...
</activity>
<service android:name=".service.MyIntentService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="true" />

</application>

✒️ ui/MainActivity.kt …… → Menu

// check manifests for permissions
private val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.WAKE_LOCK
)

class MainActivity : AppCompatActivity() {

// app permission
private val reqMultiplePermissions = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
permissions.entries.forEach {
lgd("mainAct: Permission: ${it.key} = ${it.value}")
if (!it.value) {
// toast
msg
(this, "Permission: ${it.key} denied!", 1)
finish()
}
}
}

// =============== Variables
// view binding
private lateinit var binding: ActivityMainBinding
// view model
val viewModel: MainViewModel by viewModels()

// =============== END of Variables

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

// check app permissions
reqMultiplePermissions.launch(REQUIRED_PERMISSIONS)
}

companion object {
const val USER_INPUT = "USER_INPUT"
}
}

⌚5. Observables & Hilt …… → Menu

I need to monitor two observables:

  1. isRunning: This is for service status.
  2. userInput: The service will get the message from the UI. The service is running on a background, so it has no guarantee that IntentExtra will be working. The 100% working solution is LiveData. I’ll show you the test result soon.

There’re two locations that need to be injected into these observables: MainViewModel and MyIntentService. Now, let’s insert Hilt.

🔆 app/ServiceApp.kt …… → Menu

@HiltAndroidApp
class ServiceApp: Application()

🗡 di/LiveDataModule.kt …… → Menu

@Module
@InstallIn(SingletonComponent::class)
object LiveDataModule {

@Provides
@Singleton
fun provideServiceStatus():
MutableLiveData<Boolean> = MutableLiveData<Boolean>()

@Provides
@Singleton
fun provideUserInput():
MutableLiveData<String> = MutableLiveData<String>()

}

♈️ ui/MainViewModel.kt …… → Menu

@HiltViewModel
class MainViewModel @Inject constructor(
val isRunning: MutableLiveData<Boolean>,
private val userInput: MutableLiveData<String>

): ViewModel() {

init {
isRunning.value = false
userInput.value = ""
}

fun enableService() {
isRunning.postValue(true)
}

fun updateUserInput(inputText: String) {
userInput.postValue(inputText)
}

}

🔧 service/MyIntentService.kt …… → Menu

@AndroidEntryPoint
class MyIntentService: JobIntentService() {

@Inject
lateinit var isRunning: MutableLiveData<Boolean>

@Inject
lateinit var userInput: MutableLiveData<String>

init {
instance = this
}

override fun onHandleWork(intent: Intent) {
lgd("onHandleWork")
try {
lgd("MyIntentService: Service is running...")
if (isRunning.value!!) {

// check Intent Extra
val extraInput = intent.getStringExtra(USER_INPUT)
lgd("Intent Extra: $extraInput")

var input = "Empty"
if
(userInput.value != "")
input = userInput.value.toString()

lgd("receive text from LiveData: $input")

for (i in 0..9) {
lgd("Input: $input - $i")
if (isRunning.value == false)
return
SystemClock.sleep(1000)
}
stopService()
}
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
}
...

🔨 Setup observers in ui/MainActivity.kt …… → Menu

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
...

override fun onCreate(savedInstanceState: Bundle?) {
...

binding.btStart.setOnClickListener {
viewModel
.enableService()

// update user input
val inputText = binding.etMessage.text
if (!inputText.isEmpty() || !inputText.isBlank())
viewModel.updateUserInput(inputText.toString())
lgd("input text: $inputText")

val mIntent = Intent(this, MyIntentService::class.java)
mIntent.putExtra(USER_INPUT, inputText)

// start service
MyIntentService.enqueueWork(this, mIntent)
}

binding
.btStop.setOnClickListener {
MyIntentService.stopService()

}

// observer
viewModel.isRunning.observe(this, {
if
(it)
binding.tvService.text = "Service is Start..."
else
binding
.tvService.text = "Service is Stop!"
}
)
}

companion object {...}
}

🏃🏻6. Running Result …… → Menu

Service start and stop are working. Let’s check Logcat.

--------- beginning of system
2021-04-24 14:39:27.113 6199-7154 D/MLOG: onHandleWork
2021-04-24 14:39:27.113 6199-7154 D/MLOG: MyIntentService: Service is running...
2021-04-24 14:39:27.123 6199-7154 D/MLOG: Intent Extra: null
2021-04-24 14:39:27.123 6199-7154 D/MLOG: receive text from LiveData: Empty
2021-04-24 14:39:27.123 6199-7154 D/MLOG: Input: Empty - 0
2021-04-24 14:39:28.164 6199-7154 D/MLOG: Input: Empty - 1
2021-04-24 14:39:29.205 6199-7154 D/MLOG: Input: Empty - 2
2021-04-24 14:39:30.246 6199-7154 D/MLOG: Input: Empty - 3
2021-04-24 14:39:31.273 6199-7154 D/MLOG: Input: Empty - 4
2021-04-24 14:39:32.318 6199-7154 D/MLOG: Input: Empty - 5
2021-04-24 14:39:33.360 6199-7154 D/MLOG: Input: Empty - 6
2021-04-24 14:39:34.375 6199-7154 D/MLOG: Input: Empty - 7
2021-04-24 14:39:35.380 6199-7154 D/MLOG: Input: Empty - 8
2021-04-24 14:39:36.384 6199-7154 D/MLOG: Input: Empty - 9
2021-04-24 14:39:37.387 6199-7154 D/MLOG: MyIntentService: Service is stopping...

Next, I input “Welcome” into the textbox.

Logcat Result:

2021-04-24 14:44:55.110 6199-6199 D/MLOG: input text: Welcome
2021-04-24 14:44:55.126 6199-7154 D/MLOG: onHandleWork
2021-04-24 14:44:55.126 6199-7154 D/MLOG: MyIntentService: Service is running...
2021-04-24 14:44:55.127 6199-7154 D/MLOG: Intent Extra: null
2021-04-24 14:44:55.127 6199-7154 D/MLOG: receive text from LiveData: Welcome
2021-04-24 14:44:55.127 6199-7154 D/MLOG: Input: Welcome - 0
2021-04-24 14:44:56.168 6199-7154 D/MLOG: Input: Welcome - 1
2021-04-24 14:44:57.211 6199-7154 D/MLOG: Input: Welcome - 2
2021-04-24 14:44:58.251 6199-7154 D/MLOG: Input: Welcome - 3
2021-04-24 14:44:59.293 6199-7154 D/MLOG: Input: Welcome - 4
2021-04-24 14:45:00.334 6199-7154 D/MLOG: Input: Welcome - 5
2021-04-24 14:45:01.377 6199-7154 D/MLOG: Input: Welcome - 6
2021-04-24 14:45:02.418 6199-7154 D/MLOG: Input: Welcome - 7
2021-04-24 14:45:03.460 6199-7154 D/MLOG: Input: Welcome - 8
2021-04-24 14:45:04.501 6199-7154 D/MLOG: Input: Welcome - 9
2021-04-24 14:45:05.542 6199-7154 D/MLOG: MyIntentService: Service is stopping...

You see. The Intent Extra has failed to import data. The LiveData is working fine in the service.

☕ 7. Test Service with Espresso …… → Menu

We need to ensure that the service is capable to be ON and OFF. That’s why we need a service test. The First tool in my mind is Espresso. Let’s create a new one. Click on “class MyIntentService” and Alt+Insert.

The UI test requires androidTest folder.

service/MyIntentServiceTest.kt

@ExperimentalCoroutinesApi
@LargeTest
@HiltAndroidTest
class MyIntentServiceTest {

@get:Rule(order = 1)
var hiltRule = HiltAndroidRule(this)

@get:Rule(order = 2)
var activityRule = ActivityScenarioRule(MainActivity::class.java)


@Before
fun setup() {
hiltRule.inject()
}

🍬 helper/Constants.kt …… → Menu

const val START = "Service is Start..."
const val
STOP = "Service is Stop!"

This will help me with the constant strings.

Stop Test Case:

Let’s add a stop test case:

@Test
fun test_stop_service_espresso() {
lgd("=====> Stop Service Test")

// start activity
val scenario = activityRule.getScenario()

onView(withId(R.id.bt_start)).perform(click())
lgd("=====> Start Button Clicked")

onView(withId(R.id.bt_stop)).perform(click())
lgd("=====> Stop Button Clicked")

val serviceMsg = onView(withId(R.id.tv_service))
serviceMsg.check(ViewAssertions.matches(
ViewMatchers.withText(STOP)))
}

It seems OK.

Let’s check Logcat:

2021-04-27 07:57:31.223 20493-20703 D/MLOG: MyIntentService: Service is running...
2021-04-27 07:57:31.230 20493-20703 D/MLOG: Intent Extra: null
2021-04-27 07:57:31.230 20493-20703 D/MLOG: receive text from LiveData: Empty
2021-04-27 07:57:31.230 20493-20703 D/MLOG: Input: Empty - 0
2021-04-27 07:57:32.278 20493-20703 D/MLOG: Input: Empty - 1
2021-04-27 07:57:33.313 20493-20703 D/MLOG: Input: Empty - 2
2021-04-27 07:57:34.345 20493-20703 D/MLOG: Input: Empty - 3
2021-04-27 07:57:35.356 20493-20703 D/MLOG: Input: Empty - 4
2021-04-27 07:57:36.393 20493-20703 D/MLOG: Input: Empty - 5
2021-04-27 07:57:37.434 20493-20703 D/MLOG: Input: Empty - 6
2021-04-27 07:57:38.473 20493-20703 D/MLOG: Input: Empty - 7
2021-04-27 07:57:39.475 20493-20703 D/MLOG: Input: Empty - 8
2021-04-27 07:57:40.521 20493-20703 D/MLOG: Input: Empty - 9
2021-04-27 07:57:41.558 20493-20703 D/MLOG: MyIntentService: Service is stopping...
2021-04-27 07:57:41.792 20493-20533 D/MLOG: =====> Start Button Clicked
2021-04-27 07:57:41.850 20493-20493 D/MLOG: MainAct: stop button clicked!
2021-04-27 07:57:41.850 20493-20493 D/MLOG: MyIntentService: Service is stopping...
2021-04-27 07:57:42.089 20493-20533 D/MLOG: =====> Stop Button Clicked

The Espresso begins to run after the service job finished. Obviously, it is not suitable for this test case. We can’t reply on one tool to do the job. In the next part, I will use UiAutomator to test the JobIntentService.

🚍 8. UiAutomator — Stop Test …… → Menu

Modify the Gradle to add a new library.

📌 gradle.module …… → Menu

//UiAutomator
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'

Sync.

MyIntentJobServiceTest

Let’s keep one rule:

@get:Rule(order = 1)
var hiltRule = HiltAndroidRule(this)

Add a new variable:

private var mDevice: UiDevice? = null

Setup the App

@Before
fun setup() {
hiltRule.inject()

// Initialize UiDevice instance
mDevice = UiDevice.getInstance(getInstrumentation())
mDevice!!.pressMenu()
val launcherPackage = mDevice!!.launcherPackageName
Truth.assertThat(launcherPackage).isNotNull()

mDevice!!.wait(
Until.hasObject(By.pkg(launcherPackage).depth(0)),
LAUNCH_TIMEOUT
)

// launch app
val context = ApplicationProvider.getApplicationContext<Context>()
val intent = context.packageManager.getLaunchIntentForPackage(
APPLICATION_ID)?.apply {
// Clear out any previous instances
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
context.startActivity(intent)

// Wait for the app to appear
mDevice!!.wait(
Until.hasObject(By.pkg(APPLICATION_ID).depth(0)),
LAUNCH_TIMEOUT
)
}
companion object {
const val LAUNCH_TIMEOUT = 5000L
}

Start the activity with the APPLICATION_ID . This is always the same at the beginning. You can keep it in the Live Template.

Stop Service Test Case

@Test
fun test_stop_service_uiautomator() {
}

🔍 Find Object by ID …… → Menu

The UiAutomator can find objects by ID. Here is the Live Template:

val $var$ = mDevice!!.findObject(
res("${APPLICATION_ID}:id/$obj$"))

Let’s import two buttons, Start &. Stop.

// buttons
val startBt = mDevice!!.findObject(
res("${APPLICATION_ID}:id/bt_start"))
val stopBt = mDevice!!.findObject(
res("${APPLICATION_ID}:id/bt_stop"))

The same process: Start service → Stop service → Check status

lgd("start bt: ${startBt.resourceName}")
startBt.click()

Thread.sleep(1000L)

lgd("stop bt: ${stopBt.resourceName}")
stopBt.click()

// service message
val serviceMsg = mDevice!!.findObject(
res("${APPLICATION_ID}:id/tv_service"))
val statusStr = serviceMsg.text
Truth.assertThat(statusStr).isEqualTo(STOP)

🚴Run:

Great, passed!

🚁 9. UiAutomator — UserInput Test + Logcat …… → Menu

It’s similar with the last one; furthermore, we need to read the Logcat.

👁‍🗨 LogOutput.kt …… → Menu

/**
* Editor: Homan Huang
* Date: 04/27/2021
*/
/**
* Get Logcat output by getOutput("logcat *:S TAG -d")
*/
fun getOutput(command: String): String? {
val proc = Runtime.getRuntime().exec(command)

try {
val stdInput = BufferedReader(
InputStreamReader(
proc.inputStream
)
)
val output = StringBuilder()
var line: String? = ""
//var counter = 0
while (stdInput.readLine().also { line = it } != null) {
//counter += 1
//lgd("line #$counter = $line")
output.append(line+"\n")
}
stdInput.close()
return output.toString()
} catch (e: IOException) {
e.printStackTrace()
}
return null
}

/**
* clear logcat buffer
*/
fun clearLog() {
Runtime.getRuntime().exec("logcat -c")
}

User Input Service Test

/**
* Test: Input the message;
* start the service;
* and check logcat
*/
@Test
fun message_input_service_uiautomator() {
// buttons
val msgInput = mDevice!!.findObject(
res("${APPLICATION_ID}:id/et_message"))
val startBt = mDevice!!.findObject(
res("${APPLICATION_ID}:id/bt_start"))
val stopBt = mDevice!!.findObject(
res("${APPLICATION_ID}:id/bt_stop"))

// clear Logcat buffer
clearLog()

// input
val toServiceStr = "This is a test."
msgInput.text = toServiceStr
Thread.sleep(1000)

lgd("start bt: ${startBt.resourceName}")
startBt.click()

Thread.sleep(1000)

lgd("stop bt: ${stopBt.resourceName}")
stopBt.click()

val param = "logcat *:S MLOG -d"
lgd("param: $param")
val mLog = getOutput(param)

Thread.sleep(500)

lgd("mlog: $mLog")

Truth.assertThat(mLog?.contains("Input: $toServiceStr"))
.isTrue()
}

Easy, right? You need to find three objects: EditText and 2 buttons.

Afterward, I insert the test message into the EditText, Start &. Stop the service. Before the test, I clear the Logcat buffer. At the end, I grab the Logcat output to check if it contains the test message.

Here is the Logcat:

mlog: --------- beginning of main
04-27 14:06:29.582 31368 31368 D MLOG : mainAct: Permission: android.permission.WAKE_LOCK = true
04-27 14:06:31.459 31368 31400 D MLOG : start bt: com.homan.huang.servicedemo:id/bt_start
04-27 14:06:31.504 31368 31368 D MLOG : MainAct: start button clicked!
04-27 14:06:31.504 31368 31368 D MLOG : input text: This is a test.
04-27 14:06:31.527 31368 31419 D MLOG : onHandleWork
04-27 14:06:31.527 31368 31419 D MLOG : MyIntentService: Service is running...
04-27 14:06:31.528 31368 31419 D MLOG : Intent Extra: null
04-27 14:06:31.528 31368 31419 D MLOG : receive text from LiveData: This is a test.
04-27 14:06:31.528 31368 31419 D MLOG : Input: This is a test. - 0
04-27 14:06:32.495 31368 31400 D MLOG : stop bt: com.homan.huang.servicedemo:id/bt_stop
04-27 14:06:32.519 31368 31400 D MLOG : param: logcat *:S MLOG -d
04-27 14:06:32.525 31368 31368 D MLOG : MainAct: stop button clicked!
04-27 14:06:32.525 31368 31368 D MLOG : MyIntentService: Service is stopping...

Passed!

Clap, clap… …… → Menu
😊 Hope You Have A Good Testing DAY!

--

--

Homan Huang

Computer Science BS from SFSU. I studied and worked on Android system since 2017. If you are interesting in my past works, please go to my LinkedIn.