| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
A small Android learning app for one interview question:
How can one feature module open an Activity owned by another feature module without a Gradle dependency (or a cycle) between those features?
Home (:feature:home) opens Profile (:feature:profile) through a navigation contract in :core:navigation. Hilt binds the contract to Profile’s implementation at runtime in :app. That is the Dependency Inversion Principle applied to cross-module navigation.
The in-app header always shows Scenario N of 7. Tap chips 1–7 to change the lesson. Home and Profile stay visually consistent: Material 3, primary #6200EE, and a soft purple gradient behind the same centered layout.
| Item | Version / note |
|---|---|
| Android Studio | Compatible with Android Gradle Plugin 9.3 (Narwhal / 2026+ toolchains) |
| JDK | 17 or newer (the Gradle wrapper uses the Foojay toolchain resolver) |
| Android SDK | compileSdk 37, targetSdk 37, minSdk 24 |
| Device | Emulator or hardware, API 24+ |
| Gradle | 9.5.0 via gradle/wrapper (downloaded on first sync) |
Stack:
./gradlew :app:assembleDebug
./gradlew :app:installDebugOn Windows use gradlew.bat.
Useful tasks:
# Home unit tests (does not compile :feature:profile)
./gradlew :feature:home:testDebugUnitTest
# Prove there is no feature-to-feature Gradle edge
./gradlew :feature:home:dependencies --configuration debugCompileClasspath
./gradlew :feature:profile:dependencies --configuration debugCompileClasspathHome’s compile classpath should list :core:navigation and :core:ui, not :feature:profile.
Profile’s compile classpath should list :core:navigation and :core:ui, not :feature:home.
:app
├── :feature:home → :core:navigation, :core:ui
├── :feature:profile → :core:navigation, :core:ui
└── :core:navigation (Hilt binds ProfileNavigator)
:app includes both features so their implementations land in the APK, and it binds ProfileNavigator. Both features depend on :core:ui for theme only. The important missing arrows:
:feature:home ──✕──► :feature:profile
:feature:profile ──✕──► :feature:home
| Module | Owns | Depends on |
|---|---|---|
| :app | MainActivity trampoline, @HiltAndroidApp, Hilt binding of ProfileNavigator | home, profile, core:navigation |
| :core:navigation | Contracts, result types, fake / unavailable navigators | Android SDK only |
| :core:ui | LabTheme, gradient scaffold, numbered scenario picker | Compose / Material 3 |
| :feature:home | HomeActivity, screen, ViewModel, HomeNavigatorImpl, unit tests | core:navigation, core:ui |
| :feature:profile | ProfileActivity, screen, ProfileDirectory, ProfileNavigatorImpl | core:navigation, core:ui |
HomeViewModel --HomeEvent.OpenProfile(userId)--> HomeScreen
HomeScreen --ProfileNavigator.prepareOpen--> NavigationResult
Success --OpenProfileContract--> ProfileActivity
Profile --HomeNavigator.openHome--> HomeActivity (CLEAR_TOP)
Profile Save --setResult(ProfileResult)--> Home (Last result)
HomeViewModel never sees Context, Intent, or ProfileActivity.
Profile never names HomeActivity.
| Layer | Types |
|---|---|
| Home | HomeScreen, HomeViewModel, HomeEvent, HomeNavigatorImpl |
| Core navigation | ProfileNavigator, HomeNavigator, OpenProfileContract, ProfileResult, NavigationResult |
| Profile | ProfileActivity, ProfileScreen, ProfileNavigatorImpl, User / ProfileDirectory |
| App | Application class, feature inclusion in the APK, which navigator impl is bound |
The app ships all seven lessons. Use the numbered chips (1–7) on Home or Profile. They change the headline and, for 5–7, which ProfileNavigator implementation is used. They do not rebuild the Gradle graph.
| # | Title | What it proves |
|---|---|---|
| 1 | Cross-module navigation | Home opens Profile through ProfileNavigator |
| 2 | Avoid circular dependency | Profile opens Home through HomeNavigator |
| 3 | Pass userId only | Profile loads User itself (ProfileDirectory) |
| 4 | Return a result | Edit name → Save → Home shows Last result |
| 5 | Feature unavailable | Fallback when prepareOpen returns FeatureUnavailable |
| 6 | Swap navigator impl | Real / Fake / Unavailable chips; Home call site unchanged |
| 7 | Test Home without Profile | Home unit tests do not compile :feature:profile |
Scenario 5 selects Unavailable automatically. 1–4 stay on Real so Open Profile still opens Profile. 6–7 show the Real / Fake / Unavailable chips.
Before the walkthrough: install the app (see Setup), open Logcat, and filter by ModularNav.
Work through the chips in order. After each scenario, you should be back on Home unless the steps say otherwise.
These apply to every scenario. They prove the Gradle graph never grows a feature-to-feature edge.
./gradlew :feature:home:dependencies --configuration debugCompileClasspath
./gradlew :feature:profile:dependencies --configuration debugCompileClasspathHome must list :core:navigation and :core:ui, not :feature:profile.
Profile must list :core:navigation and :core:ui, not :feature:home.
In Logcat (ModularNav) you should never see Home importing ProfileActivity or Profile importing HomeActivity. You should see ProfileNavigatorImpl / HomeNavigatorImpl (or the fake / unavailable types) instead.
Goal: Home opens Profile without a Gradle dependency on :feature:profile.
If Profile opened, the contract worked. Home never named ProfileActivity.
Goal: Profile can return to Home without depending on :feature:home.
If this compiled, there is no home ↔ profile Gradle cycle. Both features depend down on HomeNavigator / ProfileNavigator.
Goal: Navigation passes an id, not a User object. Profile owns the record.
Home has no User type. A shared Parcelable / :core:model is not required for this lab.
Goal: Profile returns a value to Home through the Activity Result API, not a shared ViewModel.
Cancel path: Open Profile, tap Back without Save. Home must not overwrite Last result.
Goal: Home handles a missing destination without probing ProfileActivity::class.
Home’s click path did not change. prepareOpen returned FeatureUnavailable; the UI never started an Intent.
Goal: One ProfileNavigator interface, three implementations. Home does not import Fake or Profile.
Real
Fake
Unavailable
The Open Profile button and ViewModel are unchanged. :app (DelegatingProfileNavigator) is the only place that knows the three concrete classes.
Goal: :feature:home is testable without :feature:profile on the classpath.
In the app
On the JVM (required for this scenario)
./gradlew :feature:home:testDebugUnitTestExpect: BUILD SUCCESSFUL. Tests live in :feature:home/src/test:
| Test | What it asserts |
|---|---|
| HomeViewModelTest | Click emits HomeEvent.OpenProfile("123"). No navigator, no Profile. |
| OpenProfileWithFakeNavigatorTest | ProfileNavigator = FakeProfileNavigator(); prepareOpen("123") is Recorded and lastUserId is 123. Blank id is Error. |
:feature:profile is not a testImplementation of Home. If these tests compile, Home does not need Profile’s implementation module.
:app
ModularNavigationLabApp @HiltAndroidApp
MainActivity trampoline → HomeActivity
AppNavigationModule binds ProfileNavigator (real / fake / unavailable)
DelegatingProfileNavigator
:core:navigation
ProfileNavigator prepareOpen + createOpenIntent
HomeNavigator Profile → Home contract
OpenProfileContract Activity Result input/output
ProfileResult displayName returned to Home
NavigationResult Success / Recorded / Unavailable / Error
FakeProfileNavigator records lastUserId, no Activity
UnavailableProfileNavigator
NAV_LOG_TAG "ModularNav"
:core:ui
LabTheme Material 3, primary #6200EE, dynamic color off
LabScreenScaffold centered layout + purple gradient
LabScenario numbered 1–7 header + picker
:feature:home
HomeActivity
HomeScreen / HomeViewModel / HomeEvent
HomeNavigatorImpl
src/test HomeViewModelTest, OpenProfileWithFakeNavigatorTest
:feature:profile
ProfileActivity
ProfileScreen
ProfileDirectory loads User by id (Profile-owned)
ProfileNavigatorImpl
The screens did not change shape: title, module name, actions, status. Color and background only.
| Token | Value | Use |
|---|---|---|
| Primary | #6200EE | Buttons, selected chips, launcher, radial glow |
| Deep companion | #3700B3 | Dark-theme containers, faint bottom glow |
| Soft / mist | #EDE4FF / #FAF7FF | Light gradient stops |
One feature must not depend on another feature’s implementation just to open its screen. The source module depends on an abstraction (ProfileNavigator). The destination feature implements it. Hilt in :app connects them at runtime. That preserves isolation, avoids Gradle cycles, and makes Home testable with a fake.
| Approach | Coupling | Type safety | Best use case |
|---|---|---|---|
| Direct feature dependency | High | Strong | Tiny app, two features that will never split |
| Navigation contract | Low | Strong | Product-scale multi-module apps (this lab) |
| Deep link | Low | Weak | Cross-app, notifications, web, deferred destinations |
| Dynamic feature navigation | Low at compile time | Medium | On-demand modules, Play Feature Delivery |
| Symptom | What to try |
|---|---|
| Sync fails on SDK 37 | SDK Manager → install Android API 37 |
| Sync fails on JDK | Settings → Build Tools → Gradle JDK → 17+ |
| ProfileActivity cannot start | Activities are exported=false; they must be started with an explicit Intent from ProfileNavigatorImpl, not an implicit action |
| Unit tests fail on Log.d | Home already sets unitTests.isReturnDefaultValues = true |
| Keyboard covers the name field | Profile uses windowSoftInputMode=adjustResize and the scaffold has imePadding |
Learning / sample project. Use and modify freely for study.
| Back | FazBrowse Home | New Git URL |