Initial WebSocket Implementation (#1884)

* Initial setup of WebSockets!

* Got some good sets of table tennis going.

* Move to a more kotlin friendly way to lock.

* Functional get config call.

* Remove testing function.

* Linting.

* Migrate get config calls to websockets!

* Working retries.

* Get services now as websocket request.

* Remove unused service call via api.

* Fix issue with widget not prompting the correct items.

* Migrate to websocket get states.

* ktlint.

* Review Comments.
This commit is contained in:
Justin Bassett 2021-11-09 20:44:05 -05:00 committed by GitHub
parent 86c12799e7
commit ba8345a3cf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 434 additions and 229 deletions

View file

@ -8,6 +8,7 @@ import io.homeassistant.companion.android.common.data.integration.IntegrationRep
import io.homeassistant.companion.android.common.data.integration.impl.entities.RateLimitResponse
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.url.UrlRepository
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
import io.homeassistant.companion.android.settings.language.LanguagesManager
import io.homeassistant.companion.android.themes.ThemesManager
import kotlinx.coroutines.CoroutineScope
@ -26,7 +27,8 @@ class SettingsPresenterImpl @Inject constructor(
private val authenticationUseCase: AuthenticationRepository,
private val prefsRepository: PrefsRepository,
private val themesManager: ThemesManager,
private val langsManager: LanguagesManager
private val langsManager: LanguagesManager,
private val webSocketRepository: WebSocketRepository
) : SettingsPresenter, PreferenceDataStore() {
companion object {

View file

@ -175,15 +175,15 @@ class WebViewPresenterImpl @Inject constructor(
}
override suspend fun getStatusBarAndNavigationBarColor(webViewColor: String): Int = withContext(Dispatchers.IO) {
var statusbarNavBarColor = 0
var statusBarNavBarColor = 0
Log.d(TAG, "Try getting status bar/navigation bar color from webviews color \"$webViewColor\"")
if (!webViewColor.isNullOrEmpty() && webViewColor != "null" && webViewColor.length >= 2) {
val trimmedColorString = webViewColor.substring(1, webViewColor.length - 1).trim()
Log.d(TAG, "Color from webview is \"$trimmedColorString\"")
try {
statusbarNavBarColor = parseColorWithRgb(trimmedColorString)
Log.i(TAG, "Found color $statusbarNavBarColor for status bar/navigation bar")
statusBarNavBarColor = parseColorWithRgb(trimmedColorString)
Log.i(TAG, "Found color $statusBarNavBarColor for status bar/navigation bar")
} catch (e: Exception) {
Log.w(TAG, "Could not get status bar/navigation bar color from webview. Try getting status bar/navigation bar color from HA", e)
}
@ -191,25 +191,11 @@ class WebViewPresenterImpl @Inject constructor(
Log.w(TAG, "Could not get status bar/navigation bar color from webview. Color \"$webViewColor\" is not a valid color. Try getting status bar/navigation bar color from HA")
}
if (statusbarNavBarColor == 0) {
Log.d(TAG, "Try getting status bar/navigation bar color from HA")
runBlocking {
try {
val colorString = integrationUseCase.getThemeColor()
Log.d(TAG, "Color from HA is \"$colorString\"")
if (!colorString.isNullOrEmpty()) {
statusbarNavBarColor = parseColorWithRgb(colorString)
Log.i(TAG, "Found color $statusbarNavBarColor for status bar/navigation bar")
} else {
Log.e(TAG, "Could not get status bar/navigation bar color from HA. No theme color defined in theme variable \"app-header-background-color\"")
}
} catch (e: Exception) {
Log.e(TAG, "Could not get status bar/navigation bar color from HA.", e)
}
}
if (statusBarNavBarColor == 0) {
Log.w(TAG, "Couldn't get color for status bar.")
}
return@withContext statusbarNavBarColor
return@withContext statusBarNavBarColor
}
private fun parseColorWithRgb(colorString: String): Int {

View file

@ -82,7 +82,6 @@ class CameraWidgetConfigureActivity : BaseActivity() {
try {
// Fetch entities
val fetchedEntities = integrationUseCase.getEntities()
fetchedEntities.sortBy { e -> e.entityId }
fetchedEntities.forEach {
val entityId = it.entityId
val domain = entityId.split(".")[0]

View file

@ -122,9 +122,9 @@ class EntityWidgetConfigureActivity : BaseActivity() {
}
val entityAdapter = SingleItemArrayAdapter<Entity<Any>>(this) { it?.entityId ?: "" }
binding.widgetTextConfigAttribute.setAdapter(entityAdapter)
binding.widgetTextConfigAttribute.onFocusChangeListener = dropDownOnFocus
binding.widgetTextConfigAttribute.onItemClickListener = entityDropDownOnItemClick
binding.widgetTextConfigEntityId.setAdapter(entityAdapter)
binding.widgetTextConfigEntityId.onFocusChangeListener = dropDownOnFocus
binding.widgetTextConfigEntityId.onItemClickListener = entityDropDownOnItemClick
binding.widgetTextConfigAttribute.onFocusChangeListener = dropDownOnFocus
binding.widgetTextConfigAttribute.onItemClickListener = attributeDropDownOnItemClick
binding.widgetTextConfigAttribute.setOnClickListener {
@ -140,7 +140,6 @@ class EntityWidgetConfigureActivity : BaseActivity() {
try {
// Fetch entities
val fetchedEntities = integrationUseCase.getEntities()
fetchedEntities.sortBy { e -> e.entityId }
fetchedEntities.forEach {
entities[it.entityId] = it
}

View file

@ -111,7 +111,6 @@ class MediaPlayerControlsWidgetConfigureActivity : BaseActivity() {
try {
// Fetch entities
val fetchedEntities = integrationUseCase.getEntities()
fetchedEntities.sortBy { e -> e.entityId }
fetchedEntities.forEach {
val entityId = it.entityId
val domain = entityId.split(".")[0]

View file

@ -5,6 +5,7 @@ import io.homeassistant.companion.android.common.data.authentication.Authenticat
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.url.UrlRepository
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
@Component(modules = [DataModule::class])
interface AppComponent {
@ -16,4 +17,6 @@ interface AppComponent {
fun integrationUseCase(): IntegrationRepository
fun prefsUseCase(): PrefsRepository
fun webSocketRepository(): WebSocketRepository
}

View file

@ -5,6 +5,7 @@ import io.homeassistant.companion.android.common.data.authentication.Authenticat
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.url.UrlRepository
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
@Component(modules = [DataModule::class])
interface DataComponent {
@ -16,4 +17,6 @@ interface DataComponent {
fun integrationRepository(): IntegrationRepository
fun prefsRepository(): PrefsRepository
fun webSocketRepository(): WebSocketRepository
}

View file

@ -4,7 +4,7 @@ import android.os.Build
import dagger.Binds
import dagger.Module
import dagger.Provides
import io.homeassistant.companion.android.common.data.HomeAssistantRetrofit
import io.homeassistant.companion.android.common.data.HomeAssistantApis
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.impl.AuthenticationRepositoryImpl
@ -16,7 +16,10 @@ import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.prefs.PrefsRepositoryImpl
import io.homeassistant.companion.android.common.data.url.UrlRepository
import io.homeassistant.companion.android.common.data.url.UrlRepositoryImpl
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
import io.homeassistant.companion.android.common.data.websocket.impl.WebSocketRepositoryImpl
import io.homeassistant.companion.android.common.data.wifi.WifiHelper
import okhttp3.OkHttpClient
import javax.inject.Named
@Module(includes = [DataModule.Declaration::class])
@ -30,12 +33,16 @@ class DataModule(
) {
@Provides
fun provideAuthenticationService(homeAssistantRetrofit: HomeAssistantRetrofit): AuthenticationService =
homeAssistantRetrofit.retrofit.create(AuthenticationService::class.java)
fun provideAuthenticationService(homeAssistantApis: HomeAssistantApis): AuthenticationService =
homeAssistantApis.retrofit.create(AuthenticationService::class.java)
@Provides
fun providesIntegrationService(homeAssistantRetrofit: HomeAssistantRetrofit): IntegrationService =
homeAssistantRetrofit.retrofit.create(IntegrationService::class.java)
fun providesIntegrationService(homeAssistantApis: HomeAssistantApis): IntegrationService =
homeAssistantApis.retrofit.create(IntegrationService::class.java)
@Provides
fun providesOkHttpClient(homeAssistantApis: HomeAssistantApis): OkHttpClient =
homeAssistantApis.okHttpClient
@Provides
fun providesWifiHelper() = wifiHelper
@ -85,5 +92,8 @@ class DataModule(
@Binds
fun bindPrefsRepositoryImpl(repository: PrefsRepositoryImpl): PrefsRepository
@Binds
fun bindWebSocketRepositoryImpl(repository: WebSocketRepositoryImpl): WebSocketRepository
}
}

View file

@ -0,0 +1,89 @@
package io.homeassistant.companion.android.common.data
import android.os.Build
import android.webkit.CookieManager
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategy
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import io.homeassistant.companion.android.common.BuildConfig
import io.homeassistant.companion.android.common.data.url.UrlRepository
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class HomeAssistantApis @Inject constructor(private val urlRepository: UrlRepository) {
companion object {
private const val LOCAL_HOST = "http://localhost/"
private const val USER_AGENT = "User-Agent"
private const val USER_AGENT_STRING = "HomeAssistant/Android"
private val CALL_TIMEOUT = 30L
private val READ_TIMEOUT = 30L
}
private fun configureOkHttpClient(builder: OkHttpClient.Builder): OkHttpClient.Builder {
if (BuildConfig.DEBUG) {
builder.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
)
}
builder.addInterceptor {
return@addInterceptor if (it.request().url.toString().contains(LOCAL_HOST)) {
val newRequest = runBlocking {
it.request().newBuilder()
.url(
it.request().url.toString()
.replace(LOCAL_HOST, urlRepository.getUrl().toString())
)
.header(
USER_AGENT,
"$USER_AGENT_STRING ${Build.MODEL} ${BuildConfig.VERSION_NAME}"
)
.build()
}
it.proceed(newRequest)
} else {
it.proceed(it.request())
}
}
// Only deal with cookies when on non wear device and for now I don't have a better
// way to determine if we are really on wear os....
// TODO: Please fix me.
var cookieManager: CookieManager? = null
try {
cookieManager = CookieManager.getInstance()
} catch (e: Exception) {
// Noop
}
if (cookieManager != null) {
builder.cookieJar(CookieJarCookieManagerShim())
}
builder.callTimeout(CALL_TIMEOUT, TimeUnit.SECONDS)
builder.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
return builder
}
val retrofit: Retrofit = Retrofit
.Builder()
.addConverterFactory(
JacksonConverterFactory.create(
ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE)
.registerKotlinModule()
)
)
.client(configureOkHttpClient(OkHttpClient.Builder()).build())
.baseUrl(LOCAL_HOST)
.build()
val okHttpClient = configureOkHttpClient(OkHttpClient.Builder()).build()
}

View file

@ -1,82 +0,0 @@
package io.homeassistant.companion.android.common.data
import android.os.Build
import android.webkit.CookieManager
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategy
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import io.homeassistant.companion.android.common.BuildConfig
import io.homeassistant.companion.android.common.data.url.UrlRepository
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class HomeAssistantRetrofit @Inject constructor(urlRepository: UrlRepository) {
companion object {
private const val LOCAL_HOST = "http://localhost/"
private const val USER_AGENT = "User-Agent"
private const val USER_AGENT_STRING = "HomeAssistant/Android"
}
val retrofit: Retrofit = Retrofit
.Builder()
.addConverterFactory(
JacksonConverterFactory.create(
ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE)
.registerKotlinModule()
)
)
.client(
OkHttpClient.Builder().apply {
if (BuildConfig.DEBUG) {
addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
)
}
addInterceptor {
return@addInterceptor if (it.request().url.toString().contains(LOCAL_HOST)) {
val newRequest = runBlocking {
it.request().newBuilder()
.url(
it.request().url.toString()
.replace(LOCAL_HOST, urlRepository.getUrl().toString())
)
.header(
USER_AGENT,
"$USER_AGENT_STRING ${Build.MODEL} ${BuildConfig.VERSION_NAME}"
)
.build()
}
it.proceed(newRequest)
} else {
it.proceed(it.request())
}
}
// Only deal with cookies when on non wear device and for now I don't have a better
// way to determine if we are really on wear os....
// TODO: Please fix me.
var cookieManager: CookieManager? = null
try {
cookieManager = CookieManager.getInstance()
} catch (e: Exception) {
// Noop
}
if (cookieManager != null) {
cookieJar(CookieJarCookieManagerShim())
}
callTimeout(30L, TimeUnit.SECONDS)
readTimeout(30L, TimeUnit.SECONDS)
}.build()
)
.baseUrl(LOCAL_HOST)
.build()
}

View file

@ -14,6 +14,8 @@ interface AuthenticationRepository {
suspend fun retrieveExternalAuthentication(forceRefresh: Boolean): String
suspend fun retrieveAccessToken(): String
suspend fun revokeSession()
suspend fun getSessionState(): SessionState

View file

@ -71,6 +71,10 @@ class AuthenticationRepositoryImpl @Inject constructor(
return convertSession(ensureValidSession(forceRefresh))
}
override suspend fun retrieveAccessToken(): String {
return ensureValidSession(false).accessToken
}
override suspend fun revokeSession() {
val session = retrieveSession() ?: throw AuthorizationException()
authenticationService.revokeToken(session.refreshToken, AuthenticationService.REVOKE_ACTION)

View file

@ -35,13 +35,11 @@ interface IntegrationRepository {
suspend fun setWearHomeFavorites(favorites: Set<String>)
suspend fun getWearHomeFavorites(): Set<String>
suspend fun getThemeColor(): String
suspend fun getHomeAssistantVersion(): String
suspend fun getServices(): Array<Service>
suspend fun getServices(): List<Service>
suspend fun getEntities(): Array<Entity<Any>>
suspend fun getEntities(): List<Entity<Any>>
suspend fun getEntity(entityId: String): Entity<Map<String, Any>>
suspend fun callService(domain: String, service: String, serviceData: HashMap<String, Any>)

View file

@ -1,5 +1,8 @@
package io.homeassistant.companion.android.common.data.integration
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class ServiceFields(
val name: String?,
val description: String?,

View file

@ -14,7 +14,6 @@ import io.homeassistant.companion.android.common.data.integration.UpdateLocation
import io.homeassistant.companion.android.common.data.integration.ZoneAttributes
import io.homeassistant.companion.android.common.data.integration.impl.entities.EntityResponse
import io.homeassistant.companion.android.common.data.integration.impl.entities.FireEventRequest
import io.homeassistant.companion.android.common.data.integration.impl.entities.GetConfigResponse
import io.homeassistant.companion.android.common.data.integration.impl.entities.IntegrationRequest
import io.homeassistant.companion.android.common.data.integration.impl.entities.RateLimitRequest
import io.homeassistant.companion.android.common.data.integration.impl.entities.RateLimitResponse
@ -24,7 +23,8 @@ import io.homeassistant.companion.android.common.data.integration.impl.entities.
import io.homeassistant.companion.android.common.data.integration.impl.entities.Template
import io.homeassistant.companion.android.common.data.integration.impl.entities.UpdateLocationRequest
import io.homeassistant.companion.android.common.data.url.UrlRepository
import okhttp3.HttpUrl.Companion.get
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.util.regex.Pattern
import javax.inject.Inject
@ -35,6 +35,7 @@ class IntegrationRepositoryImpl @Inject constructor(
private val integrationService: IntegrationService,
private val authenticationRepository: AuthenticationRepository,
private val urlRepository: UrlRepository,
private val webSocketRepository: WebSocketRepository,
@Named("integration") private val localStorage: LocalStorage,
@Named("manufacturer") private val manufacturer: String,
@Named("model") private val model: String,
@ -359,7 +360,8 @@ class IntegrationRepositoryImpl @Inject constructor(
var causeException: Exception? = null
try {
checkRateLimits = integrationService.getRateLimit(RATE_LIMIT_URL, requestBody).rateLimits
checkRateLimits =
integrationService.getRateLimit(RATE_LIMIT_URL, requestBody).rateLimits
} catch (e: Exception) {
causeException = e
Log.e(TAG, "Unable to get notification rate limits", e)
@ -371,88 +373,47 @@ class IntegrationRepositoryImpl @Inject constructor(
else throw IntegrationException("Error calling checkRateLimits")
}
override suspend fun getThemeColor(): String {
val getConfigRequest =
IntegrationRequest(
"get_config",
null
)
var response: GetConfigResponse? = null
var causeException: Exception? = null
for (it in urlRepository.getApiUrls()) {
try {
response = integrationService.getConfig(it.toHttpUrlOrNull()!!, getConfigRequest)
} catch (e: Exception) {
if (causeException == null) causeException = e
// Ignore failure until we are out of URLS to try, but use the first exception as cause exception
}
if (response != null)
return response.themeColor
}
if (causeException != null) throw IntegrationException(causeException)
else throw IntegrationException("Error calling integration request get_config/themeColor")
}
override suspend fun getHomeAssistantVersion(): String {
val getConfigRequest =
IntegrationRequest(
"get_config",
null
)
var response: GetConfigResponse? = null
var causeException: Exception? = null
val current = System.currentTimeMillis()
val next = localStorage.getLong(PREF_CHECK_SENSOR_REGISTRATION_NEXT) ?: 0
if (current <= next)
return localStorage.getString(PREF_HA_VERSION) ?: "" // Skip checking HA version as it has not been 4 hours yet
return localStorage.getString(PREF_HA_VERSION)
?: "" // Skip checking HA version as it has not been 4 hours yet
for (it in urlRepository.getApiUrls()) {
try {
response = integrationService.getConfig(it.toHttpUrlOrNull()!!, getConfigRequest)
} catch (e: Exception) {
if (causeException == null) causeException = e
// Ignore failure until we are out of URLS to try, but use the first exception as cause exception
}
val response: GetConfigResponse = webSocketRepository.getConfig()
if (response != null) {
localStorage.putString(PREF_HA_VERSION, response.version)
localStorage.putLong(PREF_CHECK_SENSOR_REGISTRATION_NEXT, current + (14400000)) // 4 hours
return response.version
}
}
if (causeException != null) throw IntegrationException(causeException)
else throw IntegrationException("Error calling integration request get_config/version")
localStorage.putString(PREF_HA_VERSION, response.version)
localStorage.putLong(PREF_CHECK_SENSOR_REGISTRATION_NEXT, current + (14400000)) // 4 hours
return response.version
}
override suspend fun getServices(): Array<Service> {
val response = integrationService.getServices(authenticationRepository.buildBearerToken())
override suspend fun getServices(): List<Service> {
val response = webSocketRepository.getServices()
return response.flatMap {
it.services.map { service ->
Service(it.domain, service.key, service.value)
}
}.toTypedArray()
}.toList()
}
override suspend fun getEntities(): Array<Entity<Any>> {
val response = integrationService.getStates(authenticationRepository.buildBearerToken())
override suspend fun getEntities(): List<Entity<Any>> {
val response = webSocketRepository.getStates()
return response.map {
Entity(
it.entityId,
it.state,
it.attributes,
it.lastChanged,
it.lastUpdated,
it.context
)
}.toTypedArray()
return response
.map {
Entity(
it.entityId,
it.state,
it.attributes,
it.lastChanged,
it.lastUpdated,
it.context
)
}
.sortedBy { it.entityId }
.toList()
}
override suspend fun getEntity(entityId: String): Entity<Map<String, Any>> {

View file

@ -2,10 +2,7 @@ package io.homeassistant.companion.android.common.data.integration.impl
import io.homeassistant.companion.android.common.data.integration.ZoneAttributes
import io.homeassistant.companion.android.common.data.integration.impl.entities.CheckRateLimits
import io.homeassistant.companion.android.common.data.integration.impl.entities.DiscoveryInfoResponse
import io.homeassistant.companion.android.common.data.integration.impl.entities.DomainResponse
import io.homeassistant.companion.android.common.data.integration.impl.entities.EntityResponse
import io.homeassistant.companion.android.common.data.integration.impl.entities.GetConfigResponse
import io.homeassistant.companion.android.common.data.integration.impl.entities.IntegrationRequest
import io.homeassistant.companion.android.common.data.integration.impl.entities.RateLimitRequest
import io.homeassistant.companion.android.common.data.integration.impl.entities.RegisterDeviceRequest
@ -22,27 +19,12 @@ import retrofit2.http.Url
interface IntegrationService {
@GET("/api/discovery_info")
suspend fun discoveryInfo(
@Header("Authorization") auth: String
): DiscoveryInfoResponse
@POST("/api/mobile_app/registrations")
suspend fun registerDevice(
@Header("Authorization") auth: String,
@Body request: RegisterDeviceRequest
): RegisterDeviceResponse
@GET("/api/services")
suspend fun getServices(
@Header("Authorization") auth: String
): Array<DomainResponse>
@GET("/api/states")
suspend fun getStates(
@Header("Authorization") auth: String
): Array<EntityResponse<Any>>
@GET("/api/states/{entityId}")
suspend fun getState(
@Header("Authorization") auth: String,
@ -67,12 +49,6 @@ interface IntegrationService {
@Body request: IntegrationRequest
): Array<EntityResponse<ZoneAttributes>>
@POST
suspend fun getConfig(
@Url url: HttpUrl,
@Body request: IntegrationRequest
): GetConfigResponse
@POST
suspend fun getRateLimit(
@Url url: String,

View file

@ -1,13 +0,0 @@
package io.homeassistant.companion.android.common.data.integration.impl.entities
data class GetConfigResponse(
val latitude: Double,
val longitude: Double,
val elevation: Double,
val unitSystem: HashMap<String, String>,
val locationName: String,
val timeZone: String,
val components: Array<String>,
val version: String,
val themeColor: String
)

View file

@ -0,0 +1,15 @@
package io.homeassistant.companion.android.common.data.websocket
import io.homeassistant.companion.android.common.data.integration.impl.entities.EntityResponse
import io.homeassistant.companion.android.common.data.integration.impl.entities.ServiceCallRequest
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DomainResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse
interface WebSocketRepository {
suspend fun sendPing(): Boolean
suspend fun getConfig(): GetConfigResponse
suspend fun getStates(): List<EntityResponse<Any>>
suspend fun getServices(): List<DomainResponse>
suspend fun getPanels(): List<String>
suspend fun callService(request: ServiceCallRequest)
}

View file

@ -0,0 +1,224 @@
package io.homeassistant.companion.android.common.data.websocket.impl
import android.util.Log
import com.fasterxml.jackson.core.type.TypeReference
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.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.integration.ServiceData
import io.homeassistant.companion.android.common.data.integration.impl.entities.EntityResponse
import io.homeassistant.companion.android.common.data.integration.impl.entities.ServiceCallRequest
import io.homeassistant.companion.android.common.data.url.UrlRepository
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
import io.homeassistant.companion.android.common.data.websocket.impl.entities.DomainResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.SocketResponse
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
class WebSocketRepositoryImpl @Inject constructor(
private val okHttpClient: OkHttpClient,
private val urlRepository: UrlRepository,
private val authenticationRepository: AuthenticationRepository
) : WebSocketRepository, WebSocketListener() {
companion object {
private const val TAG = "WebSocketRepository"
}
private val ioScope = CoroutineScope(Dispatchers.IO + Job())
private val mapper = jacksonObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
private val responseCallbackJobs = mutableMapOf<Long, CancellableContinuation<SocketResponse>>()
private val subscriptionCallbacks = mutableMapOf<Long, (Boolean) -> Unit>()
private val id = AtomicLong(1)
private var connection: WebSocket? = null
private var connected = Job()
override suspend fun sendPing(): Boolean {
val socketResponse = sendMessage(
mapOf(
"type" to "ping"
)
)
return socketResponse.type == "pong"
}
override suspend fun getConfig(): GetConfigResponse {
val socketResponse = sendMessage(
mapOf(
"type" to "get_config"
)
)
return mapper.convertValue(socketResponse.result!!, GetConfigResponse::class.java)
}
override suspend fun getStates(): List<EntityResponse<Any>> {
val socketResponse = sendMessage(
mapOf(
"type" to "get_states"
)
)
return mapper.convertValue(
socketResponse.result!!,
object : TypeReference<List<EntityResponse<Any>>>() {}
)
}
override suspend fun getServices(): List<DomainResponse> {
val socketResponse = sendMessage(
mapOf(
"type" to "get_services"
)
)
val response = mapper.convertValue(
socketResponse.result!!,
object : TypeReference<Map<String, Map<String, ServiceData>>>() {}
)
return response.map {
DomainResponse(it.key, it.value)
}
}
override suspend fun getPanels(): List<String> {
TODO("Not yet implemented")
}
override suspend fun callService(request: ServiceCallRequest) {
TODO("Not yet implemented")
}
/**
* This method will
*/
@Synchronized
private suspend fun connect() {
if (connection != null && connected.isCompleted) {
return
}
val url = urlRepository.getUrl() ?: throw Exception("Unable to get URL for WebSocket")
val urlString = url.toString()
.replace("https://", "wss://")
.replace("http://", "ws://")
.plus("api/websocket")
connection = okHttpClient.newWebSocket(
Request.Builder().url(urlString).build(),
this
)
// Preemptively send auth
authenticate()
// Wait up to 30 seconds for auth response
withTimeout(30000) {
connected.join()
}
}
private suspend fun sendMessage(request: Map<*, *>): SocketResponse {
for (i in 0..1) {
val requestId = id.getAndIncrement()
val outbound = request.plus("id" to requestId)
Log.d(TAG, "Sending message number $requestId: $outbound")
connect()
try {
return withTimeout(30000) {
suspendCancellableCoroutine { cont ->
responseCallbackJobs[requestId] = cont
connection!!.send(mapper.writeValueAsString(outbound))
Log.d(TAG, "Message number $requestId sent")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error sending request number $requestId", e)
}
}
throw Exception("Unable to send message: $request")
}
private suspend fun authenticate() {
connection!!.send(
mapper.writeValueAsString(
mapOf(
"type" to "auth",
"access_token" to authenticationRepository.retrieveAccessToken()
)
)
)
}
private fun handleAuthComplete(successful: Boolean) {
if (successful)
connected.complete()
else
connected.completeExceptionally(Exception("Authentication Error"))
}
private fun handleMessage(response: SocketResponse) {
val id = response.id!!
responseCallbackJobs[id]?.resumeWith(Result.success(response))
responseCallbackJobs.remove(id)
}
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "Websocket: onOpen")
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d(TAG, "Websocket: onMessage (text)")
val message: SocketResponse = mapper.readValue(text)
Log.d(TAG, "Message number ${message.id} received: $text")
ioScope.launch {
when (message.type) {
"auth_required" -> Log.d(TAG, "Auth Requested")
"auth_ok" -> handleAuthComplete(true)
"auth_invalid" -> handleAuthComplete(false)
"pong", "result" -> handleMessage(message)
else -> Log.d(TAG, "Unknown message type: $text")
}
}
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
Log.d(TAG, "Websocket: onMessage (bytes)")
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "Websocket: onClosing code: $code, reason: $reason")
connected = Job()
connection = null
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "Websocket: onClosed")
connected = Job()
connection = null
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.d(TAG, "Websocket: onFailure")
Log.e(TAG, "Failure in websocket", t)
}
}

View file

@ -1,4 +1,4 @@
package io.homeassistant.companion.android.common.data.integration.impl.entities
package io.homeassistant.companion.android.common.data.websocket.impl.entities
import io.homeassistant.companion.android.common.data.integration.ServiceData

View file

@ -0,0 +1,15 @@
package io.homeassistant.companion.android.common.data.websocket.impl.entities
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class GetConfigResponse(
val latitude: Double,
val longitude: Double,
val elevation: Double,
val unitSystem: Map<String, String>,
val locationName: String,
val timeZone: String,
val components: List<String>,
val version: String,
)

View file

@ -0,0 +1,12 @@
package io.homeassistant.companion.android.common.data.websocket.impl.entities
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.databind.JsonNode
@JsonIgnoreProperties(ignoreUnknown = true)
data class SocketResponse(
val id: Long?,
val type: String,
val success: Boolean?,
val result: JsonNode?
)

View file

@ -135,7 +135,7 @@ class HomeActivity : ComponentActivity(), HomeView {
@ExperimentalWearMaterialApi
@Composable
private fun LoadHomePage(entities: Array<Entity<Any>>, favorites: MutableSet<String>) {
private fun LoadHomePage(entities: List<Entity<Any>>, favorites: MutableSet<String>) {
val rotaryEventDispatcher = RotaryEventDispatcher()
if (entities.isNullOrEmpty() && favorites.isNullOrEmpty()) {

View file

@ -8,7 +8,7 @@ interface HomePresenter {
fun onEntityClicked(entityId: String)
fun onLogoutClicked()
fun onFinish()
suspend fun getEntities(): Array<Entity<Any>>
suspend fun getEntities(): List<Entity<Any>>
suspend fun getWearHomeFavorites(): Set<String>
suspend fun setWearHomeFavorites(favorites: Set<String>)
}

View file

@ -46,12 +46,12 @@ class HomePresenterImpl @Inject constructor(
}
}
override suspend fun getEntities(): Array<Entity<Any>> {
override suspend fun getEntities(): List<Entity<Any>> {
return try {
integrationUseCase.getEntities()
} catch (e: Exception) {
Log.e(TAG, "Unable to get entities", e)
emptyArray()
emptyList()
}
}

View file

@ -8,6 +8,6 @@ import io.homeassistant.companion.android.common.data.integration.Entity
class EntityViewModel : ViewModel() {
var entitiesResponse: Array<Entity<Any>> by mutableStateOf(arrayOf())
var entitiesResponse: List<Entity<Any>> by mutableStateOf(mutableListOf())
var favoriteEntities: MutableSet<String> by mutableStateOf(mutableSetOf())
}