Pop! Pop! Give JobIntentService an injection, a big injection of Hilt, see if you're happy? Oh, I can't see the result.

Posted by johng on Sun, 20 Feb 2022 04:15:14 +0100

0. Introduction Service

Service doesn't have to be used for a long time. Isn't it a long servant? We can use JobIntentService -- temporary servant. It's good to live and die with your App! However, it's easy to start and there's no clue to shut down. Because the service runs in the background, it has nothing to do with the UI. If we use MVVM, we can plug LiveData. Start through the equation, and the system will pop up "LiveData has not initialized". If you use the constructor of service, the system will say that it does not accept parameters. Spare your head, right?

It doesn't matter. We can use plug-in. What I propose is dagger hilt to inject the system.

📦 1. MVVM package

🌮 Gradle - database selection:

  • View Binding:
buildFeatures {
    viewBinding true
}
  • Dagger Hilt ——Please install by yourself.
  • ViewModel:
//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

Just mention it here. In fact, you can copy what I wrote before. Gradle takes up too much space, so omit one or two.
...

🔰 MVVM - file arrangement

🖐🏻 Helper - Helper

  • 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 graphic design


et_message: enter the data into the Service.
tv_service: service response.

💼 3. JobIntentService

JobIntentService is an improved version of IntentService.

🐔 Start service

This service is started with an equation - enqueueWork

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

This enqueueWork has four parameters:

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

⌛ Shut down the service

Close with instance.

class MyIntentService: JobIntentService() {

    init {
        instance = this
    }
    
	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()
		}
	}
}

You see, shut yourself up.

🚪 4. Permission

📍 AndroidManifest.xml

<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

// 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 observation and injection

👁‍🗨 Observation point

I need to provide two observation points:

  1. isRunning: service status.
  2. userInput: the content entered by the customer. There are two ways: IntentExtra and LiveData. I'll test that guaranteed.

🔆 app/ServiceApp.kt provides Hilt application

@HiltAndroidApp
class ServiceApp: Application()

...

🗡 di/LiveDataModule.kt

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

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

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

Easy, just serve these two.
...

♈️ ui/MainViewModel.kt

@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)
    }
}

The main function is to update the screen.

...

🔧 service/MyIntentService.kt

@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()
        }
    }
...

...

🔨 ui/MainActivity.kt

@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 {...}
}

Press the key to open the service and close the service.
Another is to observe the service status.

🏃🏻 6. Operation results


In good condition. Look at 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...

Join Welcome and see if the Service receives it.

Logcat:

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...

Intextera receive failed.
LiveData succeeded.

☕ 7. Espresso test

➕ Add test

Right click "class MyIntentService" and Alt+Insert.

Select Junit4:

Android test 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 -- constant

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

...

❌ Stop Test Case -- stop the test

@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 normal. Let's take a look at 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

Terrible. Espresso didn't come out to work until the Service stopped. Therefore, we need another tool to test - UiAutomator.

🚍 8. UiAutomator

📌 gradle.module

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

Sync.

...

📢 MyIntentJobServiceTest

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

This reservation.

private var mDevice: UiDevice? = null

Start:

@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
}

Add Test:

@Test
fun test_stop_service_uiautomator() {
}

🔍 Find object with ID

UiAutomator looks for things the same as Android:

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

This is a shortcut to Live Template.

Let's add two buttons:

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

Try the same process again: start the service → stop the service → check

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:

This time, you should see that the test didn't take 10 seconds and stopped almost immediately. So this is a successful test.

🚁 9. UiAutomator - UserInput input test

👁‍🗨 LogOutput.kt - Log output

We can intercept some logs to check the results.

/**
 *  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")
}

📳 Input 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()
}

Run! Intercept 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...

Test passed!

🍞 10. English version

Help, clap your hands!
English connection: 💉Inject LiveData into JobIntentService and How to🔫 Test It

Topics: Programming Android kotlin Testing