Basic Notification Support (#140)

* Initial pass on notifications support

* Linting and Dep updates.

* Basic Notification Support complete.

* Fix onboarding flow and test compile issue.

* Fix unit tests

* Bump tool versions.

* All tests pass... Need to clean up still.

* Using correct mockk features.

* Using correct mockk features everywhere.

* More test fixes.
This commit is contained in:
Justin Bassett 2019-12-12 12:17:26 -05:00 committed by Robbie Trencheny
parent b8a9737d18
commit 20cc710490
18 changed files with 432 additions and 52 deletions

View file

@ -94,6 +94,9 @@ dependencies {
implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1'
implementation 'com.google.android.gms:play-services-location:17.0.0'
implementation "com.google.firebase:firebase-core:17.2.1"
implementation "com.google.firebase:firebase-iid:20.0.2"
implementation "com.google.firebase:firebase-messaging:20.1.0"
testImplementation "org.spekframework.spek2:spek-dsl-jvm:$spek2Version"
testImplementation "org.spekframework.spek2:spek-runner-junit5:$spek2Version"

View file

@ -44,6 +44,20 @@
<activity
android:name=".settings.SettingsActivity"
android:parentActivityName=".webview.WebViewActivity"/>
<service
android:name=".notifications.MessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_launcher_foreground" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/colorPrimary" />
</application>
</manifest>

View file

@ -1,7 +1,14 @@
package io.homeassistant.companion.android.launch
import android.os.Build
import android.util.Log
import com.google.firebase.iid.FirebaseInstanceId
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.domain.authentication.AuthenticationUseCase
import io.homeassistant.companion.android.domain.authentication.SessionState
import io.homeassistant.companion.android.domain.integration.IntegrationUseCase
import io.homeassistant.companion.android.notifications.MessagingService
import java.lang.Exception
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -11,14 +18,20 @@ import kotlinx.coroutines.launch
class LaunchPresenterImpl @Inject constructor(
private val view: LaunchView,
private val authenticationUseCase: AuthenticationUseCase
private val authenticationUseCase: AuthenticationUseCase,
private val integrationUseCase: IntegrationUseCase
) : LaunchPresenter {
companion object {
const val TAG = "LaunchPresenter"
}
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onViewReady() {
mainScope.launch {
if (authenticationUseCase.getSessionState() == SessionState.CONNECTED) {
resyncNotificationIds()
view.displayWebview()
} else {
view.displayOnBoarding()
@ -29,4 +42,24 @@ class LaunchPresenterImpl @Inject constructor(
override fun onFinish() {
mainScope.cancel()
}
// TODO: This should probably go in settings?
private fun resyncNotificationIds() {
FirebaseInstanceId.getInstance().instanceId.addOnSuccessListener {
mainScope.launch {
try {
integrationUseCase.updateRegistration(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
Build.MODEL ?: "UNKNOWN",
Build.MANUFACTURER ?: "UNKNOWN",
Build.MODEL ?: "UNKNOWN",
Build.VERSION.SDK_INT.toString(),
MessagingService.generateAppData(it.token)
)
} catch (e: Exception) {
Log.e(TAG, "Issue updating Registration", e)
}
}
}
}
}

View file

@ -0,0 +1,124 @@
package io.homeassistant.companion.android.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
import io.homeassistant.companion.android.domain.integration.IntegrationUseCase
import io.homeassistant.companion.android.webview.WebViewActivity
import java.lang.Exception
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class MessagingService : FirebaseMessagingService() {
companion object {
const val TAG = "MessagingService"
fun generateAppData(pushToken: String): HashMap<String, String> {
return hashMapOf(
"push_url" to "https://mobile-apps.home-assistant.io/api/sendPushNotification",
"push_token" to pushToken
)
}
}
@Inject
lateinit var integrationUseCase: IntegrationUseCase
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onCreate() {
super.onCreate()
DaggerServiceComponent.builder()
.appComponent((applicationContext as GraphComponentAccessor).appComponent)
.build()
.inject(this)
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
Log.d(TAG, "From: ${remoteMessage.from}")
// Check if message contains a data payload.
remoteMessage.data.isNotEmpty().let {
Log.d(TAG, "Message data payload: " + remoteMessage.data)
}
remoteMessage.notification?.let {
Log.d(TAG, "Message Notification: ${it.title} -> ${it.body}")
sendNotification(it.title, it.body!!)
}
}
/**
* Create and show a simple notification containing the received FCM message.
*
* @param messageBody FCM message body received.
*/
private fun sendNotification(messageTitle: String?, messageBody: String) {
val intent = Intent(this, WebViewActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
val pendingIntent = PendingIntent.getActivity(this, 0, intent,
PendingIntent.FLAG_ONE_SHOT)
// TODO: implement channels
val channelId = "default"
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setAutoCancel(true)
.setSound(defaultSoundUri)
.setContentIntent(pendingIntent)
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Since android Oreo notification channel is needed.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(channelId,
"Default Channel",
NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}
// TODO: This message id probably isn't the best
notificationManager.notify((messageBody + messageTitle).hashCode(), notificationBuilder.build())
}
/**
* Called if InstanceID token is updated. This may occur if the security of
* the previous token had been compromised. Note that this is called when the InstanceID token
* is initially generated so this is where you would retrieve the token.
*/
override fun onNewToken(token: String) {
mainScope.launch {
Log.d(TAG, "Refreshed token: $token")
try {
integrationUseCase.updateRegistration(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
Build.MODEL ?: "UNKNOWN",
Build.MANUFACTURER ?: "UNKNOWN",
Build.MODEL ?: "UNKNOWN",
Build.VERSION.SDK_INT.toString(),
generateAppData(token)
)
} catch (e: Exception) {
// TODO: Store for update later
Log.e(TAG, "Issue updating token", e)
}
}
}
}

View file

@ -0,0 +1,10 @@
package io.homeassistant.companion.android.notifications
import dagger.Component
import io.homeassistant.companion.android.common.dagger.AppComponent
@Component(dependencies = [AppComponent::class])
interface ServiceComponent {
fun inject(service: MessagingService)
}

View file

@ -4,9 +4,11 @@ import android.app.Activity
import android.content.Context
import android.os.Build
import android.util.Log
import com.google.firebase.iid.FirebaseInstanceId
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.domain.integration.DeviceRegistration
import io.homeassistant.companion.android.domain.integration.IntegrationUseCase
import io.homeassistant.companion.android.notifications.MessagingService
import io.homeassistant.companion.android.util.PermissionManager
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
@ -29,28 +31,37 @@ class MobileAppIntegrationPresenterImpl @Inject constructor(
override fun onRegistrationAttempt() {
view.showLoading()
val instanceId = FirebaseInstanceId.getInstance().instanceId
instanceId.addOnSuccessListener {
mainScope.launch {
val token = it.token
mainScope.launch {
val deviceRegistration = DeviceRegistration(
BuildConfig.APPLICATION_ID,
"Home Assistant",
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
Build.MODEL ?: "UNKNOWN",
Build.MANUFACTURER ?: "UNKNOWN",
Build.MODEL ?: "UNKNOWN",
"Android",
Build.VERSION.SDK_INT.toString(),
false,
null
)
try {
integrationUseCase.registerDevice(deviceRegistration)
view.deviceRegistered()
} catch (e: Exception) {
Log.e(TAG, "Error with registering application", e)
view.showError()
val deviceRegistration = DeviceRegistration(
BuildConfig.APPLICATION_ID,
"Home Assistant",
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
Build.MODEL ?: "UNKNOWN",
Build.MANUFACTURER ?: "UNKNOWN",
Build.MODEL ?: "UNKNOWN",
"Android",
Build.VERSION.SDK_INT.toString(),
false,
token.let { MessagingService.generateAppData(it) }
)
try {
integrationUseCase.registerDevice(deviceRegistration)
view.deviceRegistered()
} catch (e: Exception) {
Log.e(TAG, "Error with registering application", e)
view.showError()
}
}
}
instanceId.addOnFailureListener {
Log.e(TAG, "Couldn't get FirebaseInstanceId", it)
view.showError()
}
}
override fun onGrantedLocationPermission(context: Context, activity: Activity) {

View file

@ -1,9 +1,16 @@
package io.homeassistant.companion.android.launch
import com.google.android.gms.tasks.OnSuccessListener
import com.google.firebase.iid.FirebaseInstanceId
import com.google.firebase.iid.InstanceIdResult
import io.homeassistant.companion.android.domain.authentication.AuthenticationUseCase
import io.homeassistant.companion.android.domain.authentication.SessionState
import io.homeassistant.companion.android.domain.integration.IntegrationUseCase
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.resetMain
@ -15,6 +22,27 @@ object LaunchPresenterImplSpec : Spek({
beforeEachTest {
Dispatchers.setMain(Dispatchers.Unconfined)
val onSuccessListener = slot<OnSuccessListener<InstanceIdResult>>()
val mockResults = mockk<InstanceIdResult> {
every { token } returns "ABC123"
}
mockkStatic(FirebaseInstanceId::class)
every { FirebaseInstanceId.getInstance() } returns mockk {
every { instanceId } returns mockk {
every { addOnSuccessListener(capture(onSuccessListener)) } answers {
onSuccessListener.captured.onSuccess(mockResults)
mockk {
every { result } returns mockResults
}
}
every { addOnFailureListener(any()) } returns mockk {
every { exception } returns Exception()
}
}
}
}
afterEachTest {
@ -23,8 +51,9 @@ object LaunchPresenterImplSpec : Spek({
describe("launch presenter") {
val authenticationUseCase by memoized { mockk<AuthenticationUseCase>() }
val integrationUseCase by memoized { mockk<IntegrationUseCase>() }
val view by memoized { mockk<LaunchView>(relaxUnitFun = true) }
val presenter by memoized { LaunchPresenterImpl(view, authenticationUseCase) }
val presenter by memoized { LaunchPresenterImpl(view, authenticationUseCase, integrationUseCase) }
describe("anonymous state") {
beforeEachTest {

View file

@ -1,15 +1,22 @@
package io.homeassistant.companion.android.onboarding.integration
import android.os.Build
import com.google.android.gms.tasks.OnSuccessListener
import com.google.firebase.iid.FirebaseInstanceId
import com.google.firebase.iid.InstanceIdResult
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.domain.integration.DeviceRegistration
import io.homeassistant.companion.android.domain.integration.IntegrationUseCase
import io.homeassistant.companion.android.notifications.MessagingService
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyAll
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
@ -20,6 +27,27 @@ object MobileAppIntegrationPresenterImplSpec : Spek({
beforeEachTest {
Dispatchers.setMain(Dispatchers.Unconfined)
val onSuccessListener = slot<OnSuccessListener<InstanceIdResult>>()
val mockResults = mockk<InstanceIdResult> {
every { token } returns "ABC123"
}
mockkStatic(FirebaseInstanceId::class)
every { FirebaseInstanceId.getInstance() } returns mockk {
every { instanceId } returns mockk {
every { addOnSuccessListener(capture(onSuccessListener)) } answers {
onSuccessListener.captured.onSuccess(mockResults)
mockk {
every { result } returns mockResults
}
}
every { addOnFailureListener(any()) } returns mockk {
every { exception } returns Exception()
}
}
}
}
afterEachTest {
@ -52,7 +80,7 @@ object MobileAppIntegrationPresenterImplSpec : Spek({
"Android",
Build.VERSION.SDK_INT.toString(),
false,
null
MessagingService.generateAppData("ABC123")
)
beforeEachTest {
coEvery { integrationUseCase.registerDevice(deviceRegistration) } just runs

View file

@ -9,9 +9,9 @@ buildscript {
maven { url "https://plugins.gradle.org/m2/" }
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.2'
classpath 'com.android.tools.build:gradle:3.5.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.2'
classpath 'com.google.gms:google-services:4.3.3'
classpath 'io.fabric.tools:gradle:1.31.2'
classpath "org.jlleitschuh.gradle:ktlint-gradle:9.1.1"
}

View file

@ -34,7 +34,25 @@ class IntegrationRepositoryImpl @Inject constructor(
authenticationRepository.buildBearerToken(),
createRegisterDeviceRequest(deviceRegistration)
)
persistDeviceRegistrationResponse(response)
}
override suspend fun updateRegistration(deviceRegistration: DeviceRegistration) {
val request = IntegrationRequest("update_registration", createRegisterDeviceRequest(deviceRegistration))
for (it in getUrls()) {
try {
if (integrationService.updateRegistration(it, request).isSuccessful) {
return
}
} catch (e: Exception) {
// Ignore failure until we are out of URLS to try!
}
}
throw IntegrationException()
}
private suspend fun persistDeviceRegistrationResponse(response: RegisterDeviceResponse) {
localStorage.putString(PREF_CLOUD_URL, response.cloudhookUrl)
localStorage.putString(PREF_REMOTE_UI_URL, response.remoteUiUrl)
localStorage.putString(PREF_SECRET, response.secret)

View file

@ -17,6 +17,12 @@ interface IntegrationService {
@Body request: RegisterDeviceRequest
): RegisterDeviceResponse
@POST
suspend fun updateRegistration(
@Url url: HttpUrl,
@Body request: IntegrationRequest
): Response<ResponseBody>
@POST
suspend fun updateLocation(
@Url url: HttpUrl,

View file

@ -1,19 +1,17 @@
package io.homeassistant.companion.android.data.integration
import com.fasterxml.jackson.annotation.JsonInclude
import java.util.Dictionary
import java.util.Objects
@JsonInclude(JsonInclude.Include.NON_NULL)
data class RegisterDeviceRequest(
var appId: String,
var appName: String,
var appVersion: String,
var deviceName: String,
var manufacturer: String,
var model: String,
var osName: String,
var osVersion: String,
var supportsEncryption: Boolean,
@JsonInclude(JsonInclude.Include.NON_NULL)
var appData: Dictionary<String, Objects>?
var appId: String?,
var appName: String?,
var appVersion: String?,
var deviceName: String?,
var manufacturer: String?,
var model: String?,
var osName: String?,
var osVersion: String?,
var supportsEncryption: Boolean?,
var appData: HashMap<String, String>?
)

View file

@ -91,6 +91,55 @@ object IntegrationRepositoryImplSpec : Spek({
}
}
describe("registerDevice") {
val deviceRegistration = DeviceRegistration(
"appId",
"appName",
"appVersion",
"deviceName",
"manufacturer",
"model",
"osName",
"osVersion",
false,
null
)
val registerDeviceRequest = RegisterDeviceRequest(
deviceRegistration.appId,
deviceRegistration.appName,
deviceRegistration.appVersion,
deviceRegistration.deviceName,
deviceRegistration.manufacturer,
deviceRegistration.model,
deviceRegistration.osName,
deviceRegistration.osVersion,
deviceRegistration.supportsEncryption,
deviceRegistration.appData
)
beforeEachTest {
coEvery {
integrationService.updateRegistration(any(), IntegrationRequest("update_registration", registerDeviceRequest))
} returns Response.success(null)
coEvery { authenticationRepository.getUrl() } returns URL("http://example.com")
coEvery { localStorage.getString("webhook_id") } returns "FGHIJ"
coEvery { localStorage.getString("cloud_url") } returns "http://best.com/hook/id"
coEvery { localStorage.getString("remote_ui_url") } returns "http://better.com"
runBlocking {
repository.updateRegistration(deviceRegistration)
}
}
it("should call the service") {
coVerify {
integrationService.updateRegistration(
"http://best.com/hook/id".toHttpUrl(),
IntegrationRequest("update_registration", registerDeviceRequest))
}
}
}
describe("is registered") {
beforeEachTest {
coEvery { localStorage.getString("webhook_id") } returns "FGHIJ"

View file

@ -1,17 +1,14 @@
package io.homeassistant.companion.android.domain.integration
import java.util.Dictionary
import java.util.Objects
data class DeviceRegistration(
val appId: String,
val appName: String,
val appVersion: String,
val deviceName: String,
val manufacturer: String,
val model: String,
val osName: String,
val osVersion: String,
val supportsEncryption: Boolean,
val appData: Dictionary<String, Objects>?
val appId: String? = null,
val appName: String? = null,
val appVersion: String? = null,
val deviceName: String? = null,
val manufacturer: String? = null,
val model: String? = null,
val osName: String? = null,
val osVersion: String? = null,
val supportsEncryption: Boolean? = null,
val appData: HashMap<String, String>? = null
)

View file

@ -3,6 +3,7 @@ package io.homeassistant.companion.android.domain.integration
interface IntegrationRepository {
suspend fun registerDevice(deviceRegistration: DeviceRegistration)
suspend fun updateRegistration(deviceRegistration: DeviceRegistration)
suspend fun isRegistered(): Boolean

View file

@ -3,6 +3,14 @@ package io.homeassistant.companion.android.domain.integration
interface IntegrationUseCase {
suspend fun registerDevice(deviceRegistration: DeviceRegistration)
suspend fun updateRegistration(
appVersion: String,
deviceName: String,
manufacturer: String,
model: String,
osVersion: String,
appData: HashMap<String, String>
)
suspend fun isRegistered(): Boolean
@ -11,10 +19,8 @@ interface IntegrationUseCase {
suspend fun getZones(): Array<Entity<ZoneAttributes>>
suspend fun setZoneTrackingEnabled(enabled: Boolean)
suspend fun isZoneTrackingEnabled(): Boolean
suspend fun setBackgroundTrackingEnabled(enabled: Boolean)
suspend fun isBackgroundTrackingEnabled(): Boolean
}

View file

@ -9,6 +9,31 @@ class IntegrationUseCaseImpl @Inject constructor(
integrationRepository.registerDevice(deviceRegistration)
}
override suspend fun updateRegistration(
appVersion: String,
deviceName: String,
manufacturer: String,
model: String,
osVersion: String,
appData: HashMap<String, String>
) {
integrationRepository.updateRegistration(
DeviceRegistration(
null,
null,
appVersion,
deviceName,
manufacturer,
model,
null,
osVersion,
null,
appData
)
)
}
override suspend fun isRegistered(): Boolean {
return integrationRepository.isRegistered()
}

View file

@ -39,6 +39,34 @@ object IntegrationUseCaseImplSpec : Spek({
}
}
describe("updateRegistration") {
beforeEachTest {
coEvery {
integrationRepository.updateRegistration(any())
} just Runs
runBlocking {
useCase.updateRegistration("1", "2", "3", "4", "5", hashMapOf())
}
}
it("should call repository") {
coVerify {
integrationRepository.updateRegistration(DeviceRegistration(
null,
null,
"1",
"2",
"3",
"4",
null,
"5",
null,
hashMapOf()))
}
}
}
describe("isRegistered") {
beforeEachTest {
runBlocking { useCase.isRegistered() }