diff --git a/README.md b/README.md index 81827ad..eb0ebc9 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,15 @@ Example usage with [Lifecycle from architecture components](https://developer.an ``` class HomeConnectAuthorizationFragment : Fragment(R.layout.fragment_home_connect_authorization) { + private var homeConnectAuthorization: HomeConnectAuthorization? = null + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val webView = view.findViewById(R.id.web_view) viewLifecycleOwner.lifecycleScope.launch { try { - HomeConnectAuthorization.authorize(webView, onRequestAccessTokenStarted = ::showLoadingIndicator) + homeConnectAuthorization = HomeConnectAuthorization() + homeConnectAuthorization.authorize(webView, savedInstanceState, onRequestAccessTokenStarted = ::showLoadingIndicator) hideLoadingIndicator() // user is authorized now } catch (e: Exception) { @@ -69,6 +72,17 @@ class HomeConnectAuthorizationFragment : Fragment(R.layout.fragment_home_connect } } + override fun onSaveInstanceState(outState: Bundle) { + val webView = requireView().findViewById(R.id.web_view) + homeConnectAuthorization?.saveInstanceState(webView, outState) + super.onSaveInstanceState(outState) + } + + override fun onDestroyView() { + super.onDestroyView() + homeConnectAuthorization = null + } + } ``` diff --git a/app/src/main/java/de/kitchenstories/homeconnect/MainActivity.kt b/app/src/main/java/de/kitchenstories/homeconnect/MainActivity.kt index bb1aa7e..1507e25 100644 --- a/app/src/main/java/de/kitchenstories/homeconnect/MainActivity.kt +++ b/app/src/main/java/de/kitchenstories/homeconnect/MainActivity.kt @@ -1,5 +1,6 @@ package de.kitchenstories.homeconnect +import android.app.Dialog import android.os.Bundle import android.util.Log import android.view.View @@ -20,6 +21,8 @@ import com.ajnsnewmedia.kitchenstories.homeconnect.model.programs.StartProgramRe import com.ajnsnewmedia.kitchenstories.homeconnect.sdk.HomeConnectAuthorization import com.ajnsnewmedia.kitchenstories.homeconnect.sdk.DefaultHomeConnectClient import com.ajnsnewmedia.kitchenstories.homeconnect.sdk.HomeConnectClient +import com.ajnsnewmedia.kitchenstories.homeconnect.util.HomeConnectError +import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel @@ -29,19 +32,21 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { private val homeConnectSecretsStore by lazy { MyTestHomeConnectSecretsStore(applicationContext) } - // private val baseUrl = "https://api.home-connect.com/" - private val baseUrl = "https://simulator.home-connect.com/" + private val baseUrl = "https://api.home-connect.com/" + //private val baseUrl = "https://simulator.home-connect.com/" private lateinit var homeConnectAuthenticateWebview: WebView private lateinit var ovenControls: ViewGroup private lateinit var temperatureInput: EditText private val credentials = HomeConnectClientCredentials( - clientId = BuildConfig.homeConnectClientId, - clientSecret = BuildConfig.homeConnectClientSecret, + clientId = BuildConfig.homeConnectClientId, + clientSecret = BuildConfig.homeConnectClientSecret, ) private lateinit var homeConnectClient: HomeConnectClient + private var homeConnectAuthorization: HomeConnectAuthorization? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) @@ -52,20 +57,46 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { if (homeConnectSecretsStore.accessToken != null) { showOvenControls() } else { + val activity = this launch { try { - HomeConnectAuthorization.authorize(homeConnectAuthenticateWebview, onRequestAccessTokenStarted = {}) + homeConnectAuthorization = HomeConnectAuthorization() + homeConnectAuthorization?.authorize(homeConnectAuthenticateWebview, + savedInstanceState, + onRequestAccessTokenStarted = {}) showOvenControls() } catch (e: Throwable) { + if (e is HomeConnectError.UserAbortedAuthorization){ + Toast.makeText(activity,"All is fine, the user aborted ${e.javaClass.canonicalName}:'${e.message}' ", Toast.LENGTH_LONG).show() + activity.finish() + return@launch + } + val message = if (e is HomeConnectError && e.message != null) { + "The error description ist \"${e.message}\"" + } else { + "something else failed. \"${e.localizedMessage}\"" + } + MaterialAlertDialogBuilder(activity) + .setTitle("Something went wrong") + .setMessage(message) + .setPositiveButton("OK") { _, _ -> } + .create() + .show() Log.e("SampleApp", "authorization failed", e) } } } } + override fun onSaveInstanceState(outState: Bundle) { + homeConnectAuthorization?.saveInstanceState(homeConnectAuthenticateWebview, outState) + super.onSaveInstanceState(outState) + } + override fun onDestroy() { super.onDestroy() cancel() + homeConnectAuthorization = null } private fun showOvenControls() { @@ -100,17 +131,15 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { launch { homeConnectClient.startProgram( - forApplianceId = oven.id, - program = StartProgramRequest( - key = program, - options = listOf( - StartProgramOption( - key = ProgramOptionKey.SetpointTemperature, - value = enteredTemperature, - unit = "°C", - ) + forApplianceId = oven.id, + program = StartProgramRequest( + key = program, + options = listOf(StartProgramOption( + key = ProgramOptionKey.SetpointTemperature, + value = enteredTemperature, + unit = "°C", + )), ), - ), ) } } diff --git a/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/sdk/DefaultHomeConnectClient.kt b/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/sdk/DefaultHomeConnectClient.kt index 73c1127..80fbae0 100644 --- a/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/sdk/DefaultHomeConnectClient.kt +++ b/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/sdk/DefaultHomeConnectClient.kt @@ -3,6 +3,7 @@ package com.ajnsnewmedia.kitchenstories.homeconnect.sdk import com.ajnsnewmedia.kitchenstories.homeconnect.model.auth.HomeConnectClientCredentials import com.ajnsnewmedia.kitchenstories.homeconnect.util.DefaultErrorHandler import com.ajnsnewmedia.kitchenstories.homeconnect.util.DefaultHomeConnectApiFactory +import kotlinx.coroutines.Dispatchers class DefaultHomeConnectClient( baseUrl: String, @@ -14,6 +15,7 @@ class DefaultHomeConnectClient( }, homeConnectSecretsStore, DefaultErrorHandler(), + Dispatchers.IO, ) { init { AuthorizationDependencies.baseUrl = baseUrl diff --git a/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/sdk/DefaultHomeConnectInteractor.kt b/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/sdk/DefaultHomeConnectInteractor.kt index d40f00e..d365975 100644 --- a/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/sdk/DefaultHomeConnectInteractor.kt +++ b/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/sdk/DefaultHomeConnectInteractor.kt @@ -4,16 +4,27 @@ import android.util.Log import com.ajnsnewmedia.kitchenstories.homeconnect.HomeConnectApi import com.ajnsnewmedia.kitchenstories.homeconnect.model.appliances.HomeAppliance import com.ajnsnewmedia.kitchenstories.homeconnect.model.appliances.HomeApplianceType +import com.ajnsnewmedia.kitchenstories.homeconnect.model.base.HomeConnectApiError +import com.ajnsnewmedia.kitchenstories.homeconnect.model.base.HomeConnectApiErrorResponse import com.ajnsnewmedia.kitchenstories.homeconnect.model.base.HomeConnectApiRequest +import com.ajnsnewmedia.kitchenstories.homeconnect.model.jsonadapters.HomeConnectMoshiBuilder import com.ajnsnewmedia.kitchenstories.homeconnect.model.programs.AvailableProgram import com.ajnsnewmedia.kitchenstories.homeconnect.model.programs.StartProgramRequest import com.ajnsnewmedia.kitchenstories.homeconnect.util.ErrorHandler import com.ajnsnewmedia.kitchenstories.homeconnect.util.HomeConnectApiFactory +import com.ajnsnewmedia.kitchenstories.homeconnect.util.HomeConnectError +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import okio.Buffer +import retrofit2.HttpException internal class DefaultHomeConnectInteractor( - private val homeConnectApiFactory: HomeConnectApiFactory, - private val homeConnectSecretsStore: HomeConnectSecretsStore, - private val errorHandler: ErrorHandler, + private val homeConnectApiFactory: HomeConnectApiFactory, + private val homeConnectSecretsStore: HomeConnectSecretsStore, + private val errorHandler: ErrorHandler, + private val ioDispatcher: CoroutineDispatcher, ) : HomeConnectClient { private val homeConnectApi: HomeConnectApi by lazy { homeConnectApiFactory.getHomeConnectApi() } @@ -50,12 +61,28 @@ internal class DefaultHomeConnectInteractor( override suspend fun startProgram(forApplianceId: String, program: StartProgramRequest) { try { - homeConnectApi.startProgram(forApplianceId, HomeConnectApiRequest(program)) + val response = homeConnectApi.startProgram(forApplianceId, HomeConnectApiRequest(program)) + if (!response.isSuccessful) { + val errorDescription = response.errorBody()?.tryParsingApiError()?.description + throw HomeConnectError.Unspecified(message = errorDescription ?: "", cause = HttpException(response)) + } } catch (e: Throwable) { Log.e("HomeConnectApi", "starting a program failed", e) errorHandler.handle(e) } } + private suspend fun ResponseBody.tryParsingApiError(): HomeConnectApiError? { + val error = try { + val errorJsonAdapter = HomeConnectMoshiBuilder.moshiInstance.adapter(HomeConnectApiErrorResponse::class.java) + withContext(ioDispatcher) { + errorJsonAdapter.fromJson(Buffer().readFrom(this@tryParsingApiError.byteStream())) + } + } catch (e: Throwable) { + null + } + return error?.error + } + } diff --git a/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/sdk/HomeConnectAuthorization.kt b/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/sdk/HomeConnectAuthorization.kt index 2d0bd34..e7f37d6 100644 --- a/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/sdk/HomeConnectAuthorization.kt +++ b/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/sdk/HomeConnectAuthorization.kt @@ -2,6 +2,9 @@ package com.ajnsnewmedia.kitchenstories.homeconnect.sdk import android.annotation.SuppressLint import android.net.Uri +import android.os.Bundle +import android.os.Parcel +import android.os.Parcelable import android.util.Log import android.webkit.WebChromeClient import android.webkit.WebResourceRequest @@ -15,6 +18,7 @@ import com.ajnsnewmedia.kitchenstories.homeconnect.model.jsonadapters.HomeConnec import com.ajnsnewmedia.kitchenstories.homeconnect.util.DefaultErrorHandler import com.ajnsnewmedia.kitchenstories.homeconnect.util.DefaultTimeProvider import com.ajnsnewmedia.kitchenstories.homeconnect.util.HomeConnectApiFactory +import com.ajnsnewmedia.kitchenstories.homeconnect.util.HomeConnectAuthorizationState import com.ajnsnewmedia.kitchenstories.homeconnect.util.HomeConnectError import com.squareup.moshi.JsonReader import kotlinx.coroutines.suspendCancellableCoroutine @@ -33,57 +37,81 @@ internal object AuthorizationDependencies { } +private const val AUTHORIZATION_SAVED_STATE = "AUTHORIZATION_SAVED_STATE" + // TODO move testable code without web view dependency somewhere else and write tests // TODO make sure that no memory leaks happen here -object HomeConnectAuthorization { +class HomeConnectAuthorization { + + private var authorizationCode: String? = null /** - * @param onRequestAccessTokenStarted This callback will be triggered when the user has given his consent and the request for the initial - * access token is ongoing. Use this to e.g. show a loading indicator to keep the user informed about the progress. + * @param onRequestAccessTokenStarted This callback will be triggered when the user went through the web authorization flow, has given + * their consent and the request for the initial access token is ongoing. Use this to e.g. show a loading indicator to keep the user + * informed about the progress. */ - suspend fun authorize(webView: WebView, onRequestAccessTokenStarted: () -> Unit) { - val authorizationCode = initWebAuthorization(webView) - loadAccessToken(authorizationCode, onRequestAccessTokenStarted) + suspend fun authorize(webView: WebView, savedInstanceState: Bundle?, onRequestAccessTokenStarted: () -> Unit) { + val savedState: HomeConnectAuthorizationState? = savedInstanceState?.getParcelable(AUTHORIZATION_SAVED_STATE) + if (savedState?.authorizationCode != null) { + loadAccessToken(savedState.authorizationCode, onRequestAccessTokenStarted) + } else { + val authorizationCode = initWebAuthorization(webView, savedState?.webViewState) + loadAccessToken(authorizationCode, onRequestAccessTokenStarted) + } + } + + fun saveInstanceState(webView: WebView, outState: Bundle) { + val webViewState = Bundle(1) + webView.saveState(webViewState) + outState.putParcelable(AUTHORIZATION_SAVED_STATE, HomeConnectAuthorizationState(webViewState, authorizationCode)) } @SuppressLint("SetJavaScriptEnabled") - private suspend fun initWebAuthorization(webView: WebView): String = suspendCancellableCoroutine { continuation -> - webView.settings.javaScriptEnabled = true - webView.webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - if (url != null && url.startsWith("https://apiclient.home-connect.com/o2c.html")) { - val uri = Uri.parse(url) - val authorizationCode = uri.parseAuthorizationCode() - if (authorizationCode != null) { - continuation.resume(authorizationCode) - } else { - val errorDescription = uri.parseErrorDescription() - continuation.resumeWithException(HomeConnectError.Unspecified(errorDescription, null)) + private suspend fun initWebAuthorization(webView: WebView, webViewState: Bundle?): String = + suspendCancellableCoroutine { continuation -> + webView.settings.javaScriptEnabled = true + webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + if (url != null && url.startsWith("https://apiclient.home-connect.com/o2c.html")) { + val uri = Uri.parse(url) + val authorizationCode = uri.parseAuthorizationCode() + this@HomeConnectAuthorization.authorizationCode = authorizationCode + if (authorizationCode != null) { + continuation.resume(authorizationCode) + } else { + val errorDescription = uri.parseErrorDescription() + val error = uri.parseError() + continuation.resumeWithException(HomeConnectError.getExceptionFromError(error, errorDescription, cause = null)) + } + } } - } - } - override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) { - super.onReceivedHttpError(view, request, errorResponse) - if (request != null && request.isForMainFrame) { - val errorDescription = errorResponse?.data?.parseErrorDescription() - continuation.resumeWithException(HomeConnectError.Unspecified(errorDescription, null)) + override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) { + super.onReceivedHttpError(view, request, errorResponse) + if (request != null && request.isForMainFrame) { + val errorDescription = errorResponse?.data?.parseErrorDescription() + continuation.resumeWithException(HomeConnectError.Unspecified(errorDescription, null)) + } + } } - } - } - webView.webChromeClient = object : WebChromeClient() {} + webView.webChromeClient = object : WebChromeClient() {} - val baseUrl = AuthorizationDependencies.baseUrl - val credentials = AuthorizationDependencies.credentials - val authUrl = "${baseUrl}security/oauth/authorize?client_id=${credentials.clientId}&response_type=code" - webView.loadUrl(authUrl) + if (webViewState != null) { + webView.restoreState(webViewState) + } else { + val baseUrl = AuthorizationDependencies.baseUrl + val credentials = AuthorizationDependencies.credentials + val authUrl = "${baseUrl}security/oauth/authorize?client_id=${credentials.clientId}&response_type=code" + webView.loadUrl(authUrl) + } - continuation.invokeOnCancellation { - webView.webChromeClient = null - webView.stopLoading() - } - } + continuation.invokeOnCancellation { + webView.webChromeClient = null + webView.stopLoading() + webView.destroy() + } + } private suspend fun loadAccessToken(authorizationCode: String, onRequestAccessTokenStarted: () -> Unit) { onRequestAccessTokenStarted() @@ -112,6 +140,8 @@ object HomeConnectAuthorization { */ private fun Uri.parseErrorDescription() = this.getQueryParameter("error_description")?.let { URLDecoder.decode(it, "UTF-8") } + private fun Uri.parseError() = this.getQueryParameter("error")?.let { URLDecoder.decode(it, "UTF-8") } + /** * Parses the error description from the json response after an HTTP error is encountered during web authorization flow */ diff --git a/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/HomeConnectAuthorizationState.kt b/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/HomeConnectAuthorizationState.kt new file mode 100644 index 0000000..d7aca9b --- /dev/null +++ b/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/HomeConnectAuthorizationState.kt @@ -0,0 +1,33 @@ +package com.ajnsnewmedia.kitchenstories.homeconnect.util + +import android.os.Bundle +import android.os.Parcel +import android.os.Parcelable + +internal data class HomeConnectAuthorizationState( + val webViewState: Bundle?, + val authorizationCode: String?, +) : Parcelable { + + constructor(parcel: Parcel) : this(parcel.readBundle(Bundle::class.java.classLoader), parcel.readString()) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeBundle(webViewState) + parcel.writeString(authorizationCode) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): HomeConnectAuthorizationState { + return HomeConnectAuthorizationState(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + +} \ No newline at end of file diff --git a/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/HomeConnectError.kt b/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/HomeConnectError.kt index c4a0143..8a5d300 100644 --- a/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/HomeConnectError.kt +++ b/home-connect/src/main/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/HomeConnectError.kt @@ -1,6 +1,6 @@ package com.ajnsnewmedia.kitchenstories.homeconnect.util -sealed class HomeConnectError(message: String? = null, cause: Throwable? = null) : Throwable(message, cause) { +sealed class HomeConnectError(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { object Network : HomeConnectError("Network error encountered") @@ -10,4 +10,32 @@ sealed class HomeConnectError(message: String? = null, cause: Throwable? = null) class Unspecified(message: String?, cause: Throwable?) : HomeConnectError(message, cause) + abstract class UserAbortedAuthorization(errorDescription: String?): HomeConnectError("User aborted the login: '${errorDescription}'") + + /** + * the user pressed cancel after logging in when reviewing the app permissions + */ + class UserAbortedAuthorizationWhileGrantingPermissision(errorDescription: String?): UserAbortedAuthorization(errorDescription) + + /** + * The user pressed cancel on the username and password entry page + */ + class UserAbortedAuthorizationOnLogin(errorDescription: String?): UserAbortedAuthorization(errorDescription) + + companion object{ + fun getExceptionFromError(errorString: String?, errorDescription: String?, cause: Throwable?): HomeConnectError { + //documentation from https://developer.home-connect.com/docs/authorization/authorizationerrors + return if (errorString == "access_denied"){ + if (errorDescription == "login aborted by the user"){ + UserAbortedAuthorizationOnLogin(errorDescription) + } else if (errorDescription == "grant operation aborted by the user") { + UserAbortedAuthorizationWhileGrantingPermissision(errorDescription) + }else { + Unspecified(errorDescription, cause = cause) + } + } else { + Unspecified(errorDescription, cause = cause) + } + } + } } diff --git a/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/DefaultHomeConnectInteractorTest.kt b/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/DefaultHomeConnectInteractorTest.kt index 17aa236..3745c33 100644 --- a/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/DefaultHomeConnectInteractorTest.kt +++ b/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/DefaultHomeConnectInteractorTest.kt @@ -3,31 +3,41 @@ package com.ajnsnewmedia.kitchenstories.homeconnect import com.ajnsnewmedia.kitchenstories.homeconnect.model.appliances.HomeApplianceType import com.ajnsnewmedia.kitchenstories.homeconnect.model.appliances.HomeAppliancesData import com.ajnsnewmedia.kitchenstories.homeconnect.model.auth.HomeConnectAccessToken +import com.ajnsnewmedia.kitchenstories.homeconnect.model.base.HomeConnectApiError import com.ajnsnewmedia.kitchenstories.homeconnect.model.base.HomeConnectApiRequest import com.ajnsnewmedia.kitchenstories.homeconnect.model.base.HomeConnectApiResponse import com.ajnsnewmedia.kitchenstories.homeconnect.model.programs.AvailableProgramsData import com.ajnsnewmedia.kitchenstories.homeconnect.model.programs.ProgramKey -import com.ajnsnewmedia.kitchenstories.homeconnect.model.programs.StartProgramRequest import com.ajnsnewmedia.kitchenstories.homeconnect.sdk.DefaultHomeConnectInteractor import com.ajnsnewmedia.kitchenstories.homeconnect.sdk.HomeConnectSecretsStore import com.ajnsnewmedia.kitchenstories.homeconnect.testdata.testAvailableProgram import com.ajnsnewmedia.kitchenstories.homeconnect.testdata.testHomeAppliance import com.ajnsnewmedia.kitchenstories.homeconnect.testdata.testStartProgramRequest +import com.ajnsnewmedia.kitchenstories.homeconnect.util.CoroutinesTestRule import com.ajnsnewmedia.kitchenstories.homeconnect.util.ErrorHandler import com.ajnsnewmedia.kitchenstories.homeconnect.util.HomeConnectApiFactory +import com.ajnsnewmedia.kitchenstories.homeconnect.util.HomeConnectError +import com.ajnsnewmedia.kitchenstories.homeconnect.util.TestErrorHandler +import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.test.runBlockingTest +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test import org.mockito.Mock import org.mockito.MockitoAnnotations +import retrofit2.Response class DefaultHomeConnectInteractorTest { + @get:Rule + val coroutinesTestRule = CoroutinesTestRule() + // @Mock @@ -39,8 +49,7 @@ class DefaultHomeConnectInteractorTest { @Mock private lateinit var homeConnectSecretsStore: HomeConnectSecretsStore - @Mock - private lateinit var errorHandler: ErrorHandler + private lateinit var testErrorHandler: TestErrorHandler // @@ -49,10 +58,16 @@ class DefaultHomeConnectInteractorTest { @Before fun setUp() { MockitoAnnotations.initMocks(this) + testErrorHandler = TestErrorHandler() whenever(homeConnectApiFactory.getHomeConnectApi()).thenReturn(homeConnectApi) - interactor = DefaultHomeConnectInteractor(homeConnectApiFactory, homeConnectSecretsStore, errorHandler) + interactor = DefaultHomeConnectInteractor( + homeConnectApiFactory, + homeConnectSecretsStore, + testErrorHandler, + coroutinesTestRule.testDispatcher, + ) } // @@ -119,10 +134,20 @@ class DefaultHomeConnectInteractorTest { @Test fun `startProgram delegates to HomeConnectApi`() = runBlockingTest { + whenever(homeConnectApi.startProgram(any(), any())).thenReturn(Response.success(null)) interactor.startProgram(forApplianceId = "appliance_id", testStartProgramRequest) verify(homeConnectApi).startProgram(forApplianceId = "appliance_id", HomeConnectApiRequest(testStartProgramRequest)) } + @Test + fun `startProgram throws unspecified HomeConnectError when response is not successful`() = runBlockingTest { + whenever(homeConnectApi.startProgram(any(), any())).thenReturn(Response.error(400, "".toResponseBody())) + + verifyThrowingSuspended(action = { interactor.startProgram(forApplianceId = "appliance_id", testStartProgramRequest) }) { error -> + assertTrue(error is HomeConnectError.Unspecified) + } + } + } \ No newline at end of file diff --git a/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/HomeConnectInterceptorTest.kt b/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/HomeConnectInterceptorTest.kt index 10bf12d..5460fc5 100644 --- a/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/HomeConnectInterceptorTest.kt +++ b/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/HomeConnectInterceptorTest.kt @@ -1,8 +1,7 @@ package com.ajnsnewmedia.kitchenstories.homeconnect -import com.ajnsnewmedia.kitchenstories.homeconnect.sdk.HomeConnectSecretsStore import com.ajnsnewmedia.kitchenstories.homeconnect.model.auth.HomeConnectAccessToken -import com.ajnsnewmedia.kitchenstories.homeconnect.util.HomeConnectError +import com.ajnsnewmedia.kitchenstories.homeconnect.sdk.HomeConnectSecretsStore import com.ajnsnewmedia.kitchenstories.homeconnect.util.HomeConnectInternalError import com.ajnsnewmedia.kitchenstories.homeconnect.util.TimeProvider import com.nhaarman.mockitokotlin2.never diff --git a/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/TestHelpers.kt b/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/TestHelpers.kt index f38b3a7..5fa0d74 100644 --- a/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/TestHelpers.kt +++ b/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/TestHelpers.kt @@ -11,5 +11,14 @@ fun verifyThrowing(action: () -> Unit, verifyError: (error: Throwable) -> Unit) verifyError(e) } } +suspend fun verifyThrowingSuspended(action: suspend () -> Unit, verifyError: (error: Throwable) -> Unit) { + try { + action() + throw TestThrowingInternalException() + } catch (e: Throwable) { + assertFalse("action did not throw an exception", e is TestThrowingInternalException) + verifyError(e) + } +} private class TestThrowingInternalException : Exception() \ No newline at end of file diff --git a/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/CoroutinesTestRule.kt b/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/CoroutinesTestRule.kt new file mode 100644 index 0000000..4dd16ad --- /dev/null +++ b/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/CoroutinesTestRule.kt @@ -0,0 +1,29 @@ +package com.ajnsnewmedia.kitchenstories.homeconnect.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class CoroutinesTestRule : TestRule { + + val testDispatcher = TestCoroutineDispatcher() + + override fun apply(base: Statement, description: Description?): Statement { + return object : Statement() { + override fun evaluate() { + Dispatchers.setMain(testDispatcher) + try { + base.evaluate() + } finally { + Dispatchers.resetMain() + testDispatcher.cleanupTestCoroutines() + } + } + } + } + +} \ No newline at end of file diff --git a/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/TestErrorHandler.kt b/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/TestErrorHandler.kt new file mode 100644 index 0000000..28df7ca --- /dev/null +++ b/home-connect/src/test/java/com/ajnsnewmedia/kitchenstories/homeconnect/util/TestErrorHandler.kt @@ -0,0 +1,9 @@ +package com.ajnsnewmedia.kitchenstories.homeconnect.util + +class TestErrorHandler : ErrorHandler { + + override fun handle(error: Throwable): Nothing { + throw error + } + +} \ No newline at end of file