diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/launch/link/LinkActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/launch/link/LinkActivity.kt index 505d57fd7cd..7c213514e6f 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/launch/link/LinkActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/launch/link/LinkActivity.kt @@ -20,16 +20,20 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.BaseActivity +import io.homeassistant.companion.android.BuildConfig import io.homeassistant.companion.android.R import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.common.util.FailFast import io.homeassistant.companion.android.launch.LaunchActivity +import io.homeassistant.companion.android.launcher.LauncherActivity import io.homeassistant.companion.android.settings.server.ServerChooserFragment import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme import io.homeassistant.companion.android.webview.WebViewActivity import javax.inject.Inject import kotlinx.coroutines.launch +private val USE_NEW_LAUNCHER by lazy { BuildConfig.DEBUG } + @AndroidEntryPoint class LinkActivity : BaseActivity() { @@ -67,7 +71,16 @@ class LinkActivity : BaseActivity() { when (val destination = linkHandler.handleLink(dataUri)) { LinkDestination.NoDestination -> finish() is LinkDestination.Onboarding -> { - startActivity(LaunchActivity.newInstance(this@LinkActivity, destination.serverUrl)) + if (USE_NEW_LAUNCHER) { + startActivity( + LauncherActivity.newInstance( + this@LinkActivity, + LauncherActivity.DeepLink.Invite(destination.serverUrl), + ), + ) + } else { + startActivity(LaunchActivity.newInstance(this@LinkActivity, destination.serverUrl)) + } finish() } @@ -83,7 +96,16 @@ class LinkActivity : BaseActivity() { if (serverManager.defaultServers.size > 1) { openServerChooser(path) } else { - startActivity(WebViewActivity.newInstance(context = this, path = path)) + if (USE_NEW_LAUNCHER) { + startActivity( + LauncherActivity.newInstance( + this, + LauncherActivity.DeepLink.NavigateTo(path, ServerManager.SERVER_ID_ACTIVE), + ), + ) + } else { + startActivity(WebViewActivity.newInstance(context = this, path = path)) + } finish() } } @@ -91,13 +113,25 @@ class LinkActivity : BaseActivity() { private fun openServerChooser(path: String) { supportFragmentManager.setFragmentResultListener(ServerChooserFragment.RESULT_KEY, this) { _, bundle -> if (bundle.containsKey(ServerChooserFragment.RESULT_SERVER)) { - startActivity( - WebViewActivity.newInstance( - context = this, - path = path, - serverId = bundle.getInt(ServerChooserFragment.RESULT_SERVER), - ), - ) + if (USE_NEW_LAUNCHER) { + startActivity( + LauncherActivity.newInstance( + this, + LauncherActivity.DeepLink.NavigateTo( + path, + bundle.getInt(ServerChooserFragment.RESULT_SERVER), + ), + ), + ) + } else { + startActivity( + WebViewActivity.newInstance( + context = this, + path = path, + serverId = bundle.getInt(ServerChooserFragment.RESULT_SERVER), + ), + ) + } finish() } supportFragmentManager.clearFragmentResultListener(ServerChooserFragment.RESULT_KEY) @@ -118,7 +152,9 @@ fun LinkActivityScreen() { Image( imageVector = ImageVector.vectorResource(R.drawable.app_icon_launch), contentDescription = null, - modifier = Modifier.size(112.dp).align(Alignment.Center), + modifier = Modifier + .size(112.dp) + .align(Alignment.Center), ) } } diff --git a/onboarding/build.gradle.kts b/onboarding/build.gradle.kts index 4afc269db4a..245b2d98bff 100644 --- a/onboarding/build.gradle.kts +++ b/onboarding/build.gradle.kts @@ -2,7 +2,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.homeassistant.android.common) alias(libs.plugins.homeassistant.android.compose) - alias(libs.plugins.hilt) + alias(libs.plugins.kotlin.parcelize) } android { diff --git a/onboarding/gradle.lockfile b/onboarding/gradle.lockfile index 8ae3cc13b50..b5fe34d6abc 100644 --- a/onboarding/gradle.lockfile +++ b/onboarding/gradle.lockfile @@ -430,21 +430,23 @@ org.jetbrains.kotlin:abi-tools:2.2.20=kotlinInternalAbiValidation org.jetbrains.kotlin:kotlin-build-tools-api:2.2.20=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:kotlin-build-tools-impl:2.2.20=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:kotlin-compiler-embeddable:2.1.0=ktlint,ktlintBaselineReporter,ktlintRuleset -org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable org.jetbrains.kotlin:kotlin-compiler-runner:2.2.20=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.2.0=kotlinCompilerPluginClasspathDebugScreenshotTest,kotlinCompilerPluginClasspathReleaseScreenshotTest org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.2.20=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest org.jetbrains.kotlin:kotlin-daemon-client:2.2.20=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:kotlin-daemon-embeddable:2.1.0=ktlint,ktlintBaselineReporter,ktlintRuleset -org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.2.20=kotlinKlibCommonizerClasspath org.jetbrains.kotlin:kotlin-metadata-jvm:2.1.21=_agp_internal_javaPreCompileDebugAndroidTest_kspClasspath,_agp_internal_javaPreCompileDebugUnitTest_kspClasspath,_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileReleaseUnitTest_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorReleaseUnitTest,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugScreenshotTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseScreenshotTestKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath org.jetbrains.kotlin:kotlin-metadata-jvm:2.2.20=kotlinInternalAbiValidation -org.jetbrains.kotlin:kotlin-reflect:1.6.10=_agp_internal_javaPreCompileDebugAndroidTest_kspClasspath,_agp_internal_javaPreCompileDebugUnitTest_kspClasspath,_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileReleaseUnitTest_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugScreenshotTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseScreenshotTestKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset,swiftExportClasspathResolvable +org.jetbrains.kotlin:kotlin-parcelize-compiler:2.2.20=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest +org.jetbrains.kotlin:kotlin-parcelize-runtime:2.2.20=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugScreenshotTestCompileClasspath,debugScreenshotTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseScreenshotTestCompileClasspath,releaseScreenshotTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-reflect:1.6.10=_agp_internal_javaPreCompileDebugAndroidTest_kspClasspath,_agp_internal_javaPreCompileDebugUnitTest_kspClasspath,_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileReleaseUnitTest_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugScreenshotTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,kspReleaseScreenshotTestKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset,swiftExportClasspathResolvable org.jetbrains.kotlin:kotlin-reflect:1.8.21=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher org.jetbrains.kotlin:kotlin-reflect:2.2.20=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugScreenshotTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseScreenshotTestRuntimeClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-script-runtime:2.1.0=ktlint,ktlintBaselineReporter,ktlintRuleset -org.jetbrains.kotlin:kotlin-script-runtime:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable +org.jetbrains.kotlin:kotlin-script-runtime:2.2.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable org.jetbrains.kotlin:kotlin-scripting-common:2.2.20=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.2.20=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.2.20=kotlinBuildToolsApiClasspath @@ -486,7 +488,7 @@ org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1=androidTestImplementationDepe org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugScreenshotTestCompileClasspath,debugScreenshotTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseScreenshotTestCompileClasspath,releaseScreenshotTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath,swiftExportClasspathResolvable org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.1=androidTestImplementationDependenciesMetadata org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugScreenshotTestCompileClasspath,debugScreenshotTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseScreenshotTestCompileClasspath,releaseScreenshotTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher diff --git a/onboarding/src/main/kotlin/io/homeassistant/companion/android/compose/HANavHost.kt b/onboarding/src/main/kotlin/io/homeassistant/companion/android/compose/HANavHost.kt index 7b0ff4d5ad7..baa19b0194c 100644 --- a/onboarding/src/main/kotlin/io/homeassistant/companion/android/compose/HANavHost.kt +++ b/onboarding/src/main/kotlin/io/homeassistant/companion/android/compose/HANavHost.kt @@ -10,6 +10,7 @@ import io.homeassistant.companion.android.frontend.navigation.navigateToFrontend import io.homeassistant.companion.android.loading.LoadingScreen import io.homeassistant.companion.android.loading.navigation.LoadingRoute import io.homeassistant.companion.android.loading.navigation.loadingScreen +import io.homeassistant.companion.android.onboarding.OnboardingRoute import io.homeassistant.companion.android.onboarding.onboarding /** @@ -49,6 +50,7 @@ internal fun HANavHost( // TODO remove this finish when the frontend is not an activity anymore activity?.finish() }, + serverToOnboard = (startDestination as? OnboardingRoute)?.serverToOnboard, ) frontendScreen(navController) } diff --git a/onboarding/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt b/onboarding/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt index 4e1d5231a1e..b50aab568b2 100644 --- a/onboarding/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt +++ b/onboarding/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt @@ -1,42 +1,62 @@ package io.homeassistant.companion.android.frontend.navigation import android.content.ComponentName +import androidx.activity.compose.LocalActivity import androidx.navigation.ActivityNavigator import androidx.navigation.ActivityNavigatorDestinationBuilder import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController import androidx.navigation.NavOptions +import androidx.navigation.compose.composable import androidx.navigation.get +import androidx.navigation.toRoute import io.homeassistant.companion.android.HAStartDestinationRoute import io.homeassistant.companion.android.common.data.servers.ServerManager.Companion.SERVER_ID_ACTIVE import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class FrontendRoute( +internal data class FrontendActivityRoute( val path: String? = null, // Override the serial name to match the name in WebViewActivity @SerialName("server") val serverId: Int = SERVER_ID_ACTIVE, -) : HAStartDestinationRoute +) + +@Serializable +internal class FrontendRoute(val path: String? = null, val serverId: Int = SERVER_ID_ACTIVE) : HAStartDestinationRoute internal fun NavController.navigateToFrontend( path: String? = null, serverId: Int = SERVER_ID_ACTIVE, navOptions: NavOptions? = null, ) { - navigate(FrontendRoute(path, serverId), navOptions) + navigate(FrontendActivityRoute(path, serverId), navOptions) } /** - * Destination to the WebviewActivity with the [FrontendRoute.path] and [FrontendRoute.serverId] parameters. + * Registers the frontend/webview destination for the Home Assistant app. + * + * The actual navigation to the frontend is done by navigating to [FrontendActivityRoute] with the + * `path` and `serverId` parameters. This will launch the `WebViewActivity` (which is in `:app`). + * + * To ensure that the activity that starts the `WebViewActivity` is finished, users should navigate + * to [FrontendRoute]. This route will then navigate to [FrontendActivityRoute] and finish the + * current activity. This behavior is necessary until `WebViewActivity` is replaced with a + * composable NavGraph entry, allowing for more direct navigation. */ -internal fun NavGraphBuilder.frontendScreen(navController: NavHostController) { +internal fun NavGraphBuilder.frontendScreen(navController: NavController) { + composable { + val dummy = it.toRoute() + navController.navigateToFrontend(dummy.path, dummy.serverId) + val activity = LocalActivity.current + activity?.finish() + } + // TODO replace with strong types when WebViewActivity is available to onboarding module // Inspired from activity { } to be able to give a ComponentName instead of a class since :onboarding doesn't know :app val destination = ActivityNavigatorDestinationBuilder( provider[ActivityNavigator::class], - FrontendRoute::class, + FrontendActivityRoute::class, emptyMap(), ).build().setComponentName( ComponentName( diff --git a/onboarding/src/main/kotlin/io/homeassistant/companion/android/launcher/LauncherActivity.kt b/onboarding/src/main/kotlin/io/homeassistant/companion/android/launcher/LauncherActivity.kt index 4f72449c3d3..52fab4d5e50 100644 --- a/onboarding/src/main/kotlin/io/homeassistant/companion/android/launcher/LauncherActivity.kt +++ b/onboarding/src/main/kotlin/io/homeassistant/companion/android/launcher/LauncherActivity.kt @@ -1,20 +1,28 @@ package io.homeassistant.companion.android.launcher +import android.content.Context +import android.content.Intent import android.os.Bundle +import android.os.Parcelable import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.produceState +import androidx.core.content.IntentCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.withCreationCallback import io.homeassistant.companion.android.HAStartDestinationRoute import io.homeassistant.companion.android.common.compose.theme.HATheme import io.homeassistant.companion.android.compose.HAApp import io.homeassistant.companion.android.frontend.navigation.FrontendRoute import io.homeassistant.companion.android.onboarding.OnboardingRoute import kotlinx.coroutines.flow.first +import kotlinx.parcelize.Parcelize + +private const val DEEP_LINK_KEY = "deep_link_key" /** * Main entry point of the application, it is mostly responsible to hold the whole navigation of the application. @@ -22,7 +30,29 @@ import kotlinx.coroutines.flow.first */ @AndroidEntryPoint class LauncherActivity : AppCompatActivity() { - private val viewModel: LauncherViewModel by viewModels() + @Parcelize + sealed interface DeepLink : Parcelable { + data class Invite(val url: String) : DeepLink + data class NavigateTo(val path: String?, val serverId: Int) : DeepLink + } + + companion object { + fun newInstance(context: Context, deepLink: DeepLink? = null): Intent { + return Intent(context, LauncherActivity::class.java).apply { + if (deepLink != null) { + putExtra(DEEP_LINK_KEY, deepLink) + } + } + } + } + + private val viewModel: LauncherViewModel by viewModels( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { + it.create(IntentCompat.getParcelableExtra(intent, DEEP_LINK_KEY, DeepLink::class.java)) + } + }, + ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -39,9 +69,10 @@ class LauncherActivity : AppCompatActivity() { val navController = rememberNavController() val startDestinationState = produceState(null, viewModel) { - value = when (viewModel.navigationEventsFlow.first()) { - LauncherNavigationEvent.Frontend -> FrontendRoute() - LauncherNavigationEvent.Onboarding -> OnboardingRoute + val event = viewModel.navigationEventsFlow.first() + value = when (event) { + is LauncherNavigationEvent.Frontend -> FrontendRoute(event.path, event.serverId) + is LauncherNavigationEvent.Onboarding -> OnboardingRoute(event.url) } } diff --git a/onboarding/src/main/kotlin/io/homeassistant/companion/android/launcher/LauncherViewModel.kt b/onboarding/src/main/kotlin/io/homeassistant/companion/android/launcher/LauncherViewModel.kt index d92c76ed6c6..a45db5811a1 100644 --- a/onboarding/src/main/kotlin/io/homeassistant/companion/android/launcher/LauncherViewModel.kt +++ b/onboarding/src/main/kotlin/io/homeassistant/companion/android/launcher/LauncherViewModel.kt @@ -3,6 +3,9 @@ package io.homeassistant.companion.android.launcher import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.WorkManager +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import io.homeassistant.companion.android.common.data.authentication.SessionState import io.homeassistant.companion.android.common.data.network.NetworkState @@ -10,7 +13,6 @@ import io.homeassistant.companion.android.common.data.network.NetworkStatusMonit import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.common.util.ResyncRegistrationWorker.Companion.enqueueResyncRegistration import io.homeassistant.companion.android.database.server.Server -import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collect @@ -24,8 +26,8 @@ import timber.log.Timber * after launching. */ internal sealed interface LauncherNavigationEvent { - data object Frontend : LauncherNavigationEvent - data object Onboarding : LauncherNavigationEvent + data class Frontend(val path: String?, val serverId: Int) : LauncherNavigationEvent + data class Onboarding(val url: String?) : LauncherNavigationEvent } /** @@ -36,8 +38,9 @@ internal sealed interface LauncherNavigationEvent { * it emits [LauncherNavigationEvent.Onboarding]. Otherwise, it schedules a resync of all server * registrations asynchronously and emits [LauncherNavigationEvent.Frontend]. */ -@HiltViewModel -internal class LauncherViewModel @Inject constructor( +@HiltViewModel(assistedFactory = LauncherViewModelFactory::class) +internal class LauncherViewModel @AssistedInject constructor( + @Assisted initialDeepLink: LauncherActivity.DeepLink?, private val workManager: WorkManager, private val serverManager: ServerManager, private val networkStatusMonitor: NetworkStatusMonitor, @@ -49,21 +52,7 @@ internal class LauncherViewModel @Inject constructor( init { viewModelScope.launch { cleanupServers() - - try { - getActiveServerConnectedAndRegistered()?.let { server -> - Timber.d("Active server (id=${server.id}) is connected and registered checking network status") - - networkStatusMonitor.observeNetworkStatus(server.connection) - .takeWhile { state -> - // Until the network is ready we continue to observe network status changes - !handleNetworkState(state) - }.collect() - } ?: navigateToOnboarding() - } catch (e: IllegalArgumentException) { - Timber.e(e, "Something went wrong while checking if any server are registered with a connected session") - navigateToOnboarding() - } + handleInitialState(initialDeepLink) } } @@ -72,15 +61,43 @@ internal class LauncherViewModel @Inject constructor( */ fun shouldShowSplashScreen(): Boolean = navigationEventsFlow.replayCache.isEmpty() - private suspend fun getActiveServerConnectedAndRegistered(): Server? { - return serverManager.getServer(ServerManager.SERVER_ID_ACTIVE)?.takeIf { + private suspend fun handleInitialState(initialDeepLink: LauncherActivity.DeepLink?) { + when (initialDeepLink) { + is LauncherActivity.DeepLink.Invite -> navigateToOnboarding(initialDeepLink.url) + is LauncherActivity.DeepLink.NavigateTo -> connectToServer(initialDeepLink.serverId, initialDeepLink.path) + null -> connectToServer(ServerManager.SERVER_ID_ACTIVE, null) + } + } + + private suspend fun connectToServer(serverId: Int, path: String?) { + try { + getServerConnectedAndRegistered(serverId)?.let { server -> + Timber.d("Server (id=${server.id}) is connected and registered checking network status") + + networkStatusMonitor.observeNetworkStatus(server.connection) + .takeWhile { state -> + // Until the network is ready we continue to observe network status changes + !handleNetworkState(state, LauncherNavigationEvent.Frontend(path, serverId)) + }.collect() + } ?: navigateToOnboarding() + } catch (e: IllegalArgumentException) { + Timber.e( + e, + "Something went wrong while checking if any server are registered with a connected session", + ) + navigateToOnboarding() + } + } + + private suspend fun getServerConnectedAndRegistered(serverId: Int): Server? { + return serverManager.getServer(serverId)?.takeIf { serverManager.isRegistered() && serverManager.authenticationRepository().getSessionState() == SessionState.CONNECTED } } - private suspend fun navigateToOnboarding() { - _navigationEventsFlow.emit(LauncherNavigationEvent.Onboarding) + private suspend fun navigateToOnboarding(url: String? = null) { + _navigationEventsFlow.emit(LauncherNavigationEvent.Onboarding(url)) } private suspend fun cleanupServers() { @@ -93,12 +110,15 @@ internal class LauncherViewModel @Inject constructor( .forEach { serverManager.removeServer(it.id) } } - private suspend fun handleNetworkState(state: NetworkState): Boolean { + private suspend fun handleNetworkState( + state: NetworkState, + destinationOnReady: LauncherNavigationEvent.Frontend, + ): Boolean { Timber.i("Current network state $state") return when (state) { NetworkState.READY_LOCAL, NetworkState.READY_REMOTE -> { workManager.enqueueResyncRegistration() - _navigationEventsFlow.emit(LauncherNavigationEvent.Frontend) + _navigationEventsFlow.emit(destinationOnReady) true } @@ -113,3 +133,8 @@ internal class LauncherViewModel @Inject constructor( } } } + +@AssistedFactory +internal interface LauncherViewModelFactory { + fun create(initialDeepLink: LauncherActivity.DeepLink?): LauncherViewModel +} diff --git a/onboarding/src/main/kotlin/io/homeassistant/companion/android/onboarding/OnboardingNavigation.kt b/onboarding/src/main/kotlin/io/homeassistant/companion/android/onboarding/OnboardingNavigation.kt index 1b6af8813cb..480f325cb5c 100644 --- a/onboarding/src/main/kotlin/io/homeassistant/companion/android/onboarding/OnboardingNavigation.kt +++ b/onboarding/src/main/kotlin/io/homeassistant/companion/android/onboarding/OnboardingNavigation.kt @@ -33,10 +33,10 @@ import io.homeassistant.companion.android.onboarding.welcome.navigation.welcomeS import kotlinx.serialization.Serializable @Serializable -internal data object OnboardingRoute : HAStartDestinationRoute +internal data class OnboardingRoute(val serverToOnboard: String? = null) : HAStartDestinationRoute -internal fun NavController.navigateToOnboarding(navOptions: NavOptions? = null) { - navigate(OnboardingRoute, navOptions) +internal fun NavController.navigateToOnboarding(serverToOnboard: String? = null, navOptions: NavOptions? = null) { + navigate(OnboardingRoute(serverToOnboard), navOptions) } /** @@ -49,10 +49,17 @@ internal fun NavGraphBuilder.onboarding( navController: NavController, onShowSnackbar: suspend (message: String, action: String?) -> Boolean, onOnboardingDone: () -> Unit, + serverToOnboard: String?, ) { navigation(startDestination = WelcomeRoute) { welcomeScreen( - onConnectClick = navController::navigateToServerDiscovery, + onConnectClick = { + if (serverToOnboard != null) { + navController.navigateToConnection(serverToOnboard) + } else { + navController.navigateToServerDiscovery() + } + }, onLearnMoreClick = { // TODO validate the URL to use navController.navigateToUri("https://www.home-assistant.io") diff --git a/onboarding/src/main/kotlin/io/homeassistant/companion/android/onboarding/serverdiscovery/ServerDiscoveryScreen.kt b/onboarding/src/main/kotlin/io/homeassistant/companion/android/onboarding/serverdiscovery/ServerDiscoveryScreen.kt index 352e7be7563..276964f5a6c 100644 --- a/onboarding/src/main/kotlin/io/homeassistant/companion/android/onboarding/serverdiscovery/ServerDiscoveryScreen.kt +++ b/onboarding/src/main/kotlin/io/homeassistant/companion/android/onboarding/serverdiscovery/ServerDiscoveryScreen.kt @@ -1,5 +1,6 @@ package io.homeassistant.companion.android.onboarding.serverdiscovery +import androidx.annotation.VisibleForTesting import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -58,6 +59,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.semantics.Role @@ -142,6 +144,9 @@ internal fun ServerDiscoveryScreen( } } +@VisibleForTesting +internal const val ONE_SERVER_FOUND_MODAL_TAG = "OneServerFoundModal" + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun OneServerFound( @@ -172,6 +177,7 @@ private fun OneServerFound( serverDiscoveredCached = null onDismiss() }, + modifier = Modifier.testTag(ONE_SERVER_FOUND_MODAL_TAG), ) { Column( modifier = Modifier @@ -340,7 +346,8 @@ private fun ColumnScope.ScanningForServer(discoveryState: DiscoveryState) { .alpha(currentAlpha) .semantics { alpha = currentAlpha - }.widthIn(max = MaxContentWidth), + } + .widthIn(max = MaxContentWidth), ) Spacer(modifier = Modifier.weight(1f - 2f * positionPercentage)) diff --git a/onboarding/src/test/kotlin/io/homeassistant/companion/android/compose/HAAppTest.kt b/onboarding/src/test/kotlin/io/homeassistant/companion/android/compose/HAAppTest.kt index 77deca28f49..3fb3c6d1422 100644 --- a/onboarding/src/test/kotlin/io/homeassistant/companion/android/compose/HAAppTest.kt +++ b/onboarding/src/test/kotlin/io/homeassistant/companion/android/compose/HAAppTest.kt @@ -16,6 +16,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication import io.homeassistant.companion.android.HAStartDestinationRoute import io.homeassistant.companion.android.HiltComponentActivity +import io.homeassistant.companion.android.frontend.navigation.FrontendActivityRoute import io.homeassistant.companion.android.frontend.navigation.FrontendRoute import io.homeassistant.companion.android.onboarding.OnboardingRoute import io.homeassistant.companion.android.onboarding.R @@ -80,7 +81,7 @@ class HAAppTest { @Test fun `Given HAApp when navigate to Welcome then show Welcome`() { - setApp(OnboardingRoute) + setApp(OnboardingRoute()) composeTestRule.apply { assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) onNodeWithText(stringResource(R.string.welcome_home_assistant_title)).assertIsDisplayed() @@ -92,20 +93,22 @@ class HAAppTest { } @Test - fun `Given HAApp when navigate to Welcome then show Frontend`() { + fun `Given HAApp when navigate to Frontend then navigate to FrontEndActivity and finish current activity`() { setApp(FrontendRoute()) composeTestRule.apply { - assertNull(navController.currentBackStackEntry?.destination?.route) + assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) verify(exactly = 1) { activityNavigator.navigate( match { - it.route == FrontendRoute.serializer().descriptor.serialName + "?path={path}&server={server}" + it.route == FrontendActivityRoute.serializer().descriptor.serialName + "?path={path}&server={server}" }, any(), any(), any(), ) } + // TODO remove this once we are using WebViewActivity anymore + assertTrue(activity.isFinishing) } } } diff --git a/onboarding/src/test/kotlin/io/homeassistant/companion/android/launcher/LauncherViewModelTest.kt b/onboarding/src/test/kotlin/io/homeassistant/companion/android/launcher/LauncherViewModelTest.kt index 136e25c2a3b..173b583aaf5 100644 --- a/onboarding/src/test/kotlin/io/homeassistant/companion/android/launcher/LauncherViewModelTest.kt +++ b/onboarding/src/test/kotlin/io/homeassistant/companion/android/launcher/LauncherViewModelTest.kt @@ -40,8 +40,8 @@ class LauncherViewModelTest { private lateinit var viewModel: LauncherViewModel - private fun createViewModel() { - viewModel = LauncherViewModel(workManager, serverManager, networkStatusMonitor) + private fun createViewModel(initialDeepLink: LauncherActivity.DeepLink? = null) { + viewModel = LauncherViewModel(initialDeepLink, workManager, serverManager, networkStatusMonitor) } @BeforeEach @@ -72,7 +72,7 @@ class LauncherViewModelTest { advanceUntilIdle() assertEquals(1, viewModel.navigationEventsFlow.replayCache.size) - assertEquals(LauncherNavigationEvent.Frontend, viewModel.navigationEventsFlow.replayCache.first()) + assertEquals(LauncherNavigationEvent.Frontend(null, ServerManager.SERVER_ID_ACTIVE), viewModel.navigationEventsFlow.replayCache.first()) assertEquals(0, networkStateFlow.subscriptionCount.value) // verify resync registration @@ -134,7 +134,7 @@ class LauncherViewModelTest { advanceUntilIdle() assertEquals(1, viewModel.navigationEventsFlow.replayCache.size) - assertEquals(LauncherNavigationEvent.Frontend, viewModel.navigationEventsFlow.replayCache.first()) + assertEquals(LauncherNavigationEvent.Frontend(null, ServerManager.SERVER_ID_ACTIVE), viewModel.navigationEventsFlow.replayCache.first()) assertEquals(0, networkStateFlow.subscriptionCount.value) // verify resync registration @@ -150,7 +150,7 @@ class LauncherViewModelTest { createViewModel() advanceUntilIdle() - assertEquals(LauncherNavigationEvent.Onboarding, viewModel.navigationEventsFlow.replayCache.first()) + assertEquals(LauncherNavigationEvent.Onboarding(null), viewModel.navigationEventsFlow.replayCache.first()) } @Test @@ -163,7 +163,7 @@ class LauncherViewModelTest { advanceUntilIdle() assertEquals(1, viewModel.navigationEventsFlow.replayCache.size) - assertEquals(LauncherNavigationEvent.Onboarding, viewModel.navigationEventsFlow.replayCache.first()) + assertEquals(LauncherNavigationEvent.Onboarding(null), viewModel.navigationEventsFlow.replayCache.first()) } @Test @@ -177,7 +177,7 @@ class LauncherViewModelTest { advanceUntilIdle() assertEquals(1, viewModel.navigationEventsFlow.replayCache.size) - assertEquals(LauncherNavigationEvent.Onboarding, viewModel.navigationEventsFlow.replayCache.first()) + assertEquals(LauncherNavigationEvent.Onboarding(null), viewModel.navigationEventsFlow.replayCache.first()) } @Test @@ -190,7 +190,7 @@ class LauncherViewModelTest { advanceUntilIdle() assertEquals(1, viewModel.navigationEventsFlow.replayCache.size) - assertEquals(LauncherNavigationEvent.Onboarding, viewModel.navigationEventsFlow.replayCache.first()) + assertEquals(LauncherNavigationEvent.Onboarding(null), viewModel.navigationEventsFlow.replayCache.first()) } @Test @@ -248,7 +248,31 @@ class LauncherViewModelTest { createViewModel() advanceUntilIdle() - assertEquals(LauncherNavigationEvent.Onboarding, viewModel.navigationEventsFlow.replayCache.first()) + assertEquals(LauncherNavigationEvent.Onboarding(null), viewModel.navigationEventsFlow.replayCache.first()) assertTrue(!viewModel.shouldShowSplashScreen()) } + + @Test + fun `Given initial deep link is Invite when creating viewModel, then navigate to onboarding with the server url`() = runTest { + createViewModel(LauncherActivity.DeepLink.Invite("http://homeassistant.io")) + advanceUntilIdle() + assertEquals(LauncherNavigationEvent.Onboarding("http://homeassistant.io"), viewModel.navigationEventsFlow.replayCache.first()) + } + + @Test + fun `Given initial deep link is NavigateTo when creating viewModel, then navigate to frontend with the server id and path`() = runTest { + val serverId = 42 + val server = mockk(relaxed = true) + every { workManager.enqueue(any()) } returns mockk() + + coEvery { serverManager.getServer(serverId) } returns server + coEvery { serverManager.isRegistered() } returns true + coEvery { serverManager.authenticationRepository().getSessionState() } returns SessionState.CONNECTED + val networkStateFlow = MutableStateFlow(NetworkState.READY_REMOTE) + coEvery { networkStatusMonitor.observeNetworkStatus(any()) } returns networkStateFlow + + createViewModel(LauncherActivity.DeepLink.NavigateTo("/path", serverId)) + advanceUntilIdle() + assertEquals(LauncherNavigationEvent.Frontend("/path", serverId), viewModel.navigationEventsFlow.replayCache.first()) + } } diff --git a/onboarding/src/test/kotlin/io/homeassistant/companion/android/onboarding/OnboardingNavigationTest.kt b/onboarding/src/test/kotlin/io/homeassistant/companion/android/onboarding/OnboardingNavigationTest.kt index aebf5cdb965..3a79de84002 100644 --- a/onboarding/src/test/kotlin/io/homeassistant/companion/android/onboarding/OnboardingNavigationTest.kt +++ b/onboarding/src/test/kotlin/io/homeassistant/companion/android/onboarding/OnboardingNavigationTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag @@ -17,6 +18,8 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp import androidx.core.content.ContextCompat import androidx.navigation.NavController import androidx.navigation.NavDestination.Companion.hasRoute @@ -37,6 +40,7 @@ import io.homeassistant.companion.android.compose.navigateToUri import io.homeassistant.companion.android.onboarding.connection.CONNECTION_SCREEN_TAG import io.homeassistant.companion.android.onboarding.connection.ConnectionNavigationEvent import io.homeassistant.companion.android.onboarding.connection.ConnectionViewModel +import io.homeassistant.companion.android.onboarding.connection.navigation.ConnectionRoute import io.homeassistant.companion.android.onboarding.localfirst.navigation.LocalFirstRoute import io.homeassistant.companion.android.onboarding.localfirst.navigation.navigateToLocalFirst import io.homeassistant.companion.android.onboarding.locationforsecureconnection.navigation.LocationForSecureConnectionRoute @@ -51,6 +55,7 @@ import io.homeassistant.companion.android.onboarding.nameyourdevice.navigation.n import io.homeassistant.companion.android.onboarding.serverdiscovery.DELAY_BEFORE_DISPLAY_DISCOVERY import io.homeassistant.companion.android.onboarding.serverdiscovery.HomeAssistantInstance import io.homeassistant.companion.android.onboarding.serverdiscovery.HomeAssistantSearcher +import io.homeassistant.companion.android.onboarding.serverdiscovery.ONE_SERVER_FOUND_MODAL_TAG import io.homeassistant.companion.android.onboarding.serverdiscovery.ServerDiscoveryModule import io.homeassistant.companion.android.onboarding.serverdiscovery.navigation.ServerDiscoveryRoute import io.homeassistant.companion.android.onboarding.serverdiscovery.navigation.navigateToServerDiscovery @@ -139,7 +144,9 @@ internal class OnboardingNavigationTest { mockkStatic(NavController::navigateToUri) every { any().navigateToUri(any()) } just Runs + } + private fun setContent(serverToOnboard: String? = null) { composeTestRule.setContent { navController = TestNavHostController(LocalContext.current) navController.navigatorProvider.addNavigator(ComposeNavigator()) @@ -151,7 +158,7 @@ internal class OnboardingNavigationTest { ) { NavHost( navController = navController, - startDestination = OnboardingRoute, + startDestination = OnboardingRoute(), ) { onboarding( navController, @@ -159,16 +166,24 @@ internal class OnboardingNavigationTest { onOnboardingDone = { onboardingDone = true }, + serverToOnboard = serverToOnboard, ) } } } } + private fun testNavigation(serverToOnboard: String? = null, testContent: suspend AndroidComposeTestRule<*, *>.() -> Unit) { + setContent(serverToOnboard) + runTest { + composeTestRule.testContent() + } + } + @Test fun `Given no action when starting the app then show Welcome`() { - assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) - composeTestRule.apply { + testNavigation { + assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) onNodeWithText(stringResource(R.string.welcome_learn_more)).performScrollTo().assertIsDisplayed().performClick() verify { any().navigateToUri("https://www.home-assistant.io") } } @@ -176,7 +191,7 @@ internal class OnboardingNavigationTest { @Test fun `Given clicking on connect button when starting the onboarding then show ServerDiscovery then back goes to Welcome`() { - composeTestRule.apply { + testNavigation { onNodeWithText(stringResource(R.string.welcome_connect_to_ha)).assertIsDisplayed().performClick() assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) @@ -188,12 +203,26 @@ internal class OnboardingNavigationTest { } } + @Test + fun `Given clicking on connect button with server to onboard when starting the onboarding then show Connection screen then back goes to Welcome`() { + testNavigation("http://homeassistant.local") { + onNodeWithText(stringResource(R.string.welcome_connect_to_ha)).assertIsDisplayed().performClick() + assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) + + onNodeWithTag(CONNECTION_SCREEN_TAG).assertIsDisplayed() + + composeTestRule.activity.onBackPressedDispatcher.onBackPressed() + + assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) + } + } + @Test fun `Given clicking enter manual address button when discovering server then show ManualServer then back goes to ServerDiscovery`() { - composeTestRule.apply { + testNavigation { navController.navigateToServerDiscovery() assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) - onNodeWithText(stringResource(commonR.string.manual_setup)).assertIsDisplayed().performClick() + onNodeWithText(stringResource(commonR.string.manual_setup)).performScrollTo().assertIsDisplayed().performClick() assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) @@ -207,10 +236,10 @@ internal class OnboardingNavigationTest { @Test fun `Given enter manual address when setting url and clicking connect then show ConnectScreen then back goes to ManualServer`() { - composeTestRule.apply { + testNavigation { navController.navigateToServerDiscovery() assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) - onNodeWithText(stringResource(commonR.string.manual_setup)).assertIsDisplayed().performClick() + onNodeWithText(stringResource(commonR.string.manual_setup)).performScrollTo().assertIsDisplayed().performClick() assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) @@ -219,8 +248,9 @@ internal class OnboardingNavigationTest { onNodeWithText("http://homeassistant.local:8123").performTextInput("http://ha.local") - onNodeWithText(stringResource(commonR.string.connect)).assertIsEnabled().performClick() + onNodeWithText(stringResource(commonR.string.connect)).performScrollTo().assertIsDisplayed().assertIsEnabled().performClick() + assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) onNodeWithTag(CONNECTION_SCREEN_TAG).assertIsDisplayed() composeTestRule.activity.onBackPressedDispatcher.onBackPressed() @@ -233,10 +263,10 @@ internal class OnboardingNavigationTest { @Test fun `Given a server discovered when clicking on it then show ConnectScreen then back goes to ServerDiscovery`() { val instanceUrl = "http://ha.local" - composeTestRule.apply { + testNavigation { navController.navigateToServerDiscovery() assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) - onNodeWithText(stringResource(commonR.string.manual_setup)).assertIsDisplayed() + onNodeWithText(stringResource(commonR.string.manual_setup)).performScrollTo().assertIsDisplayed() instanceChannel.trySend(HomeAssistantInstance("Test", URL(instanceUrl), HomeAssistantVersion(2025, 9, 1))) @@ -246,9 +276,19 @@ internal class OnboardingNavigationTest { // Wait for the screen to update based on the instance given in instanceChannel waitUntilAtLeastOneExists(hasText(instanceUrl), timeoutMillis = DELAY_BEFORE_DISPLAY_DISCOVERY.inWholeMilliseconds) + onNodeWithTag(ONE_SERVER_FOUND_MODAL_TAG).performTouchInput { + swipeUp(startY = bottom * 0.9f, endY = centerY, durationMillis = 200) + } + + waitForIdle() + onNodeWithText(instanceUrl).assertIsDisplayed() - onNodeWithText(stringResource(R.string.server_discovery_connect)).performClick() + onNodeWithText(stringResource(R.string.server_discovery_connect)).assertIsDisplayed().performClick() + + waitForIdle() + + assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) onNodeWithTag(CONNECTION_SCREEN_TAG).assertIsDisplayed() @@ -260,12 +300,12 @@ internal class OnboardingNavigationTest { @OptIn(ExperimentalTestApi::class) @Test - fun `Given a server discovered and connecting when authenticated then show NameYourDevice then back goes to ServerDiscovery not ConnectionScreen`() = runTest { + fun `Given a server discovered and connecting when authenticated then show NameYourDevice then back goes to ServerDiscovery not ConnectionScreen`() { val instanceUrl = "http://ha.local" - composeTestRule.apply { + testNavigation { navController.navigateToServerDiscovery() assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) - onNodeWithText(stringResource(commonR.string.manual_setup)).assertIsDisplayed() + onNodeWithText(stringResource(commonR.string.manual_setup)).performScrollTo().assertIsDisplayed() instanceChannel.trySend(HomeAssistantInstance("Test", URL(instanceUrl), HomeAssistantVersion(2025, 9, 1))) @@ -294,9 +334,9 @@ internal class OnboardingNavigationTest { } @Test - fun `Given device named when pressing next then show LocalFirst then goes back stop the app`() = runTest { + fun `Given device named when pressing next then show LocalFirst then goes back stop the app`() { val instanceUrl = "http://ha.local" - composeTestRule.apply { + testNavigation { navController.navigateToNameYourDevice(instanceUrl, "code") assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) @@ -312,7 +352,7 @@ internal class OnboardingNavigationTest { @Test fun `Given LocalFirst when pressing next then show LocationSharing then goes back stop the app`() { - composeTestRule.apply { + testNavigation { navController.navigateToLocalFirst(42, true) assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) onNodeWithText(stringResource(R.string.local_first_next)).performScrollTo().performClick() @@ -328,7 +368,7 @@ internal class OnboardingNavigationTest { @Test fun `Given LocationSharing when agreeing with plain text access to share then onboarding is done`() { - composeTestRule.apply { + testNavigation { navController.navigateToLocationSharing(42, true) assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) @@ -343,7 +383,7 @@ internal class OnboardingNavigationTest { @Test fun `Given LocationSharing when agreeing without plain text access to share then onboarding is done`() { - composeTestRule.apply { + testNavigation { navController.navigateToLocationSharing(42, false) assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) @@ -358,7 +398,7 @@ internal class OnboardingNavigationTest { @Test fun `Given LocationSharing when denying to share with plain text access then goes to LocationForSecureConnection then goes back stop the app`() { - composeTestRule.apply { + testNavigation { navController.navigateToLocationSharing(42, true) assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) @@ -378,7 +418,7 @@ internal class OnboardingNavigationTest { @Test fun `Given LocationSharing when denying to share without plain text access then onboarding is done`() { - composeTestRule.apply { + testNavigation { navController.navigateToLocationSharing(42, false) assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true) @@ -392,7 +432,7 @@ internal class OnboardingNavigationTest { @Test fun `Given LocationForSecureConnection when agreeing to share then onboarding is done`() { - composeTestRule.apply { + testNavigation { navController.navigateToLocationForSecureConnection(42) assertTrue(navController.currentBackStackEntry?.destination?.hasRoute() == true)