diff --git a/app/src/main/java/ru/yeahub/MainActivity.kt b/app/src/main/java/ru/yeahub/MainActivity.kt index 1759701a..2ae79b67 100644 --- a/app/src/main/java/ru/yeahub/MainActivity.kt +++ b/app/src/main/java/ru/yeahub/MainActivity.kt @@ -13,7 +13,6 @@ import androidx.compose.runtime.setValue import androidx.navigation.compose.rememberNavController import org.koin.android.ext.android.inject import ru.yeahub.core_ui.theme.YeaHubTheme -import ru.yeahub.navigation_api.NavigationPathManager import ru.yeahub.navigation_impl.AppNavigation import ru.yeahub.navigation_impl.NotificationNavigationService import timber.log.Timber @@ -49,17 +48,13 @@ import timber.log.Timber * - Используйте NavController для навигации между экранами */ class MainActivity : ComponentActivity() { - - private val pathManager: NavigationPathManager by inject() - private lateinit var notificationService: NotificationNavigationService - + + private val notificationService: NotificationNavigationService by inject() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - - // Инициализируем сервис уведомлений - notificationService = NotificationNavigationService(pathManager) - + setContent { YeaHubTheme { val navController = rememberNavController() diff --git a/core/navigation-api/build.gradle.kts b/core/navigation-api/build.gradle.kts index 5fe586a6..bbf21253 100644 --- a/core/navigation-api/build.gradle.kts +++ b/core/navigation-api/build.gradle.kts @@ -36,6 +36,8 @@ android { } dependencies { + api(project(":core:feature-toggle-api")) + implementation(libs.androidx.navigation.compose) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) diff --git a/core/navigation-api/src/main/java/ru/yeahub/navigation_api/FeatureApi.kt b/core/navigation-api/src/main/java/ru/yeahub/navigation_api/FeatureApi.kt index bdb2f6b1..aff5c2d8 100644 --- a/core/navigation-api/src/main/java/ru/yeahub/navigation_api/FeatureApi.kt +++ b/core/navigation-api/src/main/java/ru/yeahub/navigation_api/FeatureApi.kt @@ -3,10 +3,11 @@ package ru.yeahub.navigation_api import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController +import ru.yeahub.feature_toggle_api.FeatureToggle /** * Интерфейс для модульной регистрации навигации фичи. - * + * * Каждая фича должна реализовать этот интерфейс, чтобы: * 1. Максимально изолировать логику навигации внутри модуля * 2. Самостоятельно регистрировать все необходимые маршруты @@ -16,14 +17,21 @@ import androidx.navigation.NavHostController interface FeatureApi { /** * Определяет имя фичи для создания маршрута. - * + * * @return Имя фичи (например, "home", "profile", "questions") */ fun getFeatureName(): String - + + /** + * Тоггл доступности фичи или null, если фича всегда доступна. + * + * @return Тоггл фичи или null для core-фичи + */ + fun featureToggle(): FeatureToggle? = null + /** * Определяет является ли фича корневой (отображается в нижней навигации). - * + * * @return true если фича должна отображаться в нижней навигации */ fun isRootFeature(): Boolean = false diff --git a/core/navigation-impl/build.gradle.kts b/core/navigation-impl/build.gradle.kts index 7226c9d4..5529d2ed 100644 --- a/core/navigation-impl/build.gradle.kts +++ b/core/navigation-impl/build.gradle.kts @@ -38,10 +38,16 @@ android { composeOptions { kotlinCompilerExtensionVersion = "1.5.8" } + testOptions { + unitTests.all { + it.useJUnitPlatform() + } + } } dependencies { implementation(project(":core:navigation-api")) + implementation(project(":core:feature-toggle-api")) implementation(project(":core:ui")) // Feature APIs @@ -66,7 +72,9 @@ dependencies { implementation(libs.androidx.runtime.android) implementation(libs.androidx.icons) - testImplementation(libs.junit) + testImplementation(libs.junit.jupiter) + testImplementation(platform(libs.junit.bom)) + testRuntimeOnly(libs.junit.platform.launcher) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) // Timber diff --git a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/AppNavigation.kt b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/AppNavigation.kt index 35c59fef..d41b7fb8 100644 --- a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/AppNavigation.kt +++ b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/AppNavigation.kt @@ -16,6 +16,8 @@ import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -33,6 +35,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import org.koin.compose.getKoin import ru.yeahub.core_ui.theme.Theme +import ru.yeahub.feature_toggle_api.FeatureAvailabilityService import ru.yeahub.navigation_api.FeatureApi import ru.yeahub.navigation_api.NavigationPathManager import ru.yeahub.navigation_impl.model.BottomNavigationItem @@ -66,23 +69,45 @@ fun AppNavigation( modifier: Modifier = Modifier, navController: NavHostController = rememberNavController(), pathManager: NavigationPathManager = getKoin().get(), + featureAvailabilityService: FeatureAvailabilityService = getKoin().get(), ) { val features: Set = getKoin().getAll().toSet() Timber.d("AppNavigation onCreate: Loaded features: ${features.map { it.javaClass.simpleName }}") val navItems = getBottomNavItems() - + features.forEach { feature -> feature.initialize(pathManager) } + val featureFlagsSnapshot by featureAvailabilityService.featureFlagsSnapshot.collectAsState() + val disabledFeatureNames = remember(featureFlagsSnapshot) { + collectDisabledFeatureNames(features, featureAvailabilityService) + } + + val visibleNavItems = remember(disabledFeatureNames) { + navItems.filter { isRouteEnabled(it.route, disabledFeatureNames) } + } + val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route - val selectedRoute = getSelectedRoute(currentRoute, navItems) - + val selectedRoute = getSelectedRoute(currentRoute, visibleNavItems) + currentRoute?.let { route -> pathManager.setCurrentPath(route) } + LaunchedEffect(disabledFeatureNames, currentRoute) { + if (currentRoute != null && !isRouteEnabled(currentRoute, disabledFeatureNames)) { + val fallbackRoute = firstAvailableRoute(navItems.map { it.route }, disabledFeatureNames) + if (fallbackRoute != null && fallbackRoute != currentRoute) { + navController.navigate(fallbackRoute) { + launchSingleTop = true + popUpTo(navController.graph.startDestinationId) { inclusive = false } + } + } + } + } + Scaffold( modifier = modifier, bottomBar = { @@ -91,7 +116,7 @@ fun AppNavigation( .height(100.dp), containerColor = Theme.colors.purple700 ) { - navItems.forEach { item -> + visibleNavItems.forEach { item -> val isSelected by remember(selectedRoute) { derivedStateOf { selectedRoute == item.route } } @@ -139,7 +164,6 @@ fun AppNavigation( }, alwaysShowLabel = false ) - Timber.d("NavSelected", "$currentRoute") } } } @@ -251,7 +275,7 @@ private fun registerChildFeatures( targetRootFeatures.forEach { rootFeature -> pathManager.setCurrentPath(rootFeature.getFeatureName()) - + // Регистрируем дочернюю фичу childFeature.registerGraph( navGraphBuilder = navGraphBuilder, diff --git a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationFactory.kt b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationFactory.kt index 946f2fe1..3214a207 100644 --- a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationFactory.kt +++ b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationFactory.kt @@ -11,8 +11,9 @@ fun getBottomNavItems(): List = listOf( BottomNavigationItem.Questions ) -fun getSelectedRoute(currentRoute: String?, navItems: List): String = when { - currentRoute == null -> navItems[1].route +fun getSelectedRoute(currentRoute: String?, navItems: List): String? = when { + navItems.isEmpty() -> null + currentRoute == null -> navItems.first().route navItems.any { it.route == currentRoute } -> currentRoute else -> navItems.find { currentRoute.startsWith(it.route) }?.route ?: navItems.last().route } \ No newline at end of file diff --git a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationGating.kt b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationGating.kt new file mode 100644 index 00000000..13360e77 --- /dev/null +++ b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationGating.kt @@ -0,0 +1,33 @@ +package ru.yeahub.navigation_impl + +import ru.yeahub.feature_toggle_api.FeatureAvailabilityService +import ru.yeahub.navigation_api.FeatureApi + +fun collectDisabledFeatureNames( + features: Collection, + featureAvailabilityService: FeatureAvailabilityService +): Set = features.mapNotNull { feature -> + feature.featureToggle() + ?.takeUnless(featureAvailabilityService::isFeatureEnabled) + ?.let { feature.getFeatureName() } +}.toSet() + +fun isRouteEnabled(route: String?, disabledFeatureNames: Set): Boolean { + if (route.isNullOrEmpty()) return true + return route.substringBefore('?') + .split('/') + .none { segment -> segment in disabledFeatureNames } +} + +fun firstAvailableRoute(candidates: List, disabledFeatureNames: Set): String? = + candidates.firstOrNull { route -> isRouteEnabled(route, disabledFeatureNames) } + +fun resolveDeepLinkRoute( + directPath: String, + disabledFeatureNames: Set, + fallbackCandidates: List +): String? = if (isRouteEnabled(directPath, disabledFeatureNames)) { + directPath +} else { + firstAvailableRoute(fallbackCandidates, disabledFeatureNames) +} diff --git a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationPathModule.kt b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationPathModule.kt index 88f19215..0e002270 100644 --- a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationPathModule.kt +++ b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NavigationPathModule.kt @@ -14,7 +14,13 @@ val navigationPathModule = module { singleOf(::NavigationPathManagerImpl) bind NavigationPathManager::class // Сервис для обработки уведомлений - singleOf(::NotificationNavigationService) + single { + NotificationNavigationService( + pathManager = get(), + featureAvailabilityService = get(), + featureApis = getAll() + ) + } // Универсальный сервис уведомлений single { NotificationServiceImpl(get()) } diff --git a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NotificationNavigationService.kt b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NotificationNavigationService.kt index 508d1c0b..adfa7e78 100644 --- a/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NotificationNavigationService.kt +++ b/core/navigation-impl/src/main/java/ru/yeahub/navigation_impl/NotificationNavigationService.kt @@ -2,7 +2,9 @@ package ru.yeahub.navigation_impl import android.content.Intent import androidx.navigation.NavHostController +import ru.yeahub.feature_toggle_api.FeatureAvailabilityService import ru.yeahub.navigation_api.DeepLinkConfig +import ru.yeahub.navigation_api.FeatureApi import ru.yeahub.navigation_api.NavigationPathManager import timber.log.Timber @@ -15,7 +17,9 @@ import timber.log.Timber * - Правильную настройку back stack для корректной навигации назад */ class NotificationNavigationService( - private val pathManager: NavigationPathManager + private val pathManager: NavigationPathManager, + private val featureAvailabilityService: FeatureAvailabilityService, + private val featureApis: Collection ) { /** @@ -209,8 +213,29 @@ class NotificationNavigationService( * Общий метод для навигации с direct path. */ private fun navigateWithDirectPath(directPath: String, navController: NavHostController) { + val disabledFeatureNames = collectDisabledFeatureNames(featureApis, featureAvailabilityService) + val targetRoute = resolveDeepLinkRoute( + directPath = directPath, + disabledFeatureNames = disabledFeatureNames, + fallbackCandidates = getBottomNavItems().map { it.route } + ) + + if (targetRoute == null) { + Timber.w("Deep link to disabled feature blocked, no fallback: '$directPath'") + return + } + + if (targetRoute != directPath) { + Timber.w("Deep link to disabled feature blocked: '$directPath' -> '$targetRoute'") + navController.navigate(targetRoute) { + popUpTo(navController.graph.startDestinationId) { inclusive = false } + launchSingleTop = true + } + return + } + pathManager.prepareForDirectNavigation(directPath) - + navController.navigate(directPath) { popUpTo(navController.graph.startDestinationId) { inclusive = false diff --git a/core/navigation-impl/src/test/java/ru/yeahub/navigation_impl/NavigationFactoryTest.kt b/core/navigation-impl/src/test/java/ru/yeahub/navigation_impl/NavigationFactoryTest.kt new file mode 100644 index 00000000..d88f78e7 --- /dev/null +++ b/core/navigation-impl/src/test/java/ru/yeahub/navigation_impl/NavigationFactoryTest.kt @@ -0,0 +1,64 @@ +package ru.yeahub.navigation_impl + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.ArgumentsProvider +import org.junit.jupiter.params.provider.ArgumentsSource +import ru.yeahub.navigation_impl.model.BottomNavigationItem +import java.util.stream.Stream + +class NavigationFactoryTest { + + @ParameterizedTest + @ArgumentsSource(GetSelectedRouteArgumentsProvider::class) + fun `getSelectedRoute resolves selected tab for current route`( + testCase: GetSelectedRouteTestCase + ) { + val result = getSelectedRoute(testCase.currentRoute, testCase.navItems) + Assertions.assertEquals(testCase.expectedResult, result) + } + + data class GetSelectedRouteTestCase( + val currentRoute: String?, + val navItems: List, + val expectedResult: String? + ) + + class GetSelectedRouteArgumentsProvider : ArgumentsProvider { + override fun provideArguments(context: ExtensionContext?): Stream { + val navItems = getBottomNavItems() + return Stream.of( + Arguments.of( + GetSelectedRouteTestCase( + currentRoute = "home", + navItems = emptyList(), + expectedResult = null + ) + ), + Arguments.of( + GetSelectedRouteTestCase( + currentRoute = null, + navItems = navItems, + expectedResult = navItems.first().route + ) + ), + Arguments.of( + GetSelectedRouteTestCase( + currentRoute = "home", + navItems = navItems, + expectedResult = "home" + ) + ), + Arguments.of( + GetSelectedRouteTestCase( + currentRoute = "home/details/1/Title", + navItems = navItems, + expectedResult = "home" + ) + ) + ) + } + } +} diff --git a/core/navigation-impl/src/test/java/ru/yeahub/navigation_impl/NavigationGatingTest.kt b/core/navigation-impl/src/test/java/ru/yeahub/navigation_impl/NavigationGatingTest.kt new file mode 100644 index 00000000..ddc70879 --- /dev/null +++ b/core/navigation-impl/src/test/java/ru/yeahub/navigation_impl/NavigationGatingTest.kt @@ -0,0 +1,184 @@ +package ru.yeahub.navigation_impl + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.ArgumentsProvider +import org.junit.jupiter.params.provider.ArgumentsSource +import java.util.stream.Stream + +class NavigationGatingTest { + + @ParameterizedTest + @ArgumentsSource(IsRouteEnabledArgumentsProvider::class) + fun `isRouteEnabled returns expected for route and disabled features`( + testCase: IsRouteEnabledTestCase + ) { + val result = isRouteEnabled(testCase.route, testCase.disabledFeatureNames) + Assertions.assertEquals(testCase.expectedResult, result) + } + + @ParameterizedTest + @ArgumentsSource(FirstAvailableRouteArgumentsProvider::class) + fun `firstAvailableRoute returns first not disabled candidate`( + testCase: FirstAvailableRouteTestCase + ) { + val result = firstAvailableRoute(testCase.candidates, testCase.disabledFeatureNames) + Assertions.assertEquals(testCase.expectedResult, result) + } + + @ParameterizedTest + @ArgumentsSource(ResolveDeepLinkRouteArgumentsProvider::class) + fun `resolveDeepLinkRoute returns target route or fallback`( + testCase: ResolveDeepLinkRouteTestCase + ) { + val result = resolveDeepLinkRoute( + directPath = testCase.directPath, + disabledFeatureNames = testCase.disabledFeatureNames, + fallbackCandidates = testCase.fallbackCandidates + ) + Assertions.assertEquals(testCase.expectedResult, result) + } + + data class IsRouteEnabledTestCase( + val route: String?, + val disabledFeatureNames: Set, + val expectedResult: Boolean + ) + + data class FirstAvailableRouteTestCase( + val candidates: List, + val disabledFeatureNames: Set, + val expectedResult: String? + ) + + data class ResolveDeepLinkRouteTestCase( + val directPath: String, + val disabledFeatureNames: Set, + val fallbackCandidates: List, + val expectedResult: String? + ) + + class IsRouteEnabledArgumentsProvider : ArgumentsProvider { + override fun provideArguments(context: ExtensionContext?): Stream { + return Stream.of( + Arguments.of( + IsRouteEnabledTestCase( + route = null, + disabledFeatureNames = emptySet(), + expectedResult = true + ) + ), + Arguments.of( + IsRouteEnabledTestCase( + route = "", + disabledFeatureNames = emptySet(), + expectedResult = true + ) + ), + Arguments.of( + IsRouteEnabledTestCase( + route = "home", + disabledFeatureNames = emptySet(), + expectedResult = true + ) + ), + Arguments.of( + IsRouteEnabledTestCase( + route = "collections", + disabledFeatureNames = setOf("collections"), + expectedResult = false + ) + ), + Arguments.of( + IsRouteEnabledTestCase( + route = "home/details/{itemId}/{title}", + disabledFeatureNames = setOf("details"), + expectedResult = false + ) + ), + Arguments.of( + IsRouteEnabledTestCase( + route = "home/details/{itemId}/{title}", + disabledFeatureNames = emptySet(), + expectedResult = true + ) + ), + Arguments.of( + IsRouteEnabledTestCase( + route = "questions?category=tech", + disabledFeatureNames = setOf("questions"), + expectedResult = false + ) + ), + Arguments.of( + IsRouteEnabledTestCase( + route = "questions?category=tech", + disabledFeatureNames = emptySet(), + expectedResult = true + ) + ) + ) + } + } + + class FirstAvailableRouteArgumentsProvider : ArgumentsProvider { + override fun provideArguments(context: ExtensionContext?): Stream { + return Stream.of( + Arguments.of( + FirstAvailableRouteTestCase( + candidates = listOf("collections", "home", "questions"), + disabledFeatureNames = setOf("collections"), + expectedResult = "home" + ) + ), + Arguments.of( + FirstAvailableRouteTestCase( + candidates = listOf("collections", "questions", "home"), + disabledFeatureNames = setOf("collections"), + expectedResult = "questions" + ) + ), + Arguments.of( + FirstAvailableRouteTestCase( + candidates = listOf("collections", "questions"), + disabledFeatureNames = setOf("collections", "questions"), + expectedResult = null + ) + ) + ) + } + } + + class ResolveDeepLinkRouteArgumentsProvider : ArgumentsProvider { + override fun provideArguments(context: ExtensionContext?): Stream { + return Stream.of( + Arguments.of( + ResolveDeepLinkRouteTestCase( + directPath = "home/details/1/Title", + disabledFeatureNames = emptySet(), + fallbackCandidates = listOf("collections", "home", "questions"), + expectedResult = "home/details/1/Title" + ) + ), + Arguments.of( + ResolveDeepLinkRouteTestCase( + directPath = "collections/public_collections/1/Title", + disabledFeatureNames = setOf("collections"), + fallbackCandidates = listOf("collections", "home", "questions"), + expectedResult = "home" + ) + ), + Arguments.of( + ResolveDeepLinkRouteTestCase( + directPath = "collections/public_collections/1/Title", + disabledFeatureNames = setOf("collections", "home", "questions"), + fallbackCandidates = listOf("collections", "home", "questions"), + expectedResult = null + ) + ) + ) + } + } +}