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.
- Gradle dependency direction (features depend down, never on each other)
- Feature isolation (
:feature:homemust not depend on:feature:profile) - API vs implementation (
ProfileNavigatorvsProfileActivity) - Hilt bindings at the composition root (
:app) - UI events (
SharedFlow) vs UI state (StateFlow) - Why a ViewModel must not start Activities (
Context/Intentstay in the UI) - Passing a navigation argument (
userIdonly) - Returning a result (Activity Result API)
- Graceful failure when a destination is unavailable
- Swapping implementations (real / fake / unavailable) without changing Home
- Unit-testing Home without compiling Profile
| 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:
- Kotlin 2.2
- Jetpack Compose + Material 3
- Hilt 2.60 + KSP 2.3
- Coroutines
StateFlow/SharedFlow
- Clone or copy the project and open the root folder
ModularNavigationLab(the directory that containssettings.gradle.kts). - Let Gradle sync. The wrapper downloads Gradle 9.5.0 on first run.
- If sync asks for SDK 37, install it from SDK Manager → SDK Platforms.
- If sync asks for a JDK, point it at JDK 17+ (Android Studio’s embedded JDK is fine).
- Select the app run configuration.
- Run on a device or emulator.
./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.
- Launch the app. Header: Scenario 1 of 7 · Cross-module navigation. Screen: HOME MODULE.
- Confirm Home shows
feature-homeandNavigator argument: userId = 123. - Tap Open Profile.
- Expect: Profile Activity opens. Header still shows scenario 1. Screen: PROFILE MODULE,
feature-profile, User ID: 123. - Logcat:
[feature-home] HomeViewModel emit OpenProfile(userId=123)thenProfileNavigatorImpl.createOpenIntentthenProfileActivity.onCreate. - Tap Back to return to Home.
If Profile opened, the contract worked. Home never named ProfileActivity.
Goal: Profile can return to Home without depending on :feature:home.
- Tap chip 2. Header: Scenario 2 of 7 · Avoid circular dependency.
- Tap Open Profile.
- On Profile, tap Open Home (not Back).
- Expect: You are on Home again. Profile is gone from the back stack (
CLEAR_TOP+singleTop). Pressing system Back from Home leaves the app; it does not return to Profile. - Logcat:
[feature-profile] ProfileScreen → HomeNavigator.openHomethen[feature-home] HomeNavigatorImpl.start HomeActivity.
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.
- Tap chip 3. Header: Scenario 3 of 7 · Pass userId only.
- On Home, confirm the only argument shown is
userId = 123. - Tap Open Profile.
- Expect: Profile shows User ID: 123, email
alex@lab.dev, phone+1 555 0100, display name Alex Rivera. - Those fields were not in the Intent.
ProfileDirectory.findById("123")loaded them inside:feature:profile. - Tap Back.
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.
- Tap chip 4. Header: Scenario 4 of 7 · Return a result.
- Tap Open Profile.
- Change Display name (for example
Alex R.). - Tap Save.
- Expect: Profile closes. Home shows Last result: Alex R. and status
Profile returned “Alex R.”. - Tap Open Profile again. Expect: the name field still shows the saved name (in-memory
ProfileDirectory). - Rotate the device on Home. Expect: Last result is still there (
StateFlow). The Profile screen does not reopen (events areSharedFlow, not replayed).
Cancel path: Open Profile, tap Back without Save. Home must not overwrite Last result.
Goal: Home handles a missing destination without probing ProfileActivity::class.
- Tap chip 5. Header: Scenario 5 of 7 · Feature unavailable.
- Expect: Real / Fake / Unavailable chips appear. Unavailable is selected for you.
- Tap Open Profile.
- Expect: Profile does not open. Status:
Profile is not available. Showing Home fallback. - Switch the implementation chip to Real, tap Open Profile. Expect: Profile opens again (same Home button, different impl).
- Switch back to Unavailable (or tap chip 5 again) and confirm the fallback still works.
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.
- Tap chip 6. Header: Scenario 6 of 7 · Swap navigator impl.
- Expect: Real / Fake / Unavailable chips are visible.
Real
- Select Real, tap Open Profile. Expect: Profile Activity opens. Tap Back.
Fake
- Select Fake, tap Open Profile.
- Expect: Profile does not open. Status:
Fake navigator recorded userId=123. Profile was not opened. - Logcat: Home still emits
OpenProfile(userId=123). There is noProfileActivity.onCreate.
Unavailable
- Select Unavailable, tap Open Profile.
- Expect: same fallback as scenario 5.
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
- Tap chip 7. Header: Scenario 7 of 7 · Test Home without Profile.
- Implementation chips are visible (same as scenario 6). Select Fake, tap Open Profile.
- Expect: recorded
userId=123, no Profile Activity — the same isolation the unit tests assert.
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 |
- Light background: a smooth vertical lavender wash (several stops from a
#6200EEtint down to mist), plus a soft radial glow at the top and a quieter one at the bottom-right. - Dark background: the same idea in deep purple, so
#6200EEstill reads. - Dynamic / wallpaper colors are off in
LabTheme, so Android 12+ cannot replace the brand purple. - XML
windowBackgrounduses a matching gradient so theMainActivitytrampoline does not flash white.
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.