Wear os authentication (#1691)

* Initial proof-of-concept: sharing Session over data layer

* Add initial onboarding and login flow

In onboarding, the home assistant urls are received from connected devices. If the user clicks on it, the authentication flow starts.
The user can alter the login details and proceed to login.
The authentication uses the "auth/login_flow" api, instead of the normal authentication api, since there is no support for webview on wear os.

* Clean up wear and app communication

Clean up

* Add proof of authentication on home. And add logout button on home

* Update onboarding list

* Add loading views and error messages

* Move startup logic to HomeActivity to hopefully save some resources

* Add manual setup option and improve UI

* Cleanup

* Passing ktLintCheck

* Passing ktLintCheck after rebase

* Fix building after build.gradle changes during rebase

* Process review comments

Remove multiple product flavors
Remove unnecessary log
Replace margin with additional header
This commit is contained in:
leroyboerefijn 2021-10-01 02:41:22 +02:00 committed by GitHub
parent d5ae97ed50
commit 5f904b980e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 1853 additions and 88 deletions

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ google-services.json
.idea/
.gradle/
build/
*.keystore

View file

@ -145,6 +145,8 @@ dependencies {
implementation("androidx.navigation:navigation-ui-ktx:2.3.5")
implementation("com.google.android.material:material:1.4.0")
implementation("com.google.android.gms:play-services-wearable:17.1.0")
implementation("androidx.room:room-runtime:2.3.0")
implementation("androidx.room:room-ktx:2.3.0")
kapt("androidx.room:room-compiler:2.3.0")

View file

@ -231,6 +231,15 @@
<activity
android:name=".onboarding.OnboardingActivity"
android:configChanges="orientation|screenSize|keyboardHidden" />
<service android:name=".onboarding.WearOnboardingListener">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<action android:name="com.google.android.gms.wearable.DATA_CHANGED" />
<data android:scheme="wear" android:host="*"
android:path="/request_home_assistant_instance" />
</intent-filter>
</service>
<activity
android:name=".webview.WebViewActivity"

View file

@ -0,0 +1,10 @@
package io.homeassistant.companion.android.onboarding
import dagger.Component
import io.homeassistant.companion.android.common.dagger.AppComponent
@Component(dependencies = [AppComponent::class])
interface OnboardingListenerComponent {
fun inject(listener: WearOnboardingListener)
}

View file

@ -0,0 +1,57 @@
package io.homeassistant.companion.android.onboarding
import android.util.Log
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.PutDataMapRequest
import com.google.android.gms.wearable.PutDataRequest
import com.google.android.gms.wearable.Wearable
import com.google.android.gms.wearable.WearableListenerService
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.url.UrlRepository
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
class WearOnboardingListener : WearableListenerService() {
@Inject
lateinit var authenticationUseCase: AuthenticationRepository
@Inject
lateinit var urlUseCase: UrlRepository
override fun onCreate() {
super.onCreate()
DaggerOnboardingListenerComponent.builder()
.appComponent((applicationContext.applicationContext as GraphComponentAccessor).appComponent)
.build()
.inject(this)
}
override fun onMessageReceived(event: MessageEvent) {
Log.d("WearOnboardingListener", "onMessageReceived: $event")
if (event.path == "/request_home_assistant_instance") {
val nodeId = event.sourceNodeId
sendHomeAssistantInstance(nodeId)
}
}
private fun sendHomeAssistantInstance(nodeId: String) = runBlocking {
Log.d("WearOnboardingListener", "sendHomeAssistantInstance: $nodeId")
// Retrieve current instance
val url = urlUseCase.getUrl()
// Put as DataMap in data layer
val putDataReq: PutDataRequest = PutDataMapRequest.create("/home_assistant_instance").run {
dataMap.putString("name", url?.host.toString())
dataMap.putString("url", url.toString())
setUrgent()
asPutDataRequest()
}
Wearable.getDataClient(this@WearOnboardingListener).putDataItem(putDataReq).apply {
addOnSuccessListener { Log.d("WearOnboardingListener", "sendHomeAssistantInstance: success") }
addOnFailureListener { Log.d("WearOnboardingListener", "sendHomeAssistantInstance: failed") }
}
}
}

View file

@ -31,8 +31,7 @@
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/deviceName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@array/android_wear_capabilities">
<string-array name="android_wear_capabilities">
<item>request_authentication_token</item>
<item>request_home_assistant_instance</item>
</string-array>
</resources>

View file

@ -1,9 +1,15 @@
package io.homeassistant.companion.android.common.data.authentication
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowCreateEntry
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
import java.net.URL
interface AuthenticationRepository {
suspend fun initiateLoginFlow(): LoginFlowInit
suspend fun loginAuthentication(flowId: String, username: String, password: String): LoginFlowCreateEntry
suspend fun registerAuthorizationCode(authorizationCode: String)
suspend fun retrieveExternalAuthentication(forceRefresh: Boolean): String

View file

@ -5,6 +5,10 @@ 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.LoginFlowInit
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowRequest
import io.homeassistant.companion.android.common.data.url.UrlRepository
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.net.URL
@ -26,6 +30,27 @@ class AuthenticationRepositoryImpl @Inject constructor(
private const val PREF_BIOMETRIC_ENABLED = "biometric_enabled"
}
override suspend fun initiateLoginFlow(): LoginFlowInit {
return authenticationService.initializeLogin(
LoginFlowRequest(
AuthenticationService.CLIENT_ID,
AuthenticationService.AUTH_CALLBACK,
AuthenticationService.HANDLER
)
)
}
override suspend fun loginAuthentication(flowId: String, username: String, password: String): LoginFlowCreateEntry {
return authenticationService.authenticate(
AuthenticationService.AUTHENTICATE_BASE_PATH + flowId,
LoginFlowAuthentication(
AuthenticationService.CLIENT_ID,
username,
password
)
)
}
override suspend fun registerAuthorizationCode(authorizationCode: String) {
authenticationService.getToken(
AuthenticationService.GRANT_TYPE_CODE,

View file

@ -1,10 +1,16 @@
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.LoginFlowCreateEntry
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowRequest
import io.homeassistant.companion.android.common.data.authentication.impl.entities.Token
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
import retrofit2.http.Url
interface AuthenticationService {
@ -13,6 +19,9 @@ 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
@ -37,4 +46,10 @@ interface AuthenticationService {
@Field("token") refreshToken: String,
@Field("action") action: String
)
@POST("auth/login_flow")
suspend fun initializeLogin(@Body body: LoginFlowRequest): LoginFlowInit
@POST
suspend fun authenticate(@Url url: String, @Body body: LoginFlowAuthentication): LoginFlowCreateEntry
}

View file

@ -0,0 +1,12 @@
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
)

View file

@ -0,0 +1,14 @@
package io.homeassistant.companion.android.common.data.authentication.impl.entities
import com.fasterxml.jackson.annotation.JsonProperty
data class LoginFlowCreateEntry(
@JsonProperty("version")
val version: Int,
@JsonProperty("type")
val type: String,
@JsonProperty("flow_id")
val flowId: String,
@JsonProperty("result")
val result: String
)

View file

@ -0,0 +1,14 @@
package io.homeassistant.companion.android.common.data.authentication.impl.entities
import com.fasterxml.jackson.annotation.JsonProperty
data class LoginFlowInit(
@JsonProperty("type")
val type: String,
@JsonProperty("flow_id")
val flowId: String,
@JsonProperty("step_id")
val stepId: String,
@JsonProperty("errors")
val errors: Map<String, String>
)

View file

@ -0,0 +1,12 @@
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?>
)

View file

@ -53,6 +53,7 @@ android {
signingConfig = signingConfigs.getByName("release")
}
}
kotlinOptions {
jvmTarget = "11"
}
@ -65,9 +66,15 @@ android {
dependencies {
implementation(project(":common"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1")
implementation("com.google.android.material:material:1.4.0")
implementation("androidx.wear:wear:1.1.0")
implementation("com.google.android.support:wearable:2.8.1")
implementation("com.google.android.gms:play-services-wearable:17.1.0")
compileOnly("com.google.android.wearable:wearable:2.8.1")
implementation("com.google.dagger:dagger:2.38.1")
kapt("com.google.dagger:dagger-compiler:2.38.1")
}

View file

@ -2,11 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.homeassistant.companion.android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-feature android:name="android.hardware.type.watch" />
<application
android:name=".HomeAssistantApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@ -17,23 +19,26 @@
android:name="com.google.android.wearable"
android:required="true" />
<!--
Set to true if your app is Standalone, that is, it does not require the handheld
app to run.
-->
<!-- The app can run without a connected phone -->
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="true" />
<activity
android:name=".Home"
android:label="@string/app_name">
<activity android:name=".home.HomeActivity"
android:label="@string/app_name"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</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" />
<!-- To show confirmations and failures -->
<activity android:name="androidx.wear.activity.ConfirmationActivity" />
</application>
</manifest>

View file

@ -1,15 +0,0 @@
package io.homeassistant.companion.android
import android.os.Bundle
import android.support.wearable.activity.WearableActivity
class Home : WearableActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_home)
// Enables Always-on
setAmbientEnabled()
}
}

View file

@ -0,0 +1,20 @@
package io.homeassistant.companion.android
import android.app.Application
import io.homeassistant.companion.android.common.dagger.AppComponent
import io.homeassistant.companion.android.common.dagger.Graph
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
open class HomeAssistantApplication : Application(), GraphComponentAccessor {
lateinit var graph: Graph
override fun onCreate() {
super.onCreate()
graph = Graph(this, 0)
}
override val appComponent: AppComponent
get() = graph.appComponent
}

View file

@ -0,0 +1,23 @@
package io.homeassistant.companion.android
import dagger.Component
import io.homeassistant.companion.android.common.dagger.AppComponent
import io.homeassistant.companion.android.home.HomeActivity
import io.homeassistant.companion.android.onboarding.OnboardingActivity
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
@Component(dependencies = [AppComponent::class], modules = [PresenterModule::class])
interface PresenterComponent {
fun inject(activity: OnboardingActivity)
fun inject(activity: AuthenticationActivity)
fun inject(activity: MobileAppIntegrationActivity)
fun inject(activity: ManualSetupActivity)
fun inject(activity: HomeActivity)
}

View file

@ -0,0 +1,84 @@
package io.homeassistant.companion.android
import dagger.Binds
import dagger.Module
import dagger.Provides
import io.homeassistant.companion.android.home.HomePresenter
import io.homeassistant.companion.android.home.HomePresenterImpl
import io.homeassistant.companion.android.home.HomeView
import io.homeassistant.companion.android.onboarding.OnboardingPresenter
import io.homeassistant.companion.android.onboarding.OnboardingPresenterImpl
import io.homeassistant.companion.android.onboarding.OnboardingView
import io.homeassistant.companion.android.onboarding.authentication.AuthenticationPresenter
import io.homeassistant.companion.android.onboarding.authentication.AuthenticationPresenterImpl
import io.homeassistant.companion.android.onboarding.authentication.AuthenticationView
import io.homeassistant.companion.android.onboarding.integration.MobileAppIntegrationPresenter
import io.homeassistant.companion.android.onboarding.integration.MobileAppIntegrationPresenterImpl
import io.homeassistant.companion.android.onboarding.integration.MobileAppIntegrationView
import io.homeassistant.companion.android.onboarding.manual_setup.ManualSetupPresenter
import io.homeassistant.companion.android.onboarding.manual_setup.ManualSetupPresenterImpl
import io.homeassistant.companion.android.onboarding.manual_setup.ManualSetupView
@Module(includes = [PresenterModule.Declaration::class])
class PresenterModule {
private lateinit var onBoardingView: OnboardingView
private lateinit var authenticationView: AuthenticationView
private lateinit var mobileAppIntegrationView: MobileAppIntegrationView
private lateinit var manualSetupView: ManualSetupView
private lateinit var homeView: HomeView
constructor(onBoardingView: OnboardingView) {
this.onBoardingView = onBoardingView
}
constructor(authenticationView: AuthenticationView) {
this.authenticationView = authenticationView
}
constructor(mobileAppIntegrationView: MobileAppIntegrationView) {
this.mobileAppIntegrationView = mobileAppIntegrationView
}
constructor(manualSetupView: ManualSetupView) {
this.manualSetupView = manualSetupView
}
constructor(homeView: HomeView) {
this.homeView = homeView
}
@Provides
fun provideOnboardingView() = onBoardingView
@Provides
fun provideAuthenticationView() = authenticationView
@Provides
fun provideMobileAppIntegrationView() = mobileAppIntegrationView
@Provides
fun provideManualSetupView() = manualSetupView
@Provides
fun provideHomeView() = homeView
@Module
interface Declaration {
@Binds
fun bindOnboardingPresenter(presenter: OnboardingPresenterImpl): OnboardingPresenter
@Binds
fun bindAuthenticationPresenter(presenter: AuthenticationPresenterImpl): AuthenticationPresenter
@Binds
fun bindMobileAppIntegrationPresenter(presenter: MobileAppIntegrationPresenterImpl): MobileAppIntegrationPresenter
@Binds
fun bindManualSetupPresenter(presenter: ManualSetupPresenterImpl): ManualSetupPresenter
@Binds
fun bindHomePresenter(presenter: HomePresenterImpl): HomePresenter
}
}

View file

@ -0,0 +1,75 @@
package io.homeassistant.companion.android.home
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.button.MaterialButton
import io.homeassistant.companion.android.DaggerPresenterComponent
import io.homeassistant.companion.android.PresenterModule
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
import io.homeassistant.companion.android.onboarding.OnboardingActivity
import io.homeassistant.companion.android.onboarding.integration.MobileAppIntegrationActivity
import javax.inject.Inject
class HomeActivity : AppCompatActivity(), HomeView {
@Inject
lateinit var presenter: HomePresenter
companion object {
private const val TAG = "HomeActivity"
fun newInstance(context: Context): Intent {
return Intent(context, HomeActivity::class.java)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_home)
DaggerPresenterComponent
.builder()
.appComponent((application as GraphComponentAccessor).appComponent)
.presenterModule(PresenterModule(this))
.build()
.inject(this)
findViewById<MaterialButton>(R.id.btn_logout).setOnClickListener {
presenter.onLogoutClicked()
}
presenter.onViewReady()
}
override fun onDestroy() {
presenter.onFinish()
super.onDestroy()
}
override fun showHomeAssistantVersion(version: String) {
val txtVersion = findViewById<TextView>(R.id.txt_version)
txtVersion.text = getString(R.string.version, version)
}
override fun showEntitiesCount(count: Int) {
val txtEntities = findViewById<TextView>(R.id.txt_entities)
txtEntities.text = resources.getQuantityString(R.plurals.entities_found, count, count)
}
override fun displayOnBoarding() {
val intent = OnboardingActivity.newInstance(this)
startActivity(intent)
finish()
}
override fun displayMobileAppIntegration() {
val intent = MobileAppIntegrationActivity.newInstance(this)
startActivity(intent)
finish()
}
}

View file

@ -0,0 +1,8 @@
package io.homeassistant.companion.android.home
interface HomePresenter {
fun onViewReady()
fun onLogoutClicked()
fun onFinish()
}

View file

@ -0,0 +1,70 @@
package io.homeassistant.companion.android.home
import android.util.Log
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.authentication.SessionState
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
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 HomePresenterImpl @Inject constructor(
private val view: HomeView,
private val authenticationUseCase: AuthenticationRepository,
private val integrationUseCase: IntegrationRepository
) : HomePresenter {
companion object {
const val TAG = "HomePresenter"
}
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onViewReady() {
mainScope.launch {
val sessionValid = authenticationUseCase.getSessionState() == SessionState.CONNECTED
if (sessionValid && integrationUseCase.isRegistered()) {
resyncRegistration()
// We'll stay on HomeActivity, so start loading
view.showHomeAssistantVersion(integrationUseCase.getHomeAssistantVersion())
view.showEntitiesCount(integrationUseCase.getEntities().size)
} else if (sessionValid) {
view.displayMobileAppIntegration()
} else {
view.displayOnBoarding()
}
}
}
override fun onLogoutClicked() {
mainScope.launch {
authenticationUseCase.revokeSession()
view.displayOnBoarding()
}
}
override fun onFinish() {
mainScope.cancel()
}
private fun resyncRegistration() {
mainScope.launch {
try {
integrationUseCase.updateRegistration(
DeviceRegistration(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
null,
null
)
)
} catch (e: Exception) {
Log.e(TAG, "Issue updating Registration", e)
}
}
}
}

View file

@ -0,0 +1,9 @@
package io.homeassistant.companion.android.home
interface HomeView {
fun showHomeAssistantVersion(version: String)
fun showEntitiesCount(count: Int)
fun displayOnBoarding()
fun displayMobileAppIntegration()
}

View file

@ -0,0 +1,9 @@
package io.homeassistant.companion.android.onboarding
import java.net.URL
data class HomeAssistantInstance(
val name: String,
val url: URL,
val version: String
)

View file

@ -0,0 +1,172 @@
package io.homeassistant.companion.android.onboarding
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.wear.activity.ConfirmationActivity
import androidx.wear.widget.WearableRecyclerView
import com.google.android.gms.tasks.Tasks
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.CapabilityInfo
import com.google.android.gms.wearable.DataMapItem
import com.google.android.gms.wearable.Wearable
import io.homeassistant.companion.android.DaggerPresenterComponent
import io.homeassistant.companion.android.PresenterModule
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
import io.homeassistant.companion.android.onboarding.authentication.AuthenticationActivity
import io.homeassistant.companion.android.onboarding.manual_setup.ManualSetupActivity
import io.homeassistant.companion.android.util.LoadingView
import javax.inject.Inject
class OnboardingActivity : AppCompatActivity(), OnboardingView {
private lateinit var adapter: ServerListAdapter
companion object {
private const val TAG = "OnboardingActivity"
fun newInstance(context: Context): Intent {
return Intent(context, OnboardingActivity::class.java)
}
}
@Inject
lateinit var presenter: OnboardingPresenter
private lateinit var loadingView: LoadingView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DaggerPresenterComponent
.builder()
.appComponent((application as GraphComponentAccessor).appComponent)
.presenterModule(PresenterModule(this))
.build()
.inject(this)
setContentView(R.layout.activity_onboarding)
loadingView = findViewById<LoadingView>(R.id.loading_view)
adapter = ServerListAdapter(ArrayList())
adapter.onInstanceClicked = { instance -> presenter.onAdapterItemClick(instance) }
adapter.onManualSetupClicked = { this.startManualSetup() }
findViewById<WearableRecyclerView>(R.id.server_list)?.apply {
layoutManager = LinearLayoutManager(this@OnboardingActivity)
isEdgeItemsCenteringEnabled = true
adapter = this@OnboardingActivity.adapter
}
}
override fun onResume() {
super.onResume()
loadingView.visibility = View.GONE
// Add listener to exchange authentication tokens
Wearable.getDataClient(this).addListener(presenter)
// Check for current instances
Thread { findExistingInstances() }.start()
// Request authentication token in separate task
Thread { requestInstances() }.start()
}
override fun onPause() {
super.onPause()
Wearable.getDataClient(this).removeListener(presenter)
}
override fun startAuthentication(flowId: String) {
startActivity(AuthenticationActivity.newInstance(this, flowId))
}
override fun startManualSetup() {
startActivity(ManualSetupActivity.newInstance(this))
}
override fun showLoading() {
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(R.string.failed_connection))
}
startActivity(intent)
loadingView.visibility = View.GONE
}
override fun onInstanceFound(instance: HomeAssistantInstance) {
Log.d(TAG, "onInstanceFound: ${instance.name}")
if (!adapter.servers.contains(instance)) {
adapter.servers.add(instance)
adapter.notifyDataSetChanged()
Log.d(TAG, "onInstanceFound: added ${instance.name}")
}
}
override fun onInstanceLost(instance: HomeAssistantInstance) {
if (adapter.servers.contains(instance)) {
adapter.servers.remove(instance)
adapter.notifyDataSetChanged()
}
}
private fun findExistingInstances() {
Log.d(TAG, "findExistingInstances")
Tasks.await(Wearable.getDataClient(this).getDataItems(Uri.parse("wear://*/home_assistant_instance"))).apply {
Log.d(TAG, "findExistingInstances: success, found ${this.count}")
this.forEach { item ->
val instance = presenter.getInstance(DataMapItem.fromDataItem(item).dataMap)
this@OnboardingActivity.runOnUiThread {
onInstanceFound(instance)
}
}
}
}
private fun requestInstances() {
Log.d(TAG, "requestInstances")
// Find all nodes that are capable
val capabilityInfo: CapabilityInfo = Tasks.await(
Wearable.getCapabilityClient(this)
.getCapability(
"request_home_assistant_instance",
CapabilityClient.FILTER_REACHABLE
)
)
if (capabilityInfo.nodes.size == 0) {
Log.d(TAG, "requestInstances: No nodes found")
}
capabilityInfo.nodes.forEach { node ->
Wearable.getMessageClient(this).sendMessage(
node.id,
"/request_home_assistant_instance",
ByteArray(0)
).apply {
addOnSuccessListener { Log.d(TAG, "requestInstances: request home assistant instances from $node.id: ${node.displayName}") }
addOnFailureListener { Log.w(TAG, "requestInstances: failed to request home assistant instances from $node.id: ${node.displayName}") }
}
}
}
override fun onDestroy() {
presenter.onFinish()
super.onDestroy()
}
}

View file

@ -0,0 +1,13 @@
package io.homeassistant.companion.android.onboarding
import com.google.android.gms.wearable.DataClient
import com.google.android.gms.wearable.DataMap
interface OnboardingPresenter : DataClient.OnDataChangedListener {
fun onAdapterItemClick(instance: HomeAssistantInstance)
fun onFinish()
fun getInstance(map: DataMap): HomeAssistantInstance
}

View file

@ -0,0 +1,85 @@
package io.homeassistant.companion.android.onboarding
import android.util.Log
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 io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
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.net.URL
import javax.inject.Inject
class OnboardingPresenterImpl @Inject constructor(
private val view: OnboardingView,
private val authenticationUseCase: AuthenticationRepository,
private val urlUseCase: UrlRepository
) : OnboardingPresenter {
companion object {
private const val TAG = "OnboardingPresenter"
}
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onDataChanged(dataEvents: DataEventBuffer) {
Log.d(TAG, "onDataChanged: [${dataEvents.count}]")
dataEvents.forEach { event ->
if (event.type == DataEvent.TYPE_CHANGED) {
event.dataItem.also { item ->
if (item.uri.path?.compareTo("/home_assistant_instance") == 0) {
Log.d(TAG, "onDataChanged: found home_assistant_instance")
val instance = getInstance(DataMapItem.fromDataItem(item).dataMap)
view.onInstanceFound(instance)
}
}
} else if (event.type == DataEvent.TYPE_DELETED) {
event.dataItem.also { item ->
if (item.uri.path?.compareTo("/home_assistant_instance") == 0) {
val instance = getInstance(DataMapItem.fromDataItem(item).dataMap)
view.onInstanceLost(instance)
}
}
}
}
}
override fun getInstance(map: DataMap): HomeAssistantInstance {
map.apply {
return HomeAssistantInstance(
getString("name", ""),
URL(getString("url", "")),
getString("version", "")
)
}
}
override fun onAdapterItemClick(instance: HomeAssistantInstance) {
Log.d(TAG, "onAdapterItemClick: ${instance.name}")
view.showLoading()
mainScope.launch {
// Set current url
urlUseCase.saveUrl(instance.url.toString())
// Initiate login flow
try {
val flowInit: LoginFlowInit = authenticationUseCase.initiateLoginFlow()
Log.d(TAG, "Created login flow step ${flowInit.stepId}: ${flowInit.flowId}")
view.startAuthentication(flowInit.flowId)
} catch (e: Exception) {
Log.e(TAG, "Unable to initiate login flow", e)
view.showError()
}
}
}
override fun onFinish() {
mainScope.cancel()
}
}

View file

@ -0,0 +1,13 @@
package io.homeassistant.companion.android.onboarding
interface OnboardingView {
fun startAuthentication(flowId: String)
fun startManualSetup()
fun onInstanceFound(instance: HomeAssistantInstance)
fun onInstanceLost(instance: HomeAssistantInstance)
fun showLoading()
fun showError()
}

View file

@ -0,0 +1,80 @@
package io.homeassistant.companion.android.onboarding
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.onboarding.viewHolders.HeaderViewHolder
import io.homeassistant.companion.android.onboarding.viewHolders.InstanceViewHolder
import io.homeassistant.companion.android.onboarding.viewHolders.LoadingViewHolder
import io.homeassistant.companion.android.onboarding.viewHolders.ManualSetupViewHolder
import kotlin.math.min
class ServerListAdapter(
val servers: ArrayList<HomeAssistantInstance>
) : RecyclerView.Adapter<ViewHolder>() {
lateinit var onInstanceClicked: (HomeAssistantInstance) -> Unit
lateinit var onManualSetupClicked: () -> Unit
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
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): ViewHolder {
return when (viewType) {
TYPE_INSTANCE -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.listitem_instance, parent, false)
InstanceViewHolder(view, onInstanceClicked)
}
TYPE_HEADER -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.listitem_header, parent, false)
HeaderViewHolder(view)
}
TYPE_MANUAL -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.listitem_instance, parent, false)
ManualSetupViewHolder(view, onManualSetupClicked)
}
else -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.listitem_loading, parent, false)
LoadingViewHolder(view)
}
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
if (holder is InstanceViewHolder && position <= servers.size) {
holder.server = servers[position - 1]
} else if (holder is ManualSetupViewHolder) {
holder.text.setText(R.string.manual_setup)
} else if (holder is HeaderViewHolder) {
if (position == 0) {
holder.headerTextView.setText(R.string.list_header_instances)
} else {
holder.headerTextView.setText(R.string.other)
}
}
}
override fun getItemCount() = min(servers.size + 3, 4)
override fun getItemViewType(position: Int): Int {
return when {
position == 0 || position == this.itemCount - 2 -> TYPE_HEADER
position == this.itemCount - 1 -> TYPE_MANUAL
servers.size > 0 -> TYPE_INSTANCE
else -> TYPE_LOADING
}
}
}

View file

@ -0,0 +1,91 @@
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 android.widget.EditText
import androidx.appcompat.app.AppCompatActivity
import androidx.wear.activity.ConfirmationActivity
import com.google.android.material.floatingactionbutton.FloatingActionButton
import io.homeassistant.companion.android.DaggerPresenterComponent
import io.homeassistant.companion.android.PresenterModule
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
import io.homeassistant.companion.android.onboarding.integration.MobileAppIntegrationActivity
import io.homeassistant.companion.android.util.LoadingView
import javax.inject.Inject
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 loadingView: LoadingView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent == null || !intent.hasExtra("flowId")) {
Log.e(TAG, "Flow id not specified, canceling authentication")
finish()
}
DaggerPresenterComponent
.builder()
.appComponent((application as GraphComponentAccessor).appComponent)
.presenterModule(PresenterModule(this))
.build()
.inject(this)
setContentView(R.layout.activity_authentication)
loadingView = findViewById<LoadingView>(R.id.loading_view)
findViewById<FloatingActionButton>(R.id.button_next).setOnClickListener {
presenter.onNextClicked(
intent.getStringExtra("flowId")!!,
findViewById<EditText>(R.id.username).text.toString(),
findViewById<EditText>(R.id.password).text.toString()
)
}
}
override fun onResume() {
super.onResume()
loadingView.visibility = View.GONE
}
override fun startIntegration() {
startActivity(MobileAppIntegrationActivity.newInstance(this))
}
override fun showLoading() {
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(R.string.failed_authentication))
}
startActivity(intent)
loadingView.visibility = View.GONE
}
override fun onDestroy() {
presenter.onFinish()
super.onDestroy()
}
}

View file

@ -0,0 +1,8 @@
package io.homeassistant.companion.android.onboarding.authentication
interface AuthenticationPresenter {
fun onNextClicked(flowId: String, username: String, password: String)
fun onFinish()
}

View file

@ -0,0 +1,44 @@
package io.homeassistant.companion.android.onboarding.authentication
import android.util.Log
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowCreateEntry
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(
private val view: AuthenticationView,
private val authenticationUseCase: AuthenticationRepository
) : AuthenticationPresenter {
companion object {
private const val TAG = "AuthenticationPresenter"
}
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onNextClicked(flowId: String, username: String, password: String) {
view.showLoading()
Log.d(TAG, "onNextClicked")
mainScope.launch {
try {
val flowCreateEntry: LoginFlowCreateEntry = authenticationUseCase.loginAuthentication(flowId, username, password)
Log.d(TAG, "Authenticated result: ${flowCreateEntry.result}")
authenticationUseCase.registerAuthorizationCode(flowCreateEntry.result)
Log.d(TAG, "Finished!")
view.startIntegration()
} catch (e: Exception) {
Log.e(TAG, "Unable to authenticate", e)
view.showError()
}
}
}
override fun onFinish() {
mainScope.cancel()
}
}

View file

@ -0,0 +1,9 @@
package io.homeassistant.companion.android.onboarding.authentication
interface AuthenticationView {
fun startIntegration()
fun showLoading()
fun showError()
}

View file

@ -0,0 +1,86 @@
package io.homeassistant.companion.android.onboarding.integration
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.EditText
import androidx.appcompat.app.AppCompatActivity
import androidx.wear.activity.ConfirmationActivity
import com.google.android.material.floatingactionbutton.FloatingActionButton
import io.homeassistant.companion.android.DaggerPresenterComponent
import io.homeassistant.companion.android.PresenterModule
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
import io.homeassistant.companion.android.home.HomeActivity
import io.homeassistant.companion.android.util.LoadingView
import javax.inject.Inject
class MobileAppIntegrationActivity : AppCompatActivity(), MobileAppIntegrationView {
companion object {
private const val TAG = "MobileAppIntegrationActivity"
fun newInstance(context: Context): Intent {
return Intent(context, MobileAppIntegrationActivity::class.java)
}
}
@Inject
lateinit var presenter: MobileAppIntegrationPresenter
private lateinit var loadingView: LoadingView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DaggerPresenterComponent
.builder()
.appComponent((application as GraphComponentAccessor).appComponent)
.presenterModule(PresenterModule(this))
.build()
.inject(this)
setContentView(R.layout.activity_integration)
loadingView = findViewById<LoadingView>(R.id.loading_view)
val serverUrl: EditText = findViewById(R.id.server_url)
serverUrl.setText(Build.MODEL)
findViewById<FloatingActionButton>(R.id.finish).setOnClickListener {
presenter.onRegistrationAttempt(serverUrl.text.toString())
}
}
override fun onResume() {
super.onResume()
loadingView.visibility = View.GONE
}
override fun deviceRegistered() {
val intent = HomeActivity.newInstance(this)
// empty the back stack
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
}
override fun showLoading() {
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(R.string.failed_registration))
}
startActivity(intent)
loadingView.visibility = View.GONE
}
override fun onDestroy() {
presenter.onFinish()
super.onDestroy()
}
}

View file

@ -0,0 +1,6 @@
package io.homeassistant.companion.android.onboarding.integration
interface MobileAppIntegrationPresenter {
fun onRegistrationAttempt(deviceName: String)
fun onFinish()
}

View file

@ -0,0 +1,50 @@
package io.homeassistant.companion.android.onboarding.integration
import android.util.Log
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
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 MobileAppIntegrationPresenterImpl @Inject constructor(
private val view: MobileAppIntegrationView,
private val integrationUseCase: IntegrationRepository
) : MobileAppIntegrationPresenter {
companion object {
internal const val TAG = "IntegrationPresenter"
}
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
private suspend fun createRegistration(deviceName: String): DeviceRegistration {
return DeviceRegistration(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
deviceName
)
}
override fun onRegistrationAttempt(deviceName: String) {
view.showLoading()
mainScope.launch {
val deviceRegistration = createRegistration(deviceName)
try {
integrationUseCase.registerDevice(deviceRegistration)
} catch (e: Exception) {
Log.e(TAG, "Unable to register with Home Assistant", e)
view.showError()
return@launch
}
view.deviceRegistered()
}
}
override fun onFinish() {
mainScope.cancel()
}
}

View file

@ -0,0 +1,10 @@
package io.homeassistant.companion.android.onboarding.integration
interface MobileAppIntegrationView {
fun deviceRegistered()
fun showLoading()
fun showError()
}

View file

@ -0,0 +1,79 @@
package io.homeassistant.companion.android.onboarding.manual_setup
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.EditText
import androidx.appcompat.app.AppCompatActivity
import androidx.wear.activity.ConfirmationActivity
import com.google.android.material.floatingactionbutton.FloatingActionButton
import io.homeassistant.companion.android.DaggerPresenterComponent
import io.homeassistant.companion.android.PresenterModule
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
import io.homeassistant.companion.android.onboarding.authentication.AuthenticationActivity
import io.homeassistant.companion.android.util.LoadingView
import javax.inject.Inject
class ManualSetupActivity : AppCompatActivity(), ManualSetupView {
companion object {
private const val TAG = "ManualSetupActivity"
fun newInstance(context: Context): Intent {
return Intent(context, ManualSetupActivity::class.java)
}
}
@Inject
lateinit var presenter: ManualSetupPresenter
private lateinit var loadingView: LoadingView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DaggerPresenterComponent
.builder()
.appComponent((application as GraphComponentAccessor).appComponent)
.presenterModule(PresenterModule(this))
.build()
.inject(this)
setContentView(R.layout.activity_manual_setup)
loadingView = findViewById<LoadingView>(R.id.loading_view)
findViewById<FloatingActionButton>(R.id.button_next).setOnClickListener {
presenter.onNextClicked(findViewById<EditText>(R.id.server_url).text.toString())
}
}
override fun startAuthentication(flowId: String) {
startActivity(AuthenticationActivity.newInstance(this, flowId))
}
override fun showLoading() {
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(R.string.failed_connection))
}
startActivity(intent)
loadingView.visibility = View.GONE
}
override fun onResume() {
super.onResume()
loadingView.visibility = View.GONE
}
override fun onDestroy() {
presenter.onFinish()
super.onDestroy()
}
}

View file

@ -0,0 +1,8 @@
package io.homeassistant.companion.android.onboarding.manual_setup
interface ManualSetupPresenter {
fun onNextClicked(url: String)
fun onFinish()
}

View file

@ -0,0 +1,47 @@
package io.homeassistant.companion.android.onboarding.manual_setup
import android.util.Log
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
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 javax.inject.Inject
class ManualSetupPresenterImpl @Inject constructor(
private val view: ManualSetupView,
private val authenticationUseCase: AuthenticationRepository,
private val urlUseCase: UrlRepository
) : ManualSetupPresenter {
companion object {
private const val TAG = "ManualSetupPresenter"
}
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun onNextClicked(url: String) {
view.showLoading()
mainScope.launch {
// Set current url
urlUseCase.saveUrl(url)
// Initiate login flow
try {
val flowInit: LoginFlowInit = authenticationUseCase.initiateLoginFlow()
Log.d(TAG, "Created login flow step ${flowInit.stepId}: ${flowInit.flowId}")
view.startAuthentication(flowInit.flowId)
} catch (e: Exception) {
Log.e(TAG, "Unable to initiate login flow", e)
view.showError()
}
}
}
override fun onFinish() {
mainScope.cancel()
}
}

View file

@ -0,0 +1,9 @@
package io.homeassistant.companion.android.onboarding.manual_setup
interface ManualSetupView {
fun startAuthentication(flowId: String)
fun showLoading()
fun showError()
}

View file

@ -0,0 +1,11 @@
package io.homeassistant.companion.android.onboarding.viewHolders
import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import io.homeassistant.companion.android.R
class HeaderViewHolder(v: View) : RecyclerView.ViewHolder(v) {
val headerTextView = v.findViewById<TextView>(R.id.headerTextView)
}

View file

@ -0,0 +1,29 @@
package io.homeassistant.companion.android.onboarding.viewHolders
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.onboarding.HomeAssistantInstance
class InstanceViewHolder(v: View, val onClick: (HomeAssistantInstance) -> Unit) :
RecyclerView.ViewHolder(v), View.OnClickListener {
private val name: TextView = v.findViewById(R.id.name)
var server: HomeAssistantInstance? = null
set(value) {
name.text = value?.name
field = value
}
init {
v.setOnClickListener {
server?.let { onClick(it) }
}
}
override fun onClick(v: View) {
Log.d("ServerListAdapter", "Clicked")
}
}

View file

@ -0,0 +1,6 @@
package io.homeassistant.companion.android.onboarding.viewHolders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class LoadingViewHolder(v: View) : RecyclerView.ViewHolder(v)

View file

@ -0,0 +1,19 @@
package io.homeassistant.companion.android.onboarding.viewHolders
import android.view.View
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import io.homeassistant.companion.android.R
class ManualSetupViewHolder(v: View, val onClick: () -> Unit) :
RecyclerView.ViewHolder(v) {
val text: TextView = v.findViewById(R.id.name)
init {
// Set onclick listener
v.setOnClickListener {
onClick()
}
}
}

View file

@ -0,0 +1,23 @@
package io.homeassistant.companion.android.util
import android.content.Context
import android.util.AttributeSet
import android.widget.LinearLayout
import android.widget.TextView
import io.homeassistant.companion.android.R
class LoadingView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : LinearLayout(context, attrs) {
init {
inflate(context, R.layout.view_loading, this)
val loadingText: TextView = findViewById(R.id.loading_text)
val attributes = context.obtainStyledAttributes(attrs, R.styleable.LoadingView)
loadingText.text = attributes.getString(R.styleable.LoadingView_loading_text)
attributes.recycle()
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/colorOverlay" />
<corners android:radius="32dp"/>
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>

View file

@ -0,0 +1,69 @@
<?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/textView2"
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" />
<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/textView2" />
<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:importantForAutofill="no" />
</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>

View file

@ -1,42 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.wear.widget.BoxInsetLayout xmlns:android="http://schemas.android.com/apk/res/android"
<ScrollView 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:background="@color/dark_grey"
android:padding="@dimen/box_inset_layout_padding"
tools:context=".Home"
android:fillViewport="true"
tools:context=".home.HomeActivity"
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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="@android:color/white"
android:textSize="12sp"
android:layout_marginTop="5dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageView
android:id="@+id/imageView"
android:layout_width="84dp"
android:layout_height="84dp"
android:contentDescription="@string/app_name"
android:src="@drawable/app_icon" />
<ImageView
android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:contentDescription="@string/app_name"
android:src="@drawable/app_icon"
app:layout_constraintBottom_toTopOf="@+id/text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:text="@string/app_name"
android:textColor="@android:color/white"
android:textSize="12sp" />
</androidx.wear.widget.BoxInsetLayout>
<TextView
android:id="@+id/txt_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:textColor="@android:color/white"
android:textSize="12sp"
android:text="@string/loading"/>
<TextView
android:id="@+id/txt_entities"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:textColor="@android:color/white"
android:textSize="12sp"
android:text="@string/loading"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_logout"
android:layout_height="48dp"
android:layout_width="wrap_content"
android:layout_margin="8dp"
app:cornerRadius="24dp"
android:backgroundTint="@color/colorWarning"
android:text="@string/logout" />
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,60 @@
<?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.integration.MobileAppIntegrationActivity"
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">
<EditText
android:id="@+id/server_url"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ems="10"
android:hint="@string/device_name"
android:inputType="textPersonName"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
android:importantForAutofill="no" />
<TextView
android:id="@+id/textView"
style="@style/HeaderText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/registerDevice"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/finish"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
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_check" />
</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_registration"
android:visibility="gone" />
</androidx.wear.widget.BoxInsetLayout>

View file

@ -0,0 +1,58 @@
<?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: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">
<EditText
android:id="@+id/server_url"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ems="10"
android:hint="@string/input_url_hint"
android:inputType="textPersonName"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
android:importantForAutofill="no" />
<TextView
android:id="@+id/textView"
style="@style/HeaderText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/choose_server"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<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" />
</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_connection"
android:visibility="gone" />
</androidx.wear.widget.BoxInsetLayout>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
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">
<androidx.wear.widget.WearableRecyclerView
android:id="@+id/server_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
tools:listitem="@layout/listitem_instance">
</androidx.wear.widget.WearableRecyclerView>
<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_connection"
android:visibility="gone" />
</FrameLayout>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/headerTextView"
style="@style/HeaderText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_alignParentTop="true"
android:layout_centerHorizontal="true"
tools:text="@string/list_header_instances" />
</RelativeLayout>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_margin="8dp"
android:background="@drawable/item_background"
android:orientation="vertical"
android:padding="20dp">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_centerVertical="true"
android:ellipsize="marquee"
android:maxLines="1"
tools:text="@string/input_url_hint" />
</RelativeLayout>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_margin="8dp"
android:orientation="vertical"
android:padding="20dp">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_centerVertical="true"
android:ellipsize="marquee"
android:maxLines="1"
tools:text="@string/loading" />
</RelativeLayout>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- TODO: might want to wrap in SwipeDismissFrameLayout to be able to cancel loading -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:background="@color/colorActivityBackground">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/loading_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/attempting_authentication"
android:textAlignment="center" />
<androidx.core.widget.ContentLoadingProgressBar
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp" />
</LinearLayout>

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorDialogBackground">#111111</color>
<color name="colorDialogMessage">@android:color/white</color>
<color name="colorDialogTitle">@android:color/white</color>
<color name="colorActivityBackground">#1c1c1c</color>
<color name="colorPrimary">#03A9F4</color>
<color name="colorPrimaryDark">#111111</color>
<color name="colorAccent">#03A9F4</color>
<color name="colorOnPrimary">@android:color/white</color>
<color name="colorWarning">#D32F2F</color>
<color name="colorHeadline1">@android:color/white</color>
<color name="colorHeadline2">@android:color/white</color>
<color name="colorActionBar">#1c1c1c</color>
<color name="colorIcon">@android:color/white</color>
<color name="colorWidgetButtonBackground">#1c1c1c</color>
<color name="colorWidgetButtonLabel">#E6E6E6</color>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="LoadingView">
<attr name="loading_text" format="string" />
</declare-styleable>
</resources>

View file

@ -1,18 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorDialogBackground">@android:color/white</color>
<color name="colorDialogMessage">@android:color/black</color>
<color name="colorDialogTitle">@android:color/black</color>
<color name="colorActivityBackground">@android:color/white</color>
<color name="colorDialogBackground">#111111</color>
<color name="colorDialogMessage">@android:color/white</color>
<color name="colorDialogTitle">@android:color/white</color>
<color name="colorActivityBackground">#1c1c1c</color>
<color name="colorPrimary">#03A9F4</color>
<color name="colorPrimaryDark">#0288D1</color>
<color name="colorPrimaryDark">#111111</color>
<color name="colorAccent">#03A9F4</color>
<color name="colorOnPrimary">@android:color/white</color>
<color name="colorWarning">#D32F2F</color>
<color name="colorHeadline1">@android:color/black</color>
<color name="colorHeadline2">@android:color/black</color>
<color name="colorActionBar">#03A9F4</color>
<color name="colorIcon">@android:color/black</color>
<color name="colorWidgetButtonBackground">@android:color/white</color>
<color name="colorWidgetButtonLabel">#3A3A3A</color>
<color name="colorHint">#70FFFFFF</color>
<color name="colorHeadline1">@android:color/white</color>
<color name="colorHeadline2">@android:color/white</color>
<color name="colorActionBar">#1c1c1c</color>
<color name="colorIcon">@android:color/white</color>
<color name="colorOverlay">#24FFFFFF</color>
<color name="colorWidgetButtonBackground">#1c1c1c</color>
<color name="colorWidgetButtonLabel">#E6E6E6</color>
</resources>

View file

@ -1,3 +1,30 @@
<resources>
<string name="app_name">Home Assistant Wear OS</string>
<string name="app_name">Home Assistant</string>
<string name="attempting_authentication">Authenticating…</string>
<string name="attempting_connection">Connecting…</string>
<string name="attempting_registration">Registering application…</string>
<string name="button_forward">Next</string>
<string name="choose_server">Choose server</string>
<string name="device_name">Device Name</string>
<plurals name="entities_found">
<item quantity="one">%d entity found</item>
<item quantity="other">%d entities found</item>
</plurals>
<string name="failed_authentication">Could not authenticate</string>
<string name="failed_connection">Could not connect</string>
<string name="failed_registration">Could not register</string>
<string name="finish">Finish</string>
<string name="input_url">Home Assistant URL</string>
<string name="input_url_hint">https://example.duckdns.org:8123</string>
<string name="list_header_instances">Instances</string>
<string name="list_header_other">Other</string>
<string name="loading">Loading…</string>
<string name="login">Login</string>
<string name="logout">Logout</string>
<string name="manual_setup">Manual setup</string>
<string name="other">Other</string>
<string name="password">Password</string>
<string name="registerDevice">Register watch</string>
<string name="username">Username</string>
<string name="version">Version: %s</string>
</resources>

View file

@ -1,10 +1,32 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.HomeAssistant" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="colorSecondary">@color/colorAccent</item>
<item name="colorOnSecondary">@color/white</item>
<item name="colorButtonNormal">@color/colorPrimary</item>
<item name="colorSurface">@color/colorOverlay</item>
<item name="colorOnSurface">@color/colorAccent</item>
<item name="android:colorBackground">@color/colorActivityBackground</item>
<item name="android:navigationBarColor">@color/colorPrimaryDark</item>
<item name="android:textSize">16sp</item>
<item name="android:textColor">@color/white</item>
<item name="android:fontFamily">sans-serif</item>
<item name="editTextStyle">@style/editText</item>
</style>
<style name="editText" parent="@android:style/Widget.EditText">
<item name="android:textSize">14sp</item>
<item name="android:textColor">@color/white</item>
<item name="android:textColorHint">@color/colorHint</item>
<item name="android:backgroundTint">@color/colorAccent</item>
<item name="android:paddingTop">4dp</item>
<item name="android:paddingBottom">8dp</item>
</style>
<style name="HeaderText" parent="android:Widget.TextView">
<item name="android:textColor">@color/colorPrimary</item>
<item name="android:textSize">20sp</item>
<item name="android:fontFamily">sans-serif-medium</item>
</style>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@array/android_wear_capabilities">
<string-array name="android_wear_capabilities">
<item>authentication_token</item>
</string-array>
</resources>