Skip to content
This repository has been archived by the owner on Feb 9, 2023. It is now read-only.

Commit

Permalink
Merge pull request #1 from KitchenStories/develop
Browse files Browse the repository at this point in the history
add proper state restoration to HomeConnectAuthorization
  • Loading branch information
falkorichter authored Dec 4, 2020
2 parents c184606 + 6aea5f2 commit 852513a
Show file tree
Hide file tree
Showing 12 changed files with 299 additions and 65 deletions.
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebView>(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) {
Expand All @@ -69,6 +72,17 @@ class HomeConnectAuthorizationFragment : Fragment(R.layout.fragment_home_connect
}
}
override fun onSaveInstanceState(outState: Bundle) {
val webView = requireView().findViewById<WebView>(R.id.web_view)
homeConnectAuthorization?.saveInstanceState(webView, outState)
super.onSaveInstanceState(outState)
}
override fun onDestroyView() {
super.onDestroyView()
homeConnectAuthorization = null
}
}
```
Expand Down
59 changes: 44 additions & 15 deletions app/src/main/java/de/kitchenstories/homeconnect/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.kitchenstories.homeconnect

import android.app.Dialog
import android.os.Bundle
import android.util.Log
import android.view.View
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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() {
Expand Down Expand Up @@ -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",
)),
),
),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,6 +15,7 @@ class DefaultHomeConnectClient(
},
homeConnectSecretsStore,
DefaultErrorHandler(),
Dispatchers.IO,
) {
init {
AuthorizationDependencies.baseUrl = baseUrl
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -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
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
*/
Expand Down
Loading

0 comments on commit 852513a

Please sign in to comment.