diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d50f45ddda2..edf3817edfe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -118,6 +118,7 @@ android { dependencies { implementation(project(":common")) + implementation(libs.androidx.browser) coreLibraryDesugaring(libs.tools.desugar.jdk) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 33b95e07742..e6c0aca8c0a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -306,7 +306,19 @@ + android:theme="@style/Theme.HomeAssistant.Config" + android:exported="true"> + + + + + + + + + () @@ -66,15 +102,22 @@ class OnboardingActivity : BaseActivity() { } } if (viewModel.manualContinueEnabled) { - supportFragmentManager.commit { - replace(R.id.content, AuthenticationFragment::class.java, null) - addToBackStack(null) - } + val uri = buildAuthUrl(baseContext, input.url) + val builder = CustomTabsIntent.Builder() + val customTabsIntent = builder.build() + customTabsIntent.launchUrl(baseContext, Uri.parse(uri)) } } } } + if (intent?.action == Intent.ACTION_VIEW && intent.data != null) { + val uri = intent.data + if (uri != null && uri.scheme == "homeassistant") { + handleAuthCallback(uri.toString()) + } + } + val onBackPressed = object : OnBackPressedCallback(supportFragmentManager.backStackEntryCount > 0) { override fun handleOnBackPressed() { supportFragmentManager.popBackStack() @@ -86,6 +129,18 @@ class OnboardingActivity : BaseActivity() { } } + private fun handleAuthCallback(url: String) { + val code = Uri.parse(url).getQueryParameter("code") + if (url.startsWith(AUTH_CALLBACK) && !code.isNullOrBlank()) { + viewModel.registerAuthCode(code) + supportFragmentManager + .beginTransaction() + .replace(R.id.content, MobileAppIntegrationFragment::class.java, null) + .addToBackStack(null) + .commit() + } + } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { // Workaround to sideload on Android TV and use a remote for basic navigation in WebView val fragmentManager = supportFragmentManager.findFragmentByTag(AUTHENTICATION_FRAGMENT) diff --git a/app/src/main/java/io/homeassistant/companion/android/onboarding/authentication/AuthenticationFragment.kt b/app/src/main/java/io/homeassistant/companion/android/onboarding/authentication/AuthenticationFragment.kt deleted file mode 100644 index 0260666c928..00000000000 --- a/app/src/main/java/io/homeassistant/companion/android/onboarding/authentication/AuthenticationFragment.kt +++ /dev/null @@ -1,249 +0,0 @@ -package io.homeassistant.companion.android.onboarding.authentication - -import android.annotation.SuppressLint -import android.net.Uri -import android.net.http.SslError -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.webkit.SslErrorHandler -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebView -import android.webkit.WebViewClient -import android.widget.Toast -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AlertDialog -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.viewinterop.AndroidView -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import dagger.hilt.android.AndroidEntryPoint -import io.homeassistant.companion.android.R -import io.homeassistant.companion.android.common.R as commonR -import io.homeassistant.companion.android.common.data.HomeAssistantApis -import io.homeassistant.companion.android.common.data.authentication.impl.AuthenticationService -import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository -import io.homeassistant.companion.android.onboarding.OnboardingViewModel -import io.homeassistant.companion.android.onboarding.integration.MobileAppIntegrationFragment -import io.homeassistant.companion.android.themes.ThemesManager -import io.homeassistant.companion.android.util.TLSWebViewClient -import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme -import io.homeassistant.companion.android.util.isStarted -import javax.inject.Inject -import javax.inject.Named -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrl - -@AndroidEntryPoint -class AuthenticationFragment : Fragment() { - - companion object { - private const val TAG = "AuthenticationFragment" - private const val AUTH_CALLBACK = "homeassistant://auth-callback" - } - - private val viewModel by activityViewModels() - - private var authUrl: String? = null - - @Inject - lateinit var themesManager: ThemesManager - - @Inject - @Named("keyChainRepository") - lateinit var keyChainRepository: KeyChainRepository - - @SuppressLint("SetJavaScriptEnabled") - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setContent { - HomeAssistantAppTheme { - AndroidView({ - WebView(requireContext()).apply { - themesManager.setThemeForWebView(requireContext(), settings) - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - settings.userAgentString = settings.userAgentString + " ${HomeAssistantApis.USER_AGENT_STRING}" - webViewClient = object : TLSWebViewClient(keyChainRepository) { - @Deprecated("Deprecated in Java") - override fun shouldOverrideUrlLoading(view: WebView?, url: String): Boolean { - return onRedirect(url) - } - - @RequiresApi(Build.VERSION_CODES.M) - override fun onReceivedError( - view: WebView?, - request: WebResourceRequest?, - error: WebResourceError? - ) { - super.onReceivedError(view, request, error) - if (request?.url?.toString() == authUrl) { - Log.e( - TAG, - "onReceivedError: Status Code: ${error?.errorCode} Description: ${error?.description}" - ) - showError( - requireContext().getString( - commonR.string.error_http_generic, - error?.errorCode, - if (error?.description.isNullOrBlank()) { - commonR.string.no_description - } else { - error?.description - } - ), - null, - error - ) - } - } - - override fun onReceivedHttpError( - view: WebView?, - request: WebResourceRequest?, - errorResponse: WebResourceResponse? - ) { - super.onReceivedHttpError(view, request, errorResponse) - if (request?.url?.toString() == authUrl) { - Log.e( - TAG, - "onReceivedHttpError: Status Code: ${errorResponse?.statusCode} Description: ${errorResponse?.reasonPhrase}" - ) - if (isTLSClientAuthNeeded && !isCertificateChainValid) { - showError( - requireContext().getString(commonR.string.tls_cert_expired_message), - null, - null - ) - } else if (isTLSClientAuthNeeded && errorResponse?.statusCode == 400) { - showError( - requireContext().getString(commonR.string.tls_cert_not_found_message), - null, - null - ) - } else { - showError( - requireContext().getString( - commonR.string.error_http_generic, - errorResponse?.statusCode, - if (errorResponse?.reasonPhrase.isNullOrBlank()) { - requireContext().getString(commonR.string.no_description) - } else { - errorResponse?.reasonPhrase - } - ), - null, - null - ) - } - } - } - - override fun onReceivedSslError( - view: WebView?, - handler: SslErrorHandler?, - error: SslError? - ) { - super.onReceivedSslError(view, handler, error) - Log.e(TAG, "onReceivedSslError: $error") - showError(requireContext().getString(commonR.string.error_ssl), error, null) - } - } - authUrl = buildAuthUrl(viewModel.manualUrl.value) - loadUrl(authUrl!!) - } - }) - } - } - } - } - - private fun buildAuthUrl(base: String): String { - return try { - val url = base.toHttpUrl() - val builder = if (url.host.endsWith("ui.nabu.casa", true)) { - HttpUrl.Builder() - .scheme(url.scheme) - .host(url.host) - .port(url.port) - } else { - url.newBuilder() - } - builder - .addPathSegments("auth/authorize") - .addEncodedQueryParameter("response_type", "code") - .addEncodedQueryParameter("client_id", AuthenticationService.CLIENT_ID) - .addEncodedQueryParameter("redirect_uri", AUTH_CALLBACK) - .build() - .toString() - } catch (e: Exception) { - Log.e(TAG, "Unable to build authentication URL", e) - Toast.makeText(context, commonR.string.error_connection_failed, Toast.LENGTH_LONG).show() - parentFragmentManager.popBackStack() - "" - } - } - - private fun onRedirect(url: String): Boolean { - val code = Uri.parse(url).getQueryParameter("code") - return if (url.startsWith(AUTH_CALLBACK) && !code.isNullOrBlank()) { - viewModel.registerAuthCode(code) - parentFragmentManager - .beginTransaction() - .replace(R.id.content, MobileAppIntegrationFragment::class.java, null) - .addToBackStack(null) - .commit() - true - } else { - // The WebViewClient should load this URL - authUrl = url - false - } - } - - private fun showError(message: String, sslError: SslError?, error: WebResourceError?) { - if (!isStarted) { - // Fragment is at least paused, can't display alert - return - } - AlertDialog.Builder(requireContext()) - .setTitle(commonR.string.error_connection_failed) - .setMessage( - when (sslError?.primaryError) { - SslError.SSL_DATE_INVALID -> requireContext().getString(commonR.string.webview_error_SSL_DATE_INVALID) - SslError.SSL_EXPIRED -> requireContext().getString(commonR.string.webview_error_SSL_EXPIRED) - SslError.SSL_IDMISMATCH -> requireContext().getString(commonR.string.webview_error_SSL_IDMISMATCH) - SslError.SSL_INVALID -> requireContext().getString(commonR.string.webview_error_SSL_INVALID) - SslError.SSL_NOTYETVALID -> requireContext().getString(commonR.string.webview_error_SSL_NOTYETVALID) - SslError.SSL_UNTRUSTED -> requireContext().getString(commonR.string.webview_error_SSL_UNTRUSTED) - else -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - when (error?.errorCode) { - WebViewClient.ERROR_FAILED_SSL_HANDSHAKE -> - requireContext().getString(commonR.string.webview_error_FAILED_SSL_HANDSHAKE) - WebViewClient.ERROR_AUTHENTICATION -> requireContext().getString(commonR.string.webview_error_AUTHENTICATION) - WebViewClient.ERROR_PROXY_AUTHENTICATION -> requireContext().getString(commonR.string.webview_error_PROXY_AUTHENTICATION) - WebViewClient.ERROR_UNSUPPORTED_AUTH_SCHEME -> requireContext().getString(commonR.string.webview_error_AUTH_SCHEME) - WebViewClient.ERROR_HOST_LOOKUP -> requireContext().getString(commonR.string.webview_error_HOST_LOOKUP) - else -> message - } - } else { - message - } - } - } - ) - .setPositiveButton(android.R.string.ok) { _, _ -> } - .show() - parentFragmentManager.popBackStack() - } -} diff --git a/app/src/main/java/io/homeassistant/companion/android/onboarding/discovery/DiscoveryFragment.kt b/app/src/main/java/io/homeassistant/companion/android/onboarding/discovery/DiscoveryFragment.kt index ebc327a81e2..ca7d218ab1d 100644 --- a/app/src/main/java/io/homeassistant/companion/android/onboarding/discovery/DiscoveryFragment.kt +++ b/app/src/main/java/io/homeassistant/companion/android/onboarding/discovery/DiscoveryFragment.kt @@ -1,9 +1,12 @@ package io.homeassistant.companion.android.onboarding.discovery +import android.content.Context +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -12,8 +15,8 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.onboarding.OnboardingActivity import io.homeassistant.companion.android.onboarding.OnboardingViewModel -import io.homeassistant.companion.android.onboarding.authentication.AuthenticationFragment import io.homeassistant.companion.android.onboarding.manual.ManualSetupFragment import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme import javax.inject.Inject @@ -47,7 +50,7 @@ class DiscoveryFragment @Inject constructor() : Fragment() { discoveryActive = viewModel.discoveryActive, foundInstances = viewModel.foundInstances, manualSetupClicked = { navigateToManualSetup() }, - instanceClicked = { onInstanceClicked(it) } + instanceClicked = { onInstanceClicked(it, requireContext()) } ) } } @@ -62,12 +65,11 @@ class DiscoveryFragment @Inject constructor() : Fragment() { .commit() } - private fun onInstanceClicked(instance: HomeAssistantInstance) { + private fun onInstanceClicked(instance: HomeAssistantInstance, context: Context) { viewModel.manualUrl.value = instance.url.toString() - parentFragmentManager - .beginTransaction() - .replace(R.id.content, AuthenticationFragment::class.java, null) - .addToBackStack(null) - .commit() + val uri = OnboardingActivity.buildAuthUrl(context, viewModel.manualUrl.value) + val builder = CustomTabsIntent.Builder() + val customTabsIntent = builder.build() + customTabsIntent.launchUrl(context, Uri.parse(uri)) } } diff --git a/app/src/main/java/io/homeassistant/companion/android/onboarding/manual/ManualSetupFragment.kt b/app/src/main/java/io/homeassistant/companion/android/onboarding/manual/ManualSetupFragment.kt index 0d7a7dee7d7..06ee6564711 100644 --- a/app/src/main/java/io/homeassistant/companion/android/onboarding/manual/ManualSetupFragment.kt +++ b/app/src/main/java/io/homeassistant/companion/android/onboarding/manual/ManualSetupFragment.kt @@ -1,16 +1,18 @@ package io.homeassistant.companion.android.onboarding.manual +import android.content.Context +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import dagger.hilt.android.AndroidEntryPoint -import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.onboarding.OnboardingActivity import io.homeassistant.companion.android.onboarding.OnboardingViewModel -import io.homeassistant.companion.android.onboarding.authentication.AuthenticationFragment import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme @AndroidEntryPoint @@ -30,18 +32,17 @@ class ManualSetupFragment : Fragment() { manualUrl = viewModel.manualUrl, onManualUrlUpdated = viewModel::onManualUrlUpdated, manualContinueEnabled = viewModel.manualContinueEnabled, - connectedClicked = { connectClicked() } + connectedClicked = { connectClicked(requireContext(), viewModel.manualUrl.value) } ) } } } } - private fun connectClicked() { - parentFragmentManager - .beginTransaction() - .replace(R.id.content, AuthenticationFragment::class.java, null) - .addToBackStack(null) - .commit() + private fun connectClicked(context: Context, url: String) { + val uri = OnboardingActivity.buildAuthUrl(context, url) + val builder = CustomTabsIntent.Builder() + val customTabsIntent = builder.build() + customTabsIntent.launchUrl(context, Uri.parse(uri)) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8486d438916..44f4ef9df9a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,6 +68,8 @@ webkit = "1.12.1" wear-remote-interactions = "1.1.0" workRuntimeKtx = "2.10.0" zxing = "4.3.0" +customTabs = "1.8.0" +browser = "1.8.0" [plugins] android-application = { id = "com.android.application", version.ref = "androidPlugin" } @@ -173,6 +175,8 @@ wear-tiles = { module = "androidx.wear.tiles:tiles", version.ref = "wear-tiles" wear-tooling = { module = "androidx.wear:wear-tooling-preview", version.ref = "wear-tooling" } webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } zxing = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxing" } +customTabs = { group = "androidx.browser", name = "browser", version.ref = "customTabs" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } [bundles] coil = ["coil-views", "coil-oktthp", "coil-svg"]