mirror of
https://github.com/home-assistant/android
synced 2024-10-02 22:34:46 +00:00
Update Wear onboarding to always use phone for sign in (#2838)
* Always do Wear onboarding on phone - Switch the Wear onboarding to always use the sign in flow on the paired phone, instead of offering the option to sign in completely on the watch. - When opening the companion app using the Wear app, immediately link the user to the list of instances or sign in screen. - Use standard OAuth flow for devices without companion app installed. - Fix type casting of location preference in Wear onboarding result. * Fix ListenableFuture await import
This commit is contained in:
parent
dab3640c6b
commit
a711c659a4
|
@ -173,12 +173,12 @@ class SettingsWearActivity : AppCompatActivity(), CapabilityClient.OnCapabilityC
|
|||
}
|
||||
wearNodesWithApp.size < allConnectedNodes.size -> {
|
||||
Log.d(TAG, "Installed on some devices")
|
||||
startActivity(SettingsWearMainView.newInstance(applicationContext, wearNodesWithApp))
|
||||
startActivity(SettingsWearMainView.newInstance(applicationContext, wearNodesWithApp, getAuthIntentUrl()))
|
||||
finish()
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Installed on all devices")
|
||||
startActivity(SettingsWearMainView.newInstance(applicationContext, wearNodesWithApp))
|
||||
startActivity(SettingsWearMainView.newInstance(applicationContext, wearNodesWithApp, getAuthIntentUrl()))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
@ -226,6 +226,17 @@ class SettingsWearActivity : AppCompatActivity(), CapabilityClient.OnCapabilityC
|
|||
}
|
||||
}
|
||||
|
||||
private fun getAuthIntentUrl(): String? {
|
||||
return intent.data?.let {
|
||||
if (it.scheme == "homeassistant" && it.host == "wear-phone-signin") {
|
||||
// Return empty string if phone sign in was used to open this, indicating no instance selected
|
||||
it.getQueryParameter("url") ?: ""
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SettingsWearAct"
|
||||
|
||||
|
|
|
@ -23,6 +23,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import io.homeassistant.companion.android.HomeAssistantApplication
|
||||
import io.homeassistant.companion.android.common.data.integration.Entity
|
||||
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.burnoutcrew.reorderable.ItemPosition
|
||||
import javax.inject.Inject
|
||||
|
@ -50,10 +52,10 @@ class SettingsWearViewModel @Inject constructor(
|
|||
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
|
||||
var hasData = mutableStateOf(false)
|
||||
private set
|
||||
var isAuthenticated = mutableStateOf(false)
|
||||
private set
|
||||
private val _hasData = MutableStateFlow(false)
|
||||
val hasData = _hasData.asStateFlow()
|
||||
private val _isAuthenticated = MutableStateFlow(false)
|
||||
val isAuthenticated = _isAuthenticated.asStateFlow()
|
||||
var entities = mutableStateMapOf<String, Entity<*>>()
|
||||
private set
|
||||
var supportedDomains = mutableStateListOf<String>()
|
||||
|
@ -221,7 +223,7 @@ class SettingsWearViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun onLoadConfigFromWear(data: DataMap) {
|
||||
isAuthenticated.value = data.getBoolean(KEY_IS_AUTHENTICATED, false)
|
||||
_isAuthenticated.value = data.getBoolean(KEY_IS_AUTHENTICATED, false)
|
||||
val supportedDomainsList: List<String> =
|
||||
objectMapper.readValue(data.getString(KEY_SUPPORTED_DOMAINS, "[\"input_boolean\", \"light\", \"lock\", \"switch\", \"script\", \"scene\"]"))
|
||||
supportedDomains.clear()
|
||||
|
@ -234,6 +236,6 @@ class SettingsWearViewModel @Inject constructor(
|
|||
}
|
||||
setTemplateContent(data.getString(KEY_TEMPLATE_TILE, ""))
|
||||
templateTileRefreshInterval.value = data.getInt(KEY_TEMPLATE_TILE_REFRESH_INTERVAL, 0)
|
||||
hasData.value = true
|
||||
_hasData.value = true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ import android.net.Uri
|
|||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
|
@ -37,10 +39,13 @@ fun LoadSettingsHomeView(
|
|||
)
|
||||
}
|
||||
composable(SettingsWearMainView.LANDING) {
|
||||
val hasData by settingsWearViewModel.hasData.collectAsState()
|
||||
val isAuthenticated by settingsWearViewModel.isAuthenticated.collectAsState()
|
||||
|
||||
SettingWearLandingView(
|
||||
deviceName = deviceName,
|
||||
hasData = settingsWearViewModel.hasData.value,
|
||||
isAuthed = settingsWearViewModel.isAuthenticated.value,
|
||||
hasData = hasData,
|
||||
isAuthed = isAuthenticated,
|
||||
navigateFavorites = { navController.navigate(SettingsWearMainView.FAVORITES) },
|
||||
navigateTemplateTile = { navController.navigate(SettingsWearMainView.TEMPLATE) },
|
||||
loginWearOs = loginWearOs,
|
||||
|
|
|
@ -7,11 +7,14 @@ import android.util.Log
|
|||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.gms.wearable.Node
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
|
||||
import io.homeassistant.companion.android.onboarding.OnboardApp
|
||||
import io.homeassistant.companion.android.settings.wear.SettingsWearViewModel
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
|
@ -30,12 +33,14 @@ class SettingsWearMainView : AppCompatActivity() {
|
|||
companion object {
|
||||
private const val TAG = "SettingsWearDevice"
|
||||
private var currentNodes = setOf<Node>()
|
||||
private var registerUrl: String? = null
|
||||
const val LANDING = "Landing"
|
||||
const val FAVORITES = "Favorites"
|
||||
const val TEMPLATE = "Template"
|
||||
|
||||
fun newInstance(context: Context, wearNodes: Set<Node>): Intent {
|
||||
fun newInstance(context: Context, wearNodes: Set<Node>, url: String?): Intent {
|
||||
currentNodes = wearNodes
|
||||
registerUrl = url
|
||||
return Intent(context, SettingsWearMainView::class.java)
|
||||
}
|
||||
}
|
||||
|
@ -51,11 +56,23 @@ class SettingsWearMainView : AppCompatActivity() {
|
|||
this::onBackPressed
|
||||
)
|
||||
}
|
||||
|
||||
if (registerUrl != null) {
|
||||
lifecycleScope.launch {
|
||||
settingsWearViewModel.hasData.collect { hasData ->
|
||||
if (hasData) {
|
||||
if (!settingsWearViewModel.isAuthenticated.value) loginWearOs()
|
||||
this@launch.cancel() // Stop listening, we only need initial load
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginWearOs() {
|
||||
registerActivityResult.launch(
|
||||
OnboardApp.Input(
|
||||
url = registerUrl,
|
||||
defaultDeviceName = currentNodes.firstOrNull()?.displayName ?: "unknown",
|
||||
locationTrackingPossible = false
|
||||
)
|
||||
|
|
|
@ -10,16 +10,19 @@ import io.homeassistant.companion.android.BuildConfig
|
|||
class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>() {
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_URL = "extra_url"
|
||||
private const val EXTRA_DEFAULT_DEVICE_NAME = "extra_default_device_name"
|
||||
private const val EXTRA_LOCATION_TRACKING_POSSIBLE = "location_tracking_possible"
|
||||
|
||||
fun parseInput(intent: Intent): Input = Input(
|
||||
url = intent.getStringExtra(EXTRA_URL),
|
||||
defaultDeviceName = intent.getStringExtra(EXTRA_DEFAULT_DEVICE_NAME) ?: Build.MODEL,
|
||||
locationTrackingPossible = intent.getBooleanExtra(EXTRA_LOCATION_TRACKING_POSSIBLE, false),
|
||||
)
|
||||
}
|
||||
|
||||
data class Input(
|
||||
val url: String? = null,
|
||||
val defaultDeviceName: String = Build.MODEL,
|
||||
val locationTrackingPossible: Boolean = BuildConfig.FLAVOR == "full"
|
||||
)
|
||||
|
@ -42,6 +45,7 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
|
|||
|
||||
override fun createIntent(context: Context, input: Input): Intent {
|
||||
return Intent(context, OnboardingActivity::class.java).apply {
|
||||
putExtra(EXTRA_URL, input.url)
|
||||
putExtra(EXTRA_DEFAULT_DEVICE_NAME, input.defaultDeviceName)
|
||||
putExtra(EXTRA_LOCATION_TRACKING_POSSIBLE, input.locationTrackingPossible)
|
||||
}
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
package io.homeassistant.companion.android.onboarding
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.commit
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.homeassistant.companion.android.R
|
||||
import io.homeassistant.companion.android.onboarding.authentication.AuthenticationFragment
|
||||
import io.homeassistant.companion.android.onboarding.discovery.DiscoveryFragment
|
||||
import io.homeassistant.companion.android.onboarding.manual.ManualSetupFragment
|
||||
import io.homeassistant.companion.android.onboarding.welcome.WelcomeFragment
|
||||
|
||||
@AndroidEntryPoint
|
||||
|
@ -26,10 +31,32 @@ class OnboardingActivity : AppCompatActivity() {
|
|||
viewModel.locationTrackingPossible.value = input.locationTrackingPossible
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.add(R.id.content, WelcomeFragment::class.java, null)
|
||||
.commit()
|
||||
supportFragmentManager.commit {
|
||||
add(R.id.content, WelcomeFragment::class.java, null)
|
||||
}
|
||||
if (input.url != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.content, DiscoveryFragment::class.java, null)
|
||||
addToBackStack(null)
|
||||
}
|
||||
}
|
||||
if (input.url.isNotBlank() || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
viewModel.onManualUrlUpdated(input.url)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || !viewModel.manualContinueEnabled) {
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.content, ManualSetupFragment::class.java, null)
|
||||
addToBackStack(null)
|
||||
}
|
||||
}
|
||||
if (viewModel.manualContinueEnabled) {
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.content, AuthenticationFragment::class.java, null)
|
||||
addToBackStack(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,17 +1,7 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication
|
||||
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowResponse
|
||||
import java.net.URL
|
||||
|
||||
interface AuthenticationRepository {
|
||||
|
||||
suspend fun initiateLoginFlow(): LoginFlowForm
|
||||
|
||||
suspend fun loginAuthentication(flowId: String, username: String, password: String): LoginFlowResponse?
|
||||
|
||||
suspend fun loginCode(flowId: String, code: String): LoginFlowResponse?
|
||||
|
||||
suspend fun registerAuthorizationCode(authorizationCode: String)
|
||||
|
||||
suspend fun retrieveExternalAuthentication(forceRefresh: Boolean): String
|
||||
|
@ -23,7 +13,7 @@ interface AuthenticationRepository {
|
|||
|
||||
suspend fun getSessionState(): SessionState
|
||||
|
||||
suspend fun buildAuthenticationUrl(callbackUrl: String): URL
|
||||
suspend fun buildAuthenticationUrl(baseUrl: String, callbackUrl: String): String
|
||||
|
||||
suspend fun buildBearerToken(): String
|
||||
|
||||
|
|
|
@ -1,26 +1,13 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication.impl
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import io.homeassistant.companion.android.common.data.LocalStorage
|
||||
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
|
||||
import io.homeassistant.companion.android.common.data.authentication.AuthorizationException
|
||||
import io.homeassistant.companion.android.common.data.authentication.SessionState
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowAuthentication
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowCreateEntry
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowMfaCode
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowRequest
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowResponse
|
||||
import io.homeassistant.companion.android.common.data.url.UrlRepository
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.ResponseBody
|
||||
import java.net.URL
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
|
||||
|
@ -40,59 +27,6 @@ class AuthenticationRepositoryImpl @Inject constructor(
|
|||
private const val PREF_BIOMETRIC_HOME_BYPASS_ENABLED = "biometric_home_bypass_enabled"
|
||||
}
|
||||
|
||||
private val mapper = jacksonObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||
|
||||
override suspend fun initiateLoginFlow(): LoginFlowForm {
|
||||
val url = urlRepository.getUrl()?.toHttpUrlOrNull().toString()
|
||||
return authenticationService.initializeLogin(
|
||||
url + "auth/login_flow",
|
||||
LoginFlowRequest(
|
||||
AuthenticationService.CLIENT_ID,
|
||||
AuthenticationService.AUTH_CALLBACK,
|
||||
AuthenticationService.HANDLER
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun loginAuthentication(flowId: String, username: String, password: String): LoginFlowResponse? {
|
||||
val url = urlRepository.getUrl()?.toHttpUrlOrNull()
|
||||
if (url == null) {
|
||||
Log.e(TAG, "Unable to authenticate with username/password.")
|
||||
throw AuthorizationException()
|
||||
}
|
||||
|
||||
val response = authenticationService.authenticatePassword(
|
||||
url.newBuilder().addPathSegments(AuthenticationService.AUTHENTICATE_BASE_PATH + flowId).build(),
|
||||
LoginFlowAuthentication(
|
||||
AuthenticationService.CLIENT_ID,
|
||||
username,
|
||||
password
|
||||
)
|
||||
)
|
||||
if (!response.isSuccessful || response.body() == null) throw AuthorizationException()
|
||||
return mapLoginFlowResponse(response.body()!!)
|
||||
}
|
||||
|
||||
override suspend fun loginCode(flowId: String, code: String): LoginFlowResponse? {
|
||||
val url = urlRepository.getUrl()?.toHttpUrlOrNull()
|
||||
if (url == null) {
|
||||
Log.e(TAG, "Unable to authenticate with MFA code.")
|
||||
throw AuthorizationException()
|
||||
}
|
||||
|
||||
val response = authenticationService.authenticateMfa(
|
||||
url.newBuilder().addPathSegments(AuthenticationService.AUTHENTICATE_BASE_PATH + flowId).build(),
|
||||
LoginFlowMfaCode(
|
||||
AuthenticationService.CLIENT_ID,
|
||||
code
|
||||
)
|
||||
)
|
||||
if (!response.isSuccessful || response.body() == null) throw AuthorizationException()
|
||||
return mapLoginFlowResponse(response.body()!!)
|
||||
}
|
||||
|
||||
override suspend fun registerAuthorizationCode(authorizationCode: String) {
|
||||
val url = urlRepository.getUrl()?.toHttpUrlOrNull()
|
||||
if (url == null) {
|
||||
|
@ -154,33 +88,21 @@ class AuthenticationRepositoryImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun buildAuthenticationUrl(callbackUrl: String): URL {
|
||||
val url = urlRepository.getUrl()
|
||||
|
||||
return url!!.toHttpUrlOrNull()!!
|
||||
override suspend fun buildAuthenticationUrl(baseUrl: String, callbackUrl: String): String {
|
||||
return baseUrl.toHttpUrlOrNull()!!
|
||||
.newBuilder()
|
||||
.addPathSegments("auth/authorize")
|
||||
.addEncodedQueryParameter("response_type", "code")
|
||||
.addEncodedQueryParameter("client_id", AuthenticationService.CLIENT_ID)
|
||||
.addEncodedQueryParameter("redirect_uri", callbackUrl)
|
||||
.build()
|
||||
.toUrl()
|
||||
.toString()
|
||||
}
|
||||
|
||||
override suspend fun buildBearerToken(): String {
|
||||
return "Bearer " + ensureValidSession().accessToken
|
||||
}
|
||||
|
||||
private fun mapLoginFlowResponse(responseBody: ResponseBody): LoginFlowResponse? {
|
||||
val responseText = responseBody.charStream().readText()
|
||||
val message: JsonNode? = mapper.readValue(responseText)
|
||||
return when (message?.get("type")?.textValue()) {
|
||||
"form" -> mapper.readValue(responseText, LoginFlowForm::class.java)
|
||||
"create_entry" -> mapper.readValue(responseText, LoginFlowCreateEntry::class.java)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertSession(session: Session): String {
|
||||
return ObjectMapper().writeValueAsString(
|
||||
mapOf(
|
||||
|
|
|
@ -1,14 +1,8 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication.impl
|
||||
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowAuthentication
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowMfaCode
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowRequest
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.Token
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.Field
|
||||
import retrofit2.http.FormUrlEncoded
|
||||
import retrofit2.http.POST
|
||||
|
@ -21,9 +15,6 @@ interface AuthenticationService {
|
|||
const val GRANT_TYPE_CODE = "authorization_code"
|
||||
const val GRANT_TYPE_REFRESH = "refresh_token"
|
||||
const val REVOKE_ACTION = "revoke"
|
||||
val HANDLER = listOf("homeassistant", null)
|
||||
const val AUTHENTICATE_BASE_PATH = "auth/login_flow/"
|
||||
const val AUTH_CALLBACK = "homeassistant://auth-callback"
|
||||
}
|
||||
|
||||
@FormUrlEncoded
|
||||
|
@ -51,13 +42,4 @@ interface AuthenticationService {
|
|||
@Field("token") refreshToken: String,
|
||||
@Field("action") action: String
|
||||
)
|
||||
|
||||
@POST
|
||||
suspend fun initializeLogin(@Url url: String, @Body body: LoginFlowRequest): LoginFlowForm
|
||||
|
||||
@POST
|
||||
suspend fun authenticatePassword(@Url url: HttpUrl, @Body body: LoginFlowAuthentication): Response<ResponseBody>
|
||||
|
||||
@POST
|
||||
suspend fun authenticateMfa(@Url url: HttpUrl, @Body body: LoginFlowMfaCode): Response<ResponseBody>
|
||||
}
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication.impl.entities
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class LoginFlowAuthentication(
|
||||
@JsonProperty("client_id")
|
||||
val clientId: String,
|
||||
@JsonProperty("username")
|
||||
val userName: String,
|
||||
@JsonProperty("password")
|
||||
val password: String
|
||||
)
|
|
@ -1,14 +0,0 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication.impl.entities
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class LoginFlowCreateEntry(
|
||||
@JsonProperty("type")
|
||||
override val type: String,
|
||||
@JsonProperty("flow_id")
|
||||
override val flowId: String,
|
||||
@JsonProperty("version")
|
||||
val version: Int,
|
||||
@JsonProperty("result")
|
||||
val result: String
|
||||
) : LoginFlowResponse()
|
|
@ -1,14 +0,0 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication.impl.entities
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class LoginFlowForm(
|
||||
@JsonProperty("type")
|
||||
override val type: String,
|
||||
@JsonProperty("flow_id")
|
||||
override val flowId: String,
|
||||
@JsonProperty("step_id")
|
||||
val stepId: String,
|
||||
@JsonProperty("errors")
|
||||
val errors: Map<String, String>
|
||||
) : LoginFlowResponse()
|
|
@ -1,10 +0,0 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication.impl.entities
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class LoginFlowMfaCode(
|
||||
@JsonProperty("client_id")
|
||||
val clientId: String,
|
||||
@JsonProperty("code")
|
||||
val code: String
|
||||
)
|
|
@ -1,12 +0,0 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication.impl.entities
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class LoginFlowRequest(
|
||||
@JsonProperty("client_id")
|
||||
val clientId: String,
|
||||
@JsonProperty("redirect_uri")
|
||||
val redirectUri: String,
|
||||
@JsonProperty("handler")
|
||||
val handler: List<String?>
|
||||
)
|
|
@ -1,6 +0,0 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication.impl.entities
|
||||
|
||||
sealed class LoginFlowResponse {
|
||||
abstract val type: String
|
||||
abstract val flowId: String
|
||||
}
|
|
@ -213,7 +213,9 @@
|
|||
<string name="expand">Expand</string>
|
||||
<string name="failed_authentication">Could not authenticate</string>
|
||||
<string name="failed_connection">Could not connect</string>
|
||||
<string name="failed_phone_connection">Could not connect to phone</string>
|
||||
<string name="failed_registration">Could not register</string>
|
||||
<string name="failed_unsupported">Not supported</string>
|
||||
<string name="failed_scan">Scanning for Home Assistant failed.</string>
|
||||
<string name="failure_send_favorites_wear">Failed to send favorites selection to Wear OS</string>
|
||||
<string name="fans">Fans</string>
|
||||
|
@ -342,8 +344,6 @@
|
|||
<string name="message_missing_all">The Wear app is missing on your watch, click the button below to install the app.\n\nNote: Currently the Wear OS app requires you to be enrolled in the beta for the phone app. If the button does not work then please join the beta: https://play.google.com/apps/testing/io.homeassistant.companion.android</string>
|
||||
<string name="message_no_connected_nodes">No connected Wear devices, please make sure Bluetooth is on and your watch is paired.</string>
|
||||
<string name="message_some_installed">The Wear app is installed on some of your Wear devices: (%1$s)\n\nClick the button below to install the app on the other devices.\n\nNote: Currently the Wear OS app requires you to be enrolled in the beta for the phone app. If the button does not work then please join the beta: https://play.google.com/apps/testing/io.homeassistant.companion.android</string>
|
||||
<string name="mfa_title">Two-factor\nAuthentication</string>
|
||||
<string name="mfa_hint">Code</string>
|
||||
<string name="missing_command_permission">Please open the Home Assistant app and send the command again in order to grant the proper permissions. You will be taken to a page to either grant the Home Assistant app the permission, or you will need to select Permissions from the details page and then grant the missing permission. For command_bluetooth the name of the permission is Nearby devices. If you are attempting to use command_activity to make a phone call you will also need to grant Phone permissions.</string>
|
||||
<string name="areas">Areas</string>
|
||||
<string name="more_entities">More entities</string>
|
||||
|
|
|
@ -98,6 +98,7 @@ dependencies {
|
|||
implementation("com.google.android.gms:play-services-wearable:17.1.0")
|
||||
implementation("androidx.wear:wear-input:1.2.0-alpha02")
|
||||
implementation("androidx.wear:wear-remote-interactions:1.0.0")
|
||||
implementation("androidx.wear:wear-phone-interactions:1.0.1")
|
||||
compileOnly("com.google.android.wearable:wearable:2.9.0")
|
||||
|
||||
implementation("com.google.dagger:hilt-android:2.43.2")
|
||||
|
|
|
@ -52,7 +52,6 @@
|
|||
</activity>
|
||||
<activity android:name=".onboarding.OnboardingActivity" />
|
||||
<activity android:name=".onboarding.integration.MobileAppIntegrationActivity" />
|
||||
<activity android:name=".onboarding.authentication.AuthenticationActivity" />
|
||||
<activity android:name=".onboarding.manual_setup.ManualSetupActivity" />
|
||||
<activity android:name=".complications.ComplicationConfigActivity"
|
||||
android:exported="true">
|
||||
|
|
|
@ -6,7 +6,9 @@ import android.net.Uri
|
|||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.wear.activity.ConfirmationActivity
|
||||
import androidx.wear.remote.interactions.RemoteActivityHelper
|
||||
|
@ -18,9 +20,11 @@ import com.google.android.gms.wearable.DataMapItem
|
|||
import com.google.android.gms.wearable.Wearable
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.homeassistant.companion.android.R
|
||||
import io.homeassistant.companion.android.onboarding.authentication.AuthenticationActivity
|
||||
import io.homeassistant.companion.android.onboarding.integration.MobileAppIntegrationActivity
|
||||
import io.homeassistant.companion.android.onboarding.manual_setup.ManualSetupActivity
|
||||
import io.homeassistant.companion.android.util.LoadingView
|
||||
import kotlinx.coroutines.guava.await
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import io.homeassistant.companion.android.common.R as commonR
|
||||
|
||||
|
@ -44,6 +48,8 @@ class OnboardingActivity : AppCompatActivity(), OnboardingView {
|
|||
lateinit var presenter: OnboardingPresenter
|
||||
private lateinit var loadingView: LoadingView
|
||||
|
||||
private var phoneSignInAvailable = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
@ -52,9 +58,14 @@ class OnboardingActivity : AppCompatActivity(), OnboardingView {
|
|||
loadingView = findViewById(R.id.loading_view)
|
||||
|
||||
adapter = ServerListAdapter(ArrayList())
|
||||
adapter.onInstanceClicked = { instance -> presenter.onAdapterItemClick(instance) }
|
||||
adapter.onManualSetupClicked = { this.startManualSetup() }
|
||||
adapter.onPhoneSignInClicked = { this.startPhoneSignIn() }
|
||||
adapter.onInstanceClicked = { instance ->
|
||||
if (phoneSignInAvailable) startPhoneSignIn(instance)
|
||||
else presenter.onInstanceClickedWithoutApp(this, instance.url.toString())
|
||||
}
|
||||
adapter.onManualSetupClicked = {
|
||||
if (phoneSignInAvailable) startPhoneSignIn(null)
|
||||
else startManualSetup()
|
||||
}
|
||||
|
||||
capabilityClient = Wearable.getCapabilityClient(this)
|
||||
remoteActivityHelper = RemoteActivityHelper(this)
|
||||
|
@ -90,45 +101,66 @@ class OnboardingActivity : AppCompatActivity(), OnboardingView {
|
|||
Wearable.getDataClient(this).removeListener(presenter)
|
||||
}
|
||||
|
||||
override fun startAuthentication(flowId: String) {
|
||||
startActivity(AuthenticationActivity.newInstance(this, flowId))
|
||||
}
|
||||
|
||||
override fun startManualSetup() {
|
||||
private fun startManualSetup() {
|
||||
startActivity(ManualSetupActivity.newInstance(this))
|
||||
}
|
||||
|
||||
override fun startPhoneSignIn() {
|
||||
try {
|
||||
remoteActivityHelper.startRemoteActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
addCategory(Intent.CATEGORY_DEFAULT)
|
||||
addCategory(Intent.CATEGORY_BROWSABLE)
|
||||
data = Uri.parse("homeassistant://wear-phone-signin")
|
||||
},
|
||||
null // a Wear device only has one companion device so this is not needed
|
||||
)
|
||||
val confirmation = Intent(this, ConfirmationActivity::class.java).apply {
|
||||
putExtra(ConfirmationActivity.EXTRA_ANIMATION_TYPE, ConfirmationActivity.OPEN_ON_PHONE_ANIMATION)
|
||||
putExtra(ConfirmationActivity.EXTRA_ANIMATION_DURATION_MILLIS, 2500)
|
||||
putExtra(ConfirmationActivity.EXTRA_MESSAGE, getString(commonR.string.continue_on_phone))
|
||||
private fun startPhoneSignIn(instance: HomeAssistantInstance?) {
|
||||
lifecycleScope.launch {
|
||||
showLoading()
|
||||
try {
|
||||
val url = "homeassistant://wear-phone-signin${if (instance != null) "?url=${instance.url}" else ""}"
|
||||
remoteActivityHelper.startRemoteActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
addCategory(Intent.CATEGORY_DEFAULT)
|
||||
addCategory(Intent.CATEGORY_BROWSABLE)
|
||||
data = Uri.parse(url)
|
||||
},
|
||||
null // a Wear device only has one companion device so this is not needed
|
||||
).await()
|
||||
showContinueOnPhone()
|
||||
} catch (e: Exception) {
|
||||
if (e is RemoteActivityHelper.RemoteIntentException) {
|
||||
Log.e(TAG, "Unable to open sign in activity on phone with app, falling back to OAuth", e)
|
||||
if (instance != null) {
|
||||
presenter.onInstanceClickedWithoutApp(this@OnboardingActivity, instance.url.toString())
|
||||
} else {
|
||||
startManualSetup()
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Unable to open sign in activity on phone", e)
|
||||
showError()
|
||||
}
|
||||
}
|
||||
startActivity(confirmation)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to open sign in activity on phone", e)
|
||||
showError()
|
||||
}
|
||||
}
|
||||
|
||||
override fun startIntegration() {
|
||||
startActivity(MobileAppIntegrationActivity.newInstance(this))
|
||||
}
|
||||
|
||||
override fun showLoading() {
|
||||
loadingView.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
override fun showError() {
|
||||
override fun showContinueOnPhone() {
|
||||
val confirmation = Intent(this, ConfirmationActivity::class.java).apply {
|
||||
putExtra(
|
||||
ConfirmationActivity.EXTRA_ANIMATION_TYPE,
|
||||
ConfirmationActivity.OPEN_ON_PHONE_ANIMATION
|
||||
)
|
||||
putExtra(ConfirmationActivity.EXTRA_ANIMATION_DURATION_MILLIS, 2000)
|
||||
putExtra(ConfirmationActivity.EXTRA_MESSAGE, getString(commonR.string.continue_on_phone))
|
||||
}
|
||||
startActivity(confirmation)
|
||||
loadingView.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun showError(@StringRes message: Int?) {
|
||||
// Show failure message
|
||||
val intent = Intent(this, ConfirmationActivity::class.java).apply {
|
||||
putExtra(ConfirmationActivity.EXTRA_ANIMATION_TYPE, ConfirmationActivity.FAILURE_ANIMATION)
|
||||
putExtra(ConfirmationActivity.EXTRA_MESSAGE, getString(commonR.string.failed_connection))
|
||||
putExtra(ConfirmationActivity.EXTRA_MESSAGE, getString(message ?: commonR.string.failed_connection))
|
||||
}
|
||||
startActivity(intent)
|
||||
loadingView.visibility = View.GONE
|
||||
|
@ -202,10 +234,7 @@ class OnboardingActivity : AppCompatActivity(), OnboardingView {
|
|||
)
|
||||
|
||||
Log.d(TAG, "requestPhoneSignIn: found ${capabilityInfo.nodes.size} nodes")
|
||||
runOnUiThread {
|
||||
adapter.phoneSignInAvailable = capabilityInfo.nodes.size > 0
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
phoneSignInAvailable = capabilityInfo.nodes.size > 0
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
package io.homeassistant.companion.android.onboarding
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.gms.wearable.DataClient
|
||||
import com.google.android.gms.wearable.DataMap
|
||||
|
||||
interface OnboardingPresenter : DataClient.OnDataChangedListener {
|
||||
|
||||
fun onAdapterItemClick(instance: HomeAssistantInstance)
|
||||
fun onInstanceClickedWithoutApp(context: Context, url: String)
|
||||
|
||||
fun onFinish()
|
||||
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
package io.homeassistant.companion.android.onboarding
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.wear.phone.interactions.authentication.CodeChallenge
|
||||
import androidx.wear.phone.interactions.authentication.CodeVerifier
|
||||
import androidx.wear.phone.interactions.authentication.OAuthRequest
|
||||
import androidx.wear.phone.interactions.authentication.OAuthResponse
|
||||
import androidx.wear.phone.interactions.authentication.RemoteAuthClient
|
||||
import com.google.android.gms.wearable.DataEvent
|
||||
import com.google.android.gms.wearable.DataEventBuffer
|
||||
import com.google.android.gms.wearable.DataMap
|
||||
import com.google.android.gms.wearable.DataMapItem
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import io.homeassistant.companion.android.BuildConfig
|
||||
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
|
||||
import io.homeassistant.companion.android.common.data.url.UrlRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -16,7 +22,9 @@ import kotlinx.coroutines.Job
|
|||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URL
|
||||
import java.util.concurrent.Executors
|
||||
import javax.inject.Inject
|
||||
import io.homeassistant.companion.android.common.R as commonR
|
||||
|
||||
class OnboardingPresenterImpl @Inject constructor(
|
||||
@ActivityContext context: Context,
|
||||
|
@ -28,9 +36,63 @@ class OnboardingPresenterImpl @Inject constructor(
|
|||
}
|
||||
|
||||
private val view = context as OnboardingView
|
||||
|
||||
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
|
||||
|
||||
private var codeVerifier = CodeVerifier()
|
||||
private var authClient: RemoteAuthClient? = null
|
||||
|
||||
override fun onInstanceClickedWithoutApp(context: Context, url: String) {
|
||||
// This is very unlikely to happen, as instances are usually only discovered by the app.
|
||||
// Still, the connection might be lost which is why this exists. Based on onNextClicked in
|
||||
// ManualSetupPresenterImpl. Also a good starting point for manual URL if it is possible to
|
||||
// enter this on the device without the app in the future.
|
||||
mainScope.launch {
|
||||
val request = OAuthRequest.Builder(context)
|
||||
.setAuthProviderUrl(
|
||||
Uri.parse(
|
||||
authenticationUseCase.buildAuthenticationUrl(
|
||||
url,
|
||||
OAuthRequest.WEAR_REDIRECT_URL_PREFIX + BuildConfig.APPLICATION_ID
|
||||
)
|
||||
)
|
||||
)
|
||||
.setCodeChallenge(CodeChallenge(codeVerifier))
|
||||
.build()
|
||||
|
||||
authClient = RemoteAuthClient.create(context)
|
||||
authClient?.let {
|
||||
view.showContinueOnPhone()
|
||||
it.sendAuthorizationRequest(
|
||||
request,
|
||||
Executors.newSingleThreadExecutor(),
|
||||
object : RemoteAuthClient.Callback() {
|
||||
override fun onAuthorizationError(request: OAuthRequest, errorCode: Int) {
|
||||
Log.w(TAG, "Received authorization error for OAuth: $errorCode")
|
||||
view.showError(
|
||||
when (errorCode) {
|
||||
RemoteAuthClient.ERROR_UNSUPPORTED -> commonR.string.failed_unsupported
|
||||
RemoteAuthClient.ERROR_PHONE_UNAVAILABLE -> commonR.string.failed_phone_connection
|
||||
else -> commonR.string.failed_connection
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAuthorizationResponse(
|
||||
request: OAuthRequest,
|
||||
response: OAuthResponse
|
||||
) {
|
||||
response.responseUrl?.getQueryParameter("code")?.let { code ->
|
||||
register(url, code)
|
||||
} ?: run {
|
||||
view.showError(commonR.string.failed_registration)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDataChanged(dataEvents: DataEventBuffer) {
|
||||
Log.d(TAG, "onDataChanged: [${dataEvents.count}]")
|
||||
dataEvents.forEach { event ->
|
||||
|
@ -64,27 +126,25 @@ class OnboardingPresenterImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun onAdapterItemClick(instance: HomeAssistantInstance) {
|
||||
Log.d(TAG, "onAdapterItemClick: ${instance.name}")
|
||||
view.showLoading()
|
||||
fun register(url: String, code: String) {
|
||||
mainScope.launch {
|
||||
// Set current url
|
||||
urlUseCase.saveUrl(instance.url.toString())
|
||||
view.showLoading()
|
||||
|
||||
// Initiate login flow
|
||||
try {
|
||||
val flowForm: LoginFlowForm = authenticationUseCase.initiateLoginFlow()
|
||||
Log.d(TAG, "Created login flow step ${flowForm.stepId}: ${flowForm.flowId}")
|
||||
|
||||
view.startAuthentication(flowForm.flowId)
|
||||
urlUseCase.saveUrl(url)
|
||||
authenticationUseCase.registerAuthorizationCode(code)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initiate login flow", e)
|
||||
view.showError()
|
||||
Log.e(TAG, "Exception during registration", e)
|
||||
view.showError(commonR.string.failed_registration)
|
||||
return@launch
|
||||
}
|
||||
|
||||
view.startIntegration()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFinish() {
|
||||
mainScope.cancel()
|
||||
authClient?.close()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
package io.homeassistant.companion.android.onboarding
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
interface OnboardingView {
|
||||
fun startAuthentication(flowId: String)
|
||||
fun startManualSetup()
|
||||
fun startPhoneSignIn()
|
||||
fun startIntegration()
|
||||
|
||||
fun onInstanceFound(instance: HomeAssistantInstance)
|
||||
fun onInstanceLost(instance: HomeAssistantInstance)
|
||||
|
||||
fun showLoading()
|
||||
|
||||
fun showError()
|
||||
fun showContinueOnPhone()
|
||||
|
||||
fun showError(@StringRes message: Int? = null)
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ import io.homeassistant.companion.android.viewHolders.HeaderViewHolder
|
|||
import io.homeassistant.companion.android.viewHolders.InstanceViewHolder
|
||||
import io.homeassistant.companion.android.viewHolders.LoadingViewHolder
|
||||
import io.homeassistant.companion.android.viewHolders.ManualSetupViewHolder
|
||||
import io.homeassistant.companion.android.viewHolders.PhoneSignInViewHolder
|
||||
import kotlin.math.min
|
||||
import io.homeassistant.companion.android.common.R as commonR
|
||||
|
||||
|
@ -19,16 +18,12 @@ class ServerListAdapter(
|
|||
|
||||
lateinit var onInstanceClicked: (HomeAssistantInstance) -> Unit
|
||||
lateinit var onManualSetupClicked: () -> Unit
|
||||
lateinit var onPhoneSignInClicked: () -> Unit
|
||||
|
||||
var phoneSignInAvailable = false
|
||||
|
||||
companion object {
|
||||
private const val TYPE_INSTANCE = 1
|
||||
private const val TYPE_HEADER = 2
|
||||
private const val TYPE_LOADING = 3
|
||||
private const val TYPE_MANUAL = 4
|
||||
private const val TYPE_PHONE_SIGNIN = 5
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
|
@ -51,11 +46,6 @@ class ServerListAdapter(
|
|||
.inflate(R.layout.listitem_instance, parent, false)
|
||||
ManualSetupViewHolder(view, onManualSetupClicked)
|
||||
}
|
||||
TYPE_PHONE_SIGNIN -> {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.listitem_instance, parent, false)
|
||||
PhoneSignInViewHolder(view, onPhoneSignInClicked)
|
||||
}
|
||||
else -> {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.listitem_loading, parent, false)
|
||||
|
@ -69,8 +59,6 @@ class ServerListAdapter(
|
|||
holder.server = servers[position - 1]
|
||||
} else if (holder is ManualSetupViewHolder) {
|
||||
holder.text.setText(commonR.string.manual_setup)
|
||||
} else if (holder is PhoneSignInViewHolder) {
|
||||
holder.text.setText(commonR.string.sign_in_on_phone)
|
||||
} else if (holder is HeaderViewHolder) {
|
||||
if (position == 0) {
|
||||
holder.headerTextView.setText(commonR.string.list_header_instances)
|
||||
|
@ -81,14 +69,13 @@ class ServerListAdapter(
|
|||
}
|
||||
|
||||
override fun getItemCount() = min(
|
||||
servers.size + (if (phoneSignInAvailable) 4 else 3),
|
||||
if (phoneSignInAvailable) 5 else 4
|
||||
servers.size + 3,
|
||||
4
|
||||
)
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when {
|
||||
position == 0 || position == this.itemCount - (if (phoneSignInAvailable) 3 else 2) -> TYPE_HEADER
|
||||
position == this.itemCount - 2 && phoneSignInAvailable -> TYPE_PHONE_SIGNIN
|
||||
position == 0 || position == this.itemCount - 2 -> TYPE_HEADER
|
||||
position == this.itemCount - 1 -> TYPE_MANUAL
|
||||
servers.size > 0 -> TYPE_INSTANCE
|
||||
else -> TYPE_LOADING
|
||||
|
|
|
@ -1,91 +0,0 @@
|
|||
package io.homeassistant.companion.android.onboarding.authentication
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.wear.activity.ConfirmationActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.homeassistant.companion.android.databinding.ActivityAuthenticationBinding
|
||||
import io.homeassistant.companion.android.onboarding.integration.MobileAppIntegrationActivity
|
||||
import javax.inject.Inject
|
||||
import io.homeassistant.companion.android.common.R as commonR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AuthenticationActivity : AppCompatActivity(), AuthenticationView {
|
||||
companion object {
|
||||
private const val TAG = "AuthenticationActivity"
|
||||
|
||||
fun newInstance(context: Context, flowId: String): Intent {
|
||||
var intent = Intent(context, AuthenticationActivity::class.java)
|
||||
intent.putExtra("flowId", flowId)
|
||||
return intent
|
||||
}
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var presenter: AuthenticationPresenter
|
||||
private lateinit var binding: ActivityAuthenticationBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (intent == null || !intent.hasExtra("flowId")) {
|
||||
Log.e(TAG, "Flow id not specified, canceling authentication")
|
||||
finish()
|
||||
}
|
||||
|
||||
binding = ActivityAuthenticationBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.buttonNext.setOnClickListener {
|
||||
presenter.onNextClicked(
|
||||
intent.getStringExtra("flowId")!!,
|
||||
binding.username.text.toString(),
|
||||
binding.password.text.toString(),
|
||||
binding.code.text.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
binding.loadingView.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun startIntegration() {
|
||||
startActivity(MobileAppIntegrationActivity.newInstance(this))
|
||||
}
|
||||
|
||||
override fun showMfa() {
|
||||
binding.titleLogin.visibility = View.GONE
|
||||
binding.titleMfa.visibility = View.VISIBLE
|
||||
binding.username.visibility = View.GONE
|
||||
binding.password.visibility = View.GONE
|
||||
binding.code.visibility = View.VISIBLE
|
||||
|
||||
binding.loadingView.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun showLoading() {
|
||||
binding.loadingView.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
override fun showError() {
|
||||
// Show failure message
|
||||
val intent = Intent(this, ConfirmationActivity::class.java).apply {
|
||||
putExtra(ConfirmationActivity.EXTRA_ANIMATION_TYPE, ConfirmationActivity.FAILURE_ANIMATION)
|
||||
putExtra(ConfirmationActivity.EXTRA_MESSAGE, getString(commonR.string.failed_authentication))
|
||||
}
|
||||
startActivity(intent)
|
||||
binding.loadingView.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
presenter.onFinish()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
package io.homeassistant.companion.android.onboarding.authentication
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ActivityComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(ActivityComponent::class)
|
||||
interface AuthenticationModule {
|
||||
|
||||
@Binds
|
||||
fun authenticationPresenter(authenticationPresenterImpl: AuthenticationPresenterImpl): AuthenticationPresenter
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
package io.homeassistant.companion.android.onboarding.authentication
|
||||
|
||||
interface AuthenticationPresenter {
|
||||
|
||||
fun onNextClicked(flowId: String, username: String?, password: String?, code: String?)
|
||||
|
||||
fun onFinish()
|
||||
}
|
|
@ -1,74 +0,0 @@
|
|||
package io.homeassistant.companion.android.onboarding.authentication
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowCreateEntry
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class AuthenticationPresenterImpl @Inject constructor(
|
||||
@ActivityContext context: Context,
|
||||
private val authenticationUseCase: AuthenticationRepository
|
||||
) : AuthenticationPresenter {
|
||||
companion object {
|
||||
private const val TAG = "AuthenticationPresenter"
|
||||
}
|
||||
|
||||
enum class AuthenticationPhase {
|
||||
PASSWORD, MFA
|
||||
}
|
||||
private var authenticationPhase = AuthenticationPhase.PASSWORD
|
||||
|
||||
private val view = context as AuthenticationView
|
||||
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
|
||||
|
||||
override fun onNextClicked(flowId: String, username: String?, password: String?, code: String?) {
|
||||
view.showLoading()
|
||||
Log.d(TAG, "onNextClicked")
|
||||
mainScope.launch {
|
||||
try {
|
||||
val flowResult = when (authenticationPhase) {
|
||||
AuthenticationPhase.PASSWORD -> authenticationUseCase.loginAuthentication(flowId, username.orEmpty(), password.orEmpty())
|
||||
AuthenticationPhase.MFA -> authenticationUseCase.loginCode(flowId, code.orEmpty())
|
||||
}
|
||||
Log.d(TAG, "Authenticated result type: ${flowResult?.type}")
|
||||
when (flowResult?.type) {
|
||||
"form" -> {
|
||||
if (authenticationPhase == AuthenticationPhase.PASSWORD && (flowResult as LoginFlowForm).stepId == "mfa") {
|
||||
Log.d(TAG, "MFA required to authenticate")
|
||||
authenticationPhase = AuthenticationPhase.MFA
|
||||
view.showMfa()
|
||||
} else {
|
||||
Log.e(TAG, "Unable to authenticate")
|
||||
view.showError()
|
||||
}
|
||||
}
|
||||
"create_entry" -> {
|
||||
authenticationUseCase.registerAuthorizationCode((flowResult as LoginFlowCreateEntry).result)
|
||||
Log.d(TAG, "Finished!")
|
||||
|
||||
view.startIntegration()
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "Unable to authenticate")
|
||||
view.showError()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to authenticate", e)
|
||||
view.showError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFinish() {
|
||||
mainScope.cancel()
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
package io.homeassistant.companion.android.onboarding.authentication
|
||||
|
||||
interface AuthenticationView {
|
||||
fun startIntegration()
|
||||
|
||||
fun showMfa()
|
||||
|
||||
fun showLoading()
|
||||
|
||||
fun showError()
|
||||
}
|
|
@ -5,12 +5,13 @@ import android.content.Intent
|
|||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.wear.activity.ConfirmationActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.homeassistant.companion.android.R
|
||||
import io.homeassistant.companion.android.databinding.ActivityManualSetupBinding
|
||||
import io.homeassistant.companion.android.onboarding.authentication.AuthenticationActivity
|
||||
import io.homeassistant.companion.android.onboarding.integration.MobileAppIntegrationActivity
|
||||
import javax.inject.Inject
|
||||
import io.homeassistant.companion.android.common.R as commonR
|
||||
|
||||
|
@ -35,26 +36,39 @@ class ManualSetupActivity : AppCompatActivity(), ManualSetupView {
|
|||
setContentView(binding.root)
|
||||
|
||||
binding.buttonNext.setOnClickListener {
|
||||
presenter.onNextClicked(findViewById<EditText>(R.id.server_url).text.toString())
|
||||
presenter.onNextClicked(this, findViewById<EditText>(R.id.server_url).text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun startAuthentication(flowId: String) {
|
||||
startActivity(AuthenticationActivity.newInstance(this, flowId))
|
||||
override fun startIntegration() {
|
||||
startActivity(MobileAppIntegrationActivity.newInstance(this))
|
||||
}
|
||||
|
||||
override fun showLoading() {
|
||||
binding.loadingView.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
override fun showError() {
|
||||
override fun showContinueOnPhone() {
|
||||
val confirmation = Intent(this, ConfirmationActivity::class.java).apply {
|
||||
putExtra(
|
||||
ConfirmationActivity.EXTRA_ANIMATION_TYPE,
|
||||
ConfirmationActivity.OPEN_ON_PHONE_ANIMATION
|
||||
)
|
||||
putExtra(ConfirmationActivity.EXTRA_ANIMATION_DURATION_MILLIS, 2000)
|
||||
putExtra(ConfirmationActivity.EXTRA_MESSAGE, getString(commonR.string.continue_on_phone))
|
||||
}
|
||||
startActivity(confirmation)
|
||||
binding.loadingView.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun showError(@StringRes message: Int) {
|
||||
// Show failure message
|
||||
val intent = Intent(this, ConfirmationActivity::class.java).apply {
|
||||
putExtra(
|
||||
ConfirmationActivity.EXTRA_ANIMATION_TYPE,
|
||||
ConfirmationActivity.FAILURE_ANIMATION
|
||||
)
|
||||
putExtra(ConfirmationActivity.EXTRA_MESSAGE, getString(commonR.string.failed_connection))
|
||||
putExtra(ConfirmationActivity.EXTRA_MESSAGE, getString(message))
|
||||
}
|
||||
startActivity(intent)
|
||||
binding.loadingView.visibility = View.GONE
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package io.homeassistant.companion.android.onboarding.manual_setup
|
||||
|
||||
import android.content.Context
|
||||
|
||||
interface ManualSetupPresenter {
|
||||
|
||||
fun onNextClicked(url: String)
|
||||
fun onNextClicked(context: Context, url: String)
|
||||
|
||||
fun onFinish()
|
||||
}
|
||||
|
|
|
@ -1,17 +1,25 @@
|
|||
package io.homeassistant.companion.android.onboarding.manual_setup
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.wear.phone.interactions.authentication.CodeChallenge
|
||||
import androidx.wear.phone.interactions.authentication.CodeVerifier
|
||||
import androidx.wear.phone.interactions.authentication.OAuthRequest
|
||||
import androidx.wear.phone.interactions.authentication.OAuthResponse
|
||||
import androidx.wear.phone.interactions.authentication.RemoteAuthClient
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import io.homeassistant.companion.android.BuildConfig
|
||||
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
|
||||
import io.homeassistant.companion.android.common.data.url.UrlRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.Executors
|
||||
import javax.inject.Inject
|
||||
import io.homeassistant.companion.android.common.R as commonR
|
||||
|
||||
class ManualSetupPresenterImpl @Inject constructor(
|
||||
@ActivityContext context: Context,
|
||||
|
@ -25,26 +33,77 @@ class ManualSetupPresenterImpl @Inject constructor(
|
|||
private val view = context as ManualSetupView
|
||||
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
|
||||
|
||||
override fun onNextClicked(url: String) {
|
||||
private var codeVerifier = CodeVerifier()
|
||||
private var authClient: RemoteAuthClient? = null
|
||||
|
||||
override fun onNextClicked(context: Context, url: String) {
|
||||
view.showLoading()
|
||||
mainScope.launch {
|
||||
// Set current url
|
||||
urlUseCase.saveUrl(url)
|
||||
val request = OAuthRequest.Builder(context)
|
||||
.setAuthProviderUrl(
|
||||
Uri.parse(
|
||||
authenticationUseCase.buildAuthenticationUrl(
|
||||
url,
|
||||
OAuthRequest.WEAR_REDIRECT_URL_PREFIX + BuildConfig.APPLICATION_ID
|
||||
)
|
||||
)
|
||||
)
|
||||
.setCodeChallenge(CodeChallenge(codeVerifier))
|
||||
.build()
|
||||
|
||||
// Initiate login flow
|
||||
try {
|
||||
val flowForm: LoginFlowForm = authenticationUseCase.initiateLoginFlow()
|
||||
Log.d(TAG, "Created login flow step ${flowForm.stepId}: ${flowForm.flowId}")
|
||||
authClient = RemoteAuthClient.create(context)
|
||||
authClient?.let {
|
||||
view.showContinueOnPhone()
|
||||
it.sendAuthorizationRequest(
|
||||
request,
|
||||
Executors.newSingleThreadExecutor(),
|
||||
object : RemoteAuthClient.Callback() {
|
||||
override fun onAuthorizationError(request: OAuthRequest, errorCode: Int) {
|
||||
Log.w(TAG, "Received authorization error for OAuth: $errorCode")
|
||||
view.showError(
|
||||
when (errorCode) {
|
||||
RemoteAuthClient.ERROR_UNSUPPORTED -> commonR.string.failed_unsupported
|
||||
RemoteAuthClient.ERROR_PHONE_UNAVAILABLE -> commonR.string.failed_phone_connection
|
||||
else -> commonR.string.failed_connection
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
view.startAuthentication(flowForm.flowId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initiate login flow", e)
|
||||
view.showError()
|
||||
override fun onAuthorizationResponse(
|
||||
request: OAuthRequest,
|
||||
response: OAuthResponse
|
||||
) {
|
||||
response.responseUrl?.getQueryParameter("code")?.let { code ->
|
||||
register(url, code)
|
||||
} ?: run {
|
||||
view.showError(commonR.string.failed_registration)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(url: String, code: String) {
|
||||
mainScope.launch {
|
||||
view.showLoading()
|
||||
|
||||
try {
|
||||
urlUseCase.saveUrl(url)
|
||||
authenticationUseCase.registerAuthorizationCode(code)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception during registration", e)
|
||||
view.showError(commonR.string.failed_registration)
|
||||
return@launch
|
||||
}
|
||||
|
||||
view.startIntegration()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFinish() {
|
||||
mainScope.cancel()
|
||||
authClient?.close()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
package io.homeassistant.companion.android.onboarding.manual_setup
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
interface ManualSetupView {
|
||||
fun startAuthentication(flowId: String)
|
||||
fun startIntegration()
|
||||
|
||||
fun showLoading()
|
||||
|
||||
fun showError()
|
||||
fun showContinueOnPhone()
|
||||
|
||||
fun showError(@StringRes message: Int)
|
||||
}
|
||||
|
|
|
@ -113,7 +113,7 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange
|
|||
val url = dataMap.getString("URL")
|
||||
val authCode = dataMap.getString("AuthCode")
|
||||
val deviceName = dataMap.getString("DeviceName")
|
||||
val deviceTrackingEnabled = dataMap.getString("LocationTracking")
|
||||
val deviceTrackingEnabled = dataMap.getBoolean("LocationTracking")
|
||||
|
||||
urlRepository.saveUrl(url)
|
||||
authenticationRepository.registerAuthorizationCode(authCode)
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
package io.homeassistant.companion.android.viewHolders
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.homeassistant.companion.android.R
|
||||
|
||||
class PhoneSignInViewHolder(v: View, val onClick: () -> Unit) :
|
||||
RecyclerView.ViewHolder(v) {
|
||||
|
||||
val text: TextView = v.findViewById(R.id.txt_name)
|
||||
|
||||
init {
|
||||
// Set onclick listener
|
||||
v.setOnClickListener {
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,96 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.wear.widget.BoxInsetLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="@dimen/box_inset_layout_padding"
|
||||
tools:context=".onboarding.authentication.AuthenticationActivity"
|
||||
tools:deviceIds="wear">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="@dimen/inner_frame_layout_padding"
|
||||
app:layout_boxedEdges="all">
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/button_next"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="@string/button_forward"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:srcCompat="@drawable/ic_button_arrow_forward" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleLogin"
|
||||
style="@style/HeaderText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/login"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleMfa"
|
||||
style="@style/HeaderText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/mfa_title"
|
||||
android:gravity="center"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/username"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ems="10"
|
||||
android:hint="@string/username"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="textPersonName"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleLogin" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/password"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ems="10"
|
||||
android:hint="@string/password"
|
||||
android:inputType="textPassword"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/username"
|
||||
android:imeOptions="actionDone"
|
||||
android:importantForAutofill="no" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/code"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ems="10"
|
||||
android:hint="@string/mfa_hint"
|
||||
android:inputType="number"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleMfa"
|
||||
android:imeOptions="actionDone"
|
||||
android:importantForAutofill="no"
|
||||
android:visibility="gone"/>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<io.homeassistant.companion.android.util.LoadingView
|
||||
android:id="@+id/loading_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:loading_text="@string/attempting_authentication"
|
||||
android:visibility="gone" />
|
||||
|
||||
</androidx.wear.widget.BoxInsetLayout>
|
Loading…
Reference in a new issue