diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt b/app/src/main/java/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt index 44be067bdc9..99836fb6203 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/SettingsPresenterImpl.kt @@ -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 { diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index 88afaaa392a..3ca0cc3182a 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -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 { diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt index 9694e68a797..75286ea561c 100755 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt @@ -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] diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/entity/EntityWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/entity/EntityWidgetConfigureActivity.kt index 5aa581cc664..314600d16d6 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/entity/EntityWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/entity/EntityWidgetConfigureActivity.kt @@ -122,9 +122,9 @@ class EntityWidgetConfigureActivity : BaseActivity() { } val entityAdapter = SingleItemArrayAdapter>(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 } diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/media_player_controls/MediaPlayerControlsWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/media_player_controls/MediaPlayerControlsWidgetConfigureActivity.kt index b27db5bea99..6fec92b22c3 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/media_player_controls/MediaPlayerControlsWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/media_player_controls/MediaPlayerControlsWidgetConfigureActivity.kt @@ -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] diff --git a/common/src/main/java/io/homeassistant/companion/android/common/dagger/AppComponent.kt b/common/src/main/java/io/homeassistant/companion/android/common/dagger/AppComponent.kt index bd781d9f830..6d8fbcbb951 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/dagger/AppComponent.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/dagger/AppComponent.kt @@ -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 } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/dagger/DataComponent.kt b/common/src/main/java/io/homeassistant/companion/android/common/dagger/DataComponent.kt index fb69b972c4b..c2c76bfb5fc 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/dagger/DataComponent.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/dagger/DataComponent.kt @@ -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 } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/dagger/DataModule.kt b/common/src/main/java/io/homeassistant/companion/android/common/dagger/DataModule.kt index fc3eab8ee3d..3c2f8023edd 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/dagger/DataModule.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/dagger/DataModule.kt @@ -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 } } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantApis.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantApis.kt new file mode 100644 index 00000000000..de6c0cc69ed --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantApis.kt @@ -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() +} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantRetrofit.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantRetrofit.kt deleted file mode 100644 index 67b94ec87f7..00000000000 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantRetrofit.kt +++ /dev/null @@ -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() -} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/authentication/AuthenticationRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/authentication/AuthenticationRepository.kt index 85abfc001fa..e73b58926f8 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/authentication/AuthenticationRepository.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/authentication/AuthenticationRepository.kt @@ -14,6 +14,8 @@ interface AuthenticationRepository { suspend fun retrieveExternalAuthentication(forceRefresh: Boolean): String + suspend fun retrieveAccessToken(): String + suspend fun revokeSession() suspend fun getSessionState(): SessionState diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/authentication/impl/AuthenticationRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/authentication/impl/AuthenticationRepositoryImpl.kt index ef43d6e6243..4de9cde4ba9 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/authentication/impl/AuthenticationRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/authentication/impl/AuthenticationRepositoryImpl.kt @@ -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) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt index 1b7fc8300bb..7108cb67e2a 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt @@ -35,13 +35,11 @@ interface IntegrationRepository { suspend fun setWearHomeFavorites(favorites: Set) suspend fun getWearHomeFavorites(): Set - suspend fun getThemeColor(): String - suspend fun getHomeAssistantVersion(): String - suspend fun getServices(): Array + suspend fun getServices(): List - suspend fun getEntities(): Array> + suspend fun getEntities(): List> suspend fun getEntity(entityId: String): Entity> suspend fun callService(domain: String, service: String, serviceData: HashMap) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/ServiceFields.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/ServiceFields.kt index c6c7a39a863..e0d480e22d5 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/ServiceFields.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/ServiceFields.kt @@ -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?, diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt index c7d94a702a7..42eeff24932 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt @@ -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 { - val response = integrationService.getServices(authenticationRepository.buildBearerToken()) + override suspend fun getServices(): List { + 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> { - val response = integrationService.getStates(authenticationRepository.buildBearerToken()) + override suspend fun getEntities(): List> { + 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> { diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationService.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationService.kt index 4dc2c08b15f..0cdd96fe7bb 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationService.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationService.kt @@ -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 - - @GET("/api/states") - suspend fun getStates( - @Header("Authorization") auth: String - ): Array> - @GET("/api/states/{entityId}") suspend fun getState( @Header("Authorization") auth: String, @@ -67,12 +49,6 @@ interface IntegrationService { @Body request: IntegrationRequest ): Array> - @POST - suspend fun getConfig( - @Url url: HttpUrl, - @Body request: IntegrationRequest - ): GetConfigResponse - @POST suspend fun getRateLimit( @Url url: String, diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/entities/GetConfigResponse.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/entities/GetConfigResponse.kt deleted file mode 100644 index af47ad6c6ab..00000000000 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/entities/GetConfigResponse.kt +++ /dev/null @@ -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, - val locationName: String, - val timeZone: String, - val components: Array, - val version: String, - val themeColor: String -) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt new file mode 100644 index 00000000000..cdaed2ff6b4 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt @@ -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> + suspend fun getServices(): List + suspend fun getPanels(): List + suspend fun callService(request: ServiceCallRequest) +} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt new file mode 100644 index 00000000000..4c5362d48a4 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt @@ -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>() + private val subscriptionCallbacks = mutableMapOf 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> { + val socketResponse = sendMessage( + mapOf( + "type" to "get_states" + ) + ) + + return mapper.convertValue( + socketResponse.result!!, + object : TypeReference>>() {} + ) + } + + override suspend fun getServices(): List { + val socketResponse = sendMessage( + mapOf( + "type" to "get_services" + ) + ) + + val response = mapper.convertValue( + socketResponse.result!!, + object : TypeReference>>() {} + ) + + return response.map { + DomainResponse(it.key, it.value) + } + } + + override suspend fun getPanels(): List { + 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) + } +} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/entities/DomainResponse.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/DomainResponse.kt similarity index 68% rename from common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/entities/DomainResponse.kt rename to common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/DomainResponse.kt index 705c9bcdee8..ff4ebb82cdf 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/entities/DomainResponse.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/DomainResponse.kt @@ -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 diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/GetConfigResponse.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/GetConfigResponse.kt new file mode 100644 index 00000000000..71ee61e9127 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/GetConfigResponse.kt @@ -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, + val locationName: String, + val timeZone: String, + val components: List, + val version: String, +) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/SocketResponse.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/SocketResponse.kt new file mode 100644 index 00000000000..543bae7b261 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/SocketResponse.kt @@ -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? +) diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/HomeActivity.kt b/wear/src/main/java/io/homeassistant/companion/android/home/HomeActivity.kt index 644205e745a..4983deda48d 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/HomeActivity.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/HomeActivity.kt @@ -135,7 +135,7 @@ class HomeActivity : ComponentActivity(), HomeView { @ExperimentalWearMaterialApi @Composable - private fun LoadHomePage(entities: Array>, favorites: MutableSet) { + private fun LoadHomePage(entities: List>, favorites: MutableSet) { val rotaryEventDispatcher = RotaryEventDispatcher() if (entities.isNullOrEmpty() && favorites.isNullOrEmpty()) { diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/HomePresenter.kt b/wear/src/main/java/io/homeassistant/companion/android/home/HomePresenter.kt index 3fc3849dcb9..b2fb6571049 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/HomePresenter.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/HomePresenter.kt @@ -8,7 +8,7 @@ interface HomePresenter { fun onEntityClicked(entityId: String) fun onLogoutClicked() fun onFinish() - suspend fun getEntities(): Array> + suspend fun getEntities(): List> suspend fun getWearHomeFavorites(): Set suspend fun setWearHomeFavorites(favorites: Set) } diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/HomePresenterImpl.kt b/wear/src/main/java/io/homeassistant/companion/android/home/HomePresenterImpl.kt index af4aa92f382..7da0c341389 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/HomePresenterImpl.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/HomePresenterImpl.kt @@ -46,12 +46,12 @@ class HomePresenterImpl @Inject constructor( } } - override suspend fun getEntities(): Array> { + override suspend fun getEntities(): List> { return try { integrationUseCase.getEntities() } catch (e: Exception) { Log.e(TAG, "Unable to get entities", e) - emptyArray() + emptyList() } } diff --git a/wear/src/main/java/io/homeassistant/companion/android/viewModels/EntityViewModel.kt b/wear/src/main/java/io/homeassistant/companion/android/viewModels/EntityViewModel.kt index f901762c747..9be42ca81c8 100755 --- a/wear/src/main/java/io/homeassistant/companion/android/viewModels/EntityViewModel.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/viewModels/EntityViewModel.kt @@ -8,6 +8,6 @@ import io.homeassistant.companion.android.common.data.integration.Entity class EntityViewModel : ViewModel() { - var entitiesResponse: Array> by mutableStateOf(arrayOf()) + var entitiesResponse: List> by mutableStateOf(mutableListOf()) var favoriteEntities: MutableSet by mutableStateOf(mutableSetOf()) }