Skip to content
33 changes: 33 additions & 0 deletions Crane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,37 @@ implemented using a different Activity will be displayed. In there, you can see
embedded in Compose and Compose buttons updating the Android View. Notice how you can also
interact with the `MapView` seamlessly.

## Hilt

Crane uses [Hilt][hilt] to manage its dependencies. The Hilt's ViewModel extension (with the
`@ViewModelInject` annotation) works perfectly with Compose's ViewModel integration (`viewModel()`
composable function) as you can see in the following snippet of code. `viewModel()` will
automatically use the factory that Hilt creates for the ViewModel:

```
class MainViewModel @ViewModelInject constructor(
private val destinationsRepository: DestinationsRepository,
@DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
datesRepository: DatesRepository
) : ViewModel() { ... }

@Composable
fun CraneHomeContent(...) {
val viewModel: MainViewModel = viewModel()
...
}
```

Disclaimer: Passing dependencies that are not available at compile time to a ViewModel
(as it's the case of the [DetailsViewModel](detailsViewModel) that takes as a dependency the name of
a `City` in order to load all the information for the screen) doesn't work as you might expect
using `viewModel()`. This is because the way the Compose's ViewModel integration works at the moment
doesn't allow you to scope a ViewModel to a given composable, it's always scoped to an Activity or
Fragment. Because of this, calling `viewModel()` with different factories in the same
Activity/Fragment don't have any effect as the first factory will be always used. In Crane, we don't
have this problem because it's a multi-activity application and every time, `DetailsScreen` is
called from a different instance of `DetailsActivity`.

## Google Maps SDK

To get the MapView working, you need to get an API key as
Expand Down Expand Up @@ -79,6 +110,8 @@ limitations under the License.
[details]: app/src/main/java/androidx/compose/samples/crane/details/DetailsActivity.kt
[data]: app/src/main/java/androidx/compose/samples/crane/data/CraneData.kt
[mainViewModel]: app/src/main/java/androidx/compose/samples/crane/home/MainViewModel.kt
[detailsViewModel]: app/src/main/java/androidx/compose/samples/crane/details/DetailsViewModel.kt
[homeTest]: app/src/androidTest/java/androidx/compose/samples/crane/home/HomeTest.kt
[detailsTest]: app/src/androidTest/java/androidx/compose/samples/crane/details/DetailsActivityTest.kt
[coil-accompanist]: https://github.com/chrisbanes/accompanist
[hilt]: https://d.android.com/hilt
35 changes: 33 additions & 2 deletions Crane/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import com.example.crane.buildsrc.Libs
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}

// Reads the Google maps key that is used in the AndroidManifest
Expand All @@ -36,7 +38,13 @@ android {
versionCode 1
versionName "1.0"
vectorDrawables.useSupportLibrary = true
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner "androidx.compose.samples.crane.CustomTestRunner"

javaCompileOptions {
annotationProcessorOptions {
arguments["dagger.hilt.disableModulesHaveInstallInCheck"] = "true"
}
}

manifestPlaceholders = [ googleMapsKey : properties.getProperty("google.maps.key", "") ]
}
Expand Down Expand Up @@ -81,14 +89,22 @@ android {
resValues false
shaders false
}

composeOptions {
kotlinCompilerVersion Libs.Kotlin.version
kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version
}

packagingOptions {
exclude "META-INF/licenses/**"
exclude "META-INF/AL2.0"
exclude "META-INF/LGPL2.1"
}
}

dependencies {
implementation Libs.Kotlin.stdlib
implementation Libs.Kotlin.Coroutines.android
implementation Libs.googleMaps

implementation Libs.AndroidX.Compose.runtime
Expand All @@ -98,12 +114,27 @@ dependencies {
implementation Libs.AndroidX.Compose.layout
implementation Libs.AndroidX.Compose.animation
implementation Libs.AndroidX.UI.tooling

implementation Libs.Accompanist.coil

implementation Libs.AndroidX.Lifecycle.viewModelKtx
implementation Libs.Hilt.android
implementation Libs.Hilt.AndroidX.viewModel
compileOnly Libs.AssistedInjection.dagger
kapt Libs.Hilt.compiler
kapt Libs.Hilt.AndroidX.compiler
kapt Libs.AssistedInjection.processor

androidTestImplementation Libs.JUnit.junit
androidTestImplementation Libs.AndroidX.Test.runner
androidTestImplementation Libs.AndroidX.Test.espressoCore
androidTestImplementation Libs.AndroidX.Test.rules
androidTestImplementation Libs.AndroidX.Test.Ext.junit
androidTestImplementation Libs.Kotlin.Coroutines.test
androidTestImplementation Libs.AndroidX.Compose.uiTest
androidTestImplementation Libs.Hilt.android
androidTestImplementation Libs.Hilt.AndroidX.viewModel
androidTestImplementation Libs.Hilt.testing
kaptAndroidTest Libs.Hilt.compiler
kaptAndroidTest Libs.Hilt.AndroidX.compiler
kaptAndroidTest Libs.AssistedInjection.processor
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.samples.crane

import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication

class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,66 +17,61 @@
package androidx.compose.samples.crane.calendar

import androidx.compose.material.Surface
import androidx.compose.samples.crane.base.ServiceLocator
import androidx.compose.samples.crane.calendar.model.CalendarDay
import androidx.compose.samples.crane.calendar.model.CalendarMonth
import androidx.compose.samples.crane.calendar.model.DaySelected
import androidx.compose.samples.crane.calendar.model.DaySelectedStatus
import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.FirstDay
import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.FirstLastDay
import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.LastDay
import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.NoSelected
import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.Selected
import androidx.compose.samples.crane.data.DatesRepository
import androidx.compose.samples.crane.di.DispatchersModule
import androidx.compose.samples.crane.ui.CraneTheme
import androidx.ui.test.ComposeTestRule
import androidx.ui.test.SemanticsMatcher
import androidx.ui.test.assertLabelEquals
import androidx.ui.test.createComposeRule
import androidx.ui.test.createAndroidComposeRule
import androidx.ui.test.onNodeWithLabel
import androidx.ui.test.performClick
import androidx.ui.test.performScrollTo
import org.junit.After
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject

@UninstallModules(DispatchersModule::class)
@HiltAndroidTest
class CalendarTest {

@get:Rule
val composeTestRule = createComposeRule(disableTransitions = true)

var dateSelected = ""
private val onDayClicked: (CalendarDay, CalendarMonth) -> Unit = { day, month ->
dateSelected = "${month.name} ${day.value}"
ServiceLocator.datesSelected.daySelected(
DaySelected(
day = day.value.toInt(),
month = month
)
)
}
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)

@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<CalendarActivity>()

@Inject
lateinit var datesRepository: DatesRepository

@Before
fun setUp() {
hiltRule.inject()

composeTestRule.setContent {
CraneTheme {
Surface {
Calendar(onDayClicked)
CalendarScreen(onBackPressed = {})
}
}
}
}

@After
fun tearDown() {
ServiceLocator.datesSelected.clearDates()
}

@Test
fun scrollsToTheBottom() {
composeTestRule.onNodeWithLabel("January 1").assertExists()
composeTestRule.onNodeWithLabel("December 31").performScrollTo().performClick()
assert(dateSelected == "December 31")
assert(datesRepository.datesSelected.toString() == "Dec 31")
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
package androidx.compose.samples.crane.details

import androidx.compose.samples.crane.R
import androidx.compose.samples.crane.data.DestinationsRepository
import androidx.compose.samples.crane.data.ExploreModel
import androidx.compose.samples.crane.data.MADRID
import androidx.compose.samples.crane.di.DispatchersModule
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
Expand All @@ -31,16 +33,30 @@ import androidx.ui.test.onNodeWithText
import com.google.android.libraries.maps.MapView
import com.google.android.libraries.maps.model.CameraPosition
import com.google.android.libraries.maps.model.LatLng
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.CountDownLatch
import javax.inject.Inject

@UninstallModules(DispatchersModule::class)
@HiltAndroidTest
class DetailsActivityTest {

private val expectedDescription = "description"
private val testExploreModel = ExploreModel(MADRID, expectedDescription, "imageUrl")
@Inject
lateinit var destinationsRepository: DestinationsRepository
lateinit var cityDetails: ExploreModel

@get:Rule
private val city = MADRID
private val testExploreModel = ExploreModel(city, "description", "imageUrl")

@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)

@get:Rule(order = 1)
val composeTestRule = AndroidComposeTestRule(
ActivityScenarioRule<DetailsActivity>(
createDetailsActivityIntent(
Expand All @@ -50,10 +66,16 @@ class DetailsActivityTest {
)
)

@Before
fun setUp() {
hiltRule.inject()
cityDetails = destinationsRepository.getDestination(MADRID.name)!!
}

@Test
fun mapView_cameraPositioned() {
composeTestRule.onNodeWithText(MADRID.nameToDisplay).assertIsDisplayed()
composeTestRule.onNodeWithText(expectedDescription).assertIsDisplayed()
composeTestRule.onNodeWithText(cityDetails.city.nameToDisplay).assertIsDisplayed()
composeTestRule.onNodeWithText(cityDetails.description).assertIsDisplayed()
onView(withId(R.id.map)).check(matches(isDisplayed()))

var cameraPosition: CameraPosition? = null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

@file:Suppress("DEPRECATION")

package androidx.compose.samples.crane.di

import android.os.AsyncTask
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ApplicationComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher

@OptIn(ExperimentalCoroutinesApi::class)
@Module
@InstallIn(ApplicationComponent::class)
class TestDispatchersModule {

@Provides
@DefaultDispatcher
fun provideDefaultDispatcher(): CoroutineDispatcher =
AsyncTask.THREAD_POOL_EXECUTOR.asCoroutineDispatcher()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,27 @@
package androidx.compose.samples.crane.home

import androidx.compose.material.Surface
import androidx.compose.samples.crane.di.DispatchersModule
import androidx.compose.samples.crane.ui.CraneTheme
import androidx.ui.test.createComposeRule
import androidx.ui.test.createAndroidComposeRule
import androidx.ui.test.onNodeWithText
import androidx.ui.test.performClick
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import org.junit.Before
import org.junit.Rule
import org.junit.Test

@UninstallModules(DispatchersModule::class)
@HiltAndroidTest
class HomeTest {

@get:Rule
val composeTestRule = createComposeRule(disableTransitions = true)
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)

@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()

@Before
fun setUp() {
Expand Down
1 change: 1 addition & 0 deletions Crane/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".CraneApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
* limitations under the License.
*/

package androidx.compose.samples.crane.base
package androidx.compose.samples.crane

import androidx.compose.samples.crane.calendar.model.DatesSelectedState
import android.app.Application
import dagger.hilt.android.HiltAndroidApp

object ServiceLocator {
val datesSelected = DatesSelectedState()
}
@HiltAndroidApp
class CraneApplication : Application()
Loading