Skip to content

Latest commit

 

History

History
369 lines (260 loc) · 15.9 KB

File metadata and controls

369 lines (260 loc) · 15.9 KB

ModularNavigationLab

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 17 to change the lesson. Home and Profile stay visually consistent: Material 3, primary #6200EE, and a soft purple gradient behind the same centered layout.


What you will learn

  • Gradle dependency direction (features depend down, never on each other)
  • Feature isolation (:feature:home must not depend on :feature:profile)
  • API vs implementation (ProfileNavigator vs ProfileActivity)
  • Hilt bindings at the composition root (:app)
  • UI events (SharedFlow) vs UI state (StateFlow)
  • Why a ViewModel must not start Activities (Context / Intent stay in the UI)
  • Passing a navigation argument (userId only)
  • 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

Requirements

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

Setup

Android Studio

  1. Clone or copy the project and open the root folder ModularNavigationLab (the directory that contains settings.gradle.kts).
  2. Let Gradle sync. The wrapper downloads Gradle 9.5.0 on first run.
  3. If sync asks for SDK 37, install it from SDK Manager → SDK Platforms.
  4. If sync asks for a JDK, point it at JDK 17+ (Android Studio’s embedded JDK is fine).
  5. Select the app run configuration.
  6. Run on a device or emulator.

Command line

./gradlew :app:assembleDebug
./gradlew :app:installDebug

On 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 debugCompileClasspath

Home’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.


Architecture

: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

Runtime flow

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.

Who owns what

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

Scenarios (1–7)

The app ships all seven lessons. Use the numbered chips (17) 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.


How to test each scenario

Work through the chips in order. After each scenario, you should be back on Home unless the steps say otherwise.

Shared checks (do once)

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 debugCompileClasspath

Home 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.

Scenario 1 — Cross-module navigation

Goal: Home opens Profile without a Gradle dependency on :feature:profile.

  1. Launch the app. Header: Scenario 1 of 7 · Cross-module navigation. Screen: HOME MODULE.
  2. Confirm Home shows feature-home and Navigator argument: userId = 123.
  3. Tap Open Profile.
  4. Expect: Profile Activity opens. Header still shows scenario 1. Screen: PROFILE MODULE, feature-profile, User ID: 123.
  5. Logcat: [feature-home] HomeViewModel emit OpenProfile(userId=123) then ProfileNavigatorImpl.createOpenIntent then ProfileActivity.onCreate.
  6. Tap Back to return to Home.

If Profile opened, the contract worked. Home never named ProfileActivity.

Scenario 2 — Avoid circular dependency

Goal: Profile can return to Home without depending on :feature:home.

  1. Tap chip 2. Header: Scenario 2 of 7 · Avoid circular dependency.
  2. Tap Open Profile.
  3. On Profile, tap Open Home (not Back).
  4. 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.
  5. Logcat: [feature-profile] ProfileScreen → HomeNavigator.openHome then [feature-home] HomeNavigatorImpl.start HomeActivity.

If this compiled, there is no home ↔ profile Gradle cycle. Both features depend down on HomeNavigator / ProfileNavigator.

Scenario 3 — Pass userId only

Goal: Navigation passes an id, not a User object. Profile owns the record.

  1. Tap chip 3. Header: Scenario 3 of 7 · Pass userId only.
  2. On Home, confirm the only argument shown is userId = 123.
  3. Tap Open Profile.
  4. Expect: Profile shows User ID: 123, email alex@lab.dev, phone +1 555 0100, display name Alex Rivera.
  5. Those fields were not in the Intent. ProfileDirectory.findById("123") loaded them inside :feature:profile.
  6. Tap Back.

Home has no User type. A shared Parcelable / :core:model is not required for this lab.

Scenario 4 — Return a result

Goal: Profile returns a value to Home through the Activity Result API, not a shared ViewModel.

  1. Tap chip 4. Header: Scenario 4 of 7 · Return a result.
  2. Tap Open Profile.
  3. Change Display name (for example Alex R.).
  4. Tap Save.
  5. Expect: Profile closes. Home shows Last result: Alex R. and status Profile returned “Alex R.”.
  6. Tap Open Profile again. Expect: the name field still shows the saved name (in-memory ProfileDirectory).
  7. Rotate the device on Home. Expect: Last result is still there (StateFlow). The Profile screen does not reopen (events are SharedFlow, not replayed).

Cancel path: Open Profile, tap Back without Save. Home must not overwrite Last result.

Scenario 5 — Feature unavailable

Goal: Home handles a missing destination without probing ProfileActivity::class.

  1. Tap chip 5. Header: Scenario 5 of 7 · Feature unavailable.
  2. Expect: Real / Fake / Unavailable chips appear. Unavailable is selected for you.
  3. Tap Open Profile.
  4. Expect: Profile does not open. Status: Profile is not available. Showing Home fallback.
  5. Switch the implementation chip to Real, tap Open Profile. Expect: Profile opens again (same Home button, different impl).
  6. 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.

Scenario 6 — Swap navigator implementation

Goal: One ProfileNavigator interface, three implementations. Home does not import Fake or Profile.

  1. Tap chip 6. Header: Scenario 6 of 7 · Swap navigator impl.
  2. Expect: Real / Fake / Unavailable chips are visible.

Real

  1. Select Real, tap Open Profile. Expect: Profile Activity opens. Tap Back.

Fake

  1. Select Fake, tap Open Profile.
  2. Expect: Profile does not open. Status: Fake navigator recorded userId=123. Profile was not opened.
  3. Logcat: Home still emits OpenProfile(userId=123). There is no ProfileActivity.onCreate.

Unavailable

  1. Select Unavailable, tap Open Profile.
  2. 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.

Scenario 7 — Test Home without Profile

Goal: :feature:home is testable without :feature:profile on the classpath.

In the app

  1. Tap chip 7. Header: Scenario 7 of 7 · Test Home without Profile.
  2. Implementation chips are visible (same as scenario 6). Select Fake, tap Open Profile.
  3. Expect: recorded userId=123, no Profile Activity — the same isolation the unit tests assert.

On the JVM (required for this scenario)

./gradlew :feature:home:testDebugUnitTest

Expect: 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.


Project map

: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

Design

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 #6200EE tint 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 #6200EE still reads.
  • Dynamic / wallpaper colors are off in LabTheme, so Android 12+ cannot replace the brand purple.
  • XML windowBackground uses a matching gradient so the MainActivity trampoline does not flash white.

Interview takeaway

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

Troubleshooting

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

License

Learning / sample project. Use and modify freely for study.