mirror of
https://github.com/home-assistant/android
synced 2024-10-02 22:34:46 +00:00
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:
parent
86c12799e7
commit
ba8345a3cf
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -14,6 +14,8 @@ interface AuthenticationRepository {
|
|||
|
||||
suspend fun retrieveExternalAuthentication(forceRefresh: Boolean): String
|
||||
|
||||
suspend fun retrieveAccessToken(): String
|
||||
|
||||
suspend fun revokeSession()
|
||||
|
||||
suspend fun getSessionState(): SessionState
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>)
|
||||
|
|
|
@ -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?,
|
||||
|
|
|
@ -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>> {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
|
@ -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?
|
||||
)
|
|
@ -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()) {
|
||||
|
|
|
@ -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>)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue