Skip to content

Commit

Permalink
feat(navigation): add support for scoped service interfaces
Browse files Browse the repository at this point in the history
  • Loading branch information
matejdro committed Oct 25, 2024
1 parent fa47a71 commit 8ef2638
Show file tree
Hide file tree
Showing 4 changed files with 179 additions and 27 deletions.
24 changes: 24 additions & 0 deletions navigation/README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,30 @@ data class SecondScreenKey(...) : ScreenKey() {
All screens with the same scope tag will share the same service instances. For this to work properly,
you also must inject all shared services into all screens.

### Service interfaces

You can use `ContributesBinding` annotation to inject a service interface into a screen, the same way it works with normal
injection:

```kotlin0
@InjectScopedService
@contributesBinding(BackstackScope::class)
class MyServiceImpl @Inject constructor() : MyService {
...
}
interface MyService: ScopedService {
...
}
@InjectNavigationScreen
class MyScreen(
private val myService: MyService
) : Screen<MyScreenKey> {
...
}
```

## Custom animations

To customize animations, you can override `forwardAnimation` and `backwardAnimation` methods of the screen's key.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.processing.SymbolProcessorProvider
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSAnnotation
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSDeclaration
import com.google.devtools.ksp.symbol.KSType
import com.google.devtools.ksp.validate
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.LambdaTypeName
Expand All @@ -35,11 +39,14 @@ import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.WildcardTypeName
import com.squareup.kotlinpoet.asClassName
import com.squareup.kotlinpoet.ksp.toClassName
import com.squareup.kotlinpoet.ksp.toTypeName
import com.squareup.kotlinpoet.ksp.writeTo
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.IntoMap
import me.tatarka.inject.annotations.Provides
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding
import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo
import kotlin.reflect.KClass

@Suppress("unused")
class ScopedServiceInjectionGenerator(private val codeGenerator: CodeGenerator, private val logger: KSPLogger) : SymbolProcessor {
Expand Down Expand Up @@ -71,38 +78,23 @@ class ScopedServiceInjectionGenerator(private val codeGenerator: CodeGenerator,
.addMember("%T::class", BACKSTACK_SCOPE_ANNOTATION)
.build()

val serviceMapKeyType = Class::class.asClassName().parameterizedBy(
WildcardTypeName.producerOf(
SCOPED_SERVICE_BASE_CLASS
)
)
val returnType =
Pair::class.asClassName().parameterizedBy(serviceMapKeyType, LambdaTypeName.get(returnType = SCOPED_SERVICE_BASE_CLASS))

val provideServiceFunction = FunSpec.builder("provide${service.simpleName.asString()}Constructor")
.returns(returnType)
.addParameter("serviceFactory", LambdaTypeName.get(returnType = serviceClassName))
.addAnnotation(Provides::class)
.addAnnotation(IntoMap::class)
.addStatement(
"return %T(%T::class.java, serviceFactory)",
Pair::class.asClassName(),
serviceClassName
)
.build()
val provideServiceFunction = createProvideServiceFunction(serviceClassName, serviceClassName)
val provideFromSimpleStackFunction = createProvideFromSimpleStackFunction(serviceClassName, serviceClassName)

val provideFromSimpleStackFunction = FunSpec.builder("provide${service.simpleName.asString()}FromSimpleStack")
.returns(serviceClassName)
.addParameter("backstack", SIMPLE_STACK_BACKSTACK_CLASS)
.addAnnotation(Provides::class)
.addAnnotation(FROM_BACKSTACK_QUALIFIER_ANNOTATION)
.addCode("return backstack.lookupService(%T::class.java.name)", serviceClassName)
.build()
val contributesBindingAnnotation =
service.annotations.firstOrNull { it.annotationType.toTypeName() == ContributesBinding::class.asClassName() }

val fromBackstackProviderComponent = TypeSpec.interfaceBuilder(serviceClassName.simpleName + "BackstackComponent")
.addAnnotation(Component::class)
.addAnnotation(backstackContributesToAnnotation)
.addFunction(provideFromSimpleStackFunction)
.apply {
if (contributesBindingAnnotation != null) {
val boundType = boundType(service, contributesBindingAnnotation).toClassName()
addFunction(createProvideServiceFunction(serviceClassName, boundType))
addFunction(createProvideFromSimpleStackFunction(serviceClassName, boundType))
}
}
.build()

val globalProviderComponent = TypeSpec.interfaceBuilder(serviceClassName.simpleName + "Component")
Expand All @@ -120,6 +112,103 @@ class ScopedServiceInjectionGenerator(private val codeGenerator: CodeGenerator,
.build()
.writeTo(codeGenerator, false, dependencies)
}

private fun createProvideServiceFunction(
serviceClassName: ClassName,
targetClassName: ClassName
): FunSpec {
val serviceMapKeyType = Class::class.asClassName().parameterizedBy(
WildcardTypeName.producerOf(
SCOPED_SERVICE_BASE_CLASS
)
)

val returnType =
Pair::class.asClassName().parameterizedBy(serviceMapKeyType, LambdaTypeName.get(returnType = SCOPED_SERVICE_BASE_CLASS))

return FunSpec.builder("provide${targetClassName.simpleName}Constructor")
.returns(returnType)
.addParameter("serviceFactory", LambdaTypeName.get(returnType = serviceClassName))
.addAnnotation(Provides::class)
.addAnnotation(IntoMap::class)
.addStatement(
"return %T(%T::class.java, serviceFactory)",
Pair::class.asClassName(),
targetClassName
)
.build()
}

private fun createProvideFromSimpleStackFunction(
serviceClassName: ClassName,
targetClassName: ClassName
) = FunSpec.builder("provide${targetClassName.simpleName}FromSimpleStack")
.returns(targetClassName)
.addParameter("backstack", SIMPLE_STACK_BACKSTACK_CLASS)
.addAnnotation(Provides::class)
.addAnnotation(FROM_BACKSTACK_QUALIFIER_ANNOTATION)
.addCode("return backstack.lookupService(%T::class.java.name)", serviceClassName)
.build()

/**
* From https://github.com/amzn/kotlin-inject-anvil/blob/7f050d078a3f1100cfbeff469f19aef76915efe6/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesBindingProcessor.kt
*/
private fun boundTypeFromAnnotation(annotation: KSAnnotation): KSType? {
return annotation.arguments.firstOrNull { it.name?.asString() == "boundType" }
?.let { it.value as? KSType }
?.takeIf {
it.declaration.requireQualifiedName() != Unit::class.requireQualifiedName()
}
}

/**
* From https://github.com/amzn/kotlin-inject-anvil/blob/7f050d078a3f1100cfbeff469f19aef76915efe6/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesBindingProcessor.kt
*/
@Suppress("ReturnCount")
private fun boundType(
clazz: KSClassDeclaration,
annotation: KSAnnotation,
): KSType {
boundTypeFromAnnotation(annotation)?.let { return it }

// The bound type is not defined in the annotation, let's inspect the super types.
val superTypes = clazz.superTypes
.map { it.resolve() }
.filter { it.declaration.requireQualifiedName() != Any::class.requireQualifiedName() }
.toList()

when (superTypes.size) {
0 -> {
val message = "The bound type could not be determined for " +
"${clazz.simpleName.asString()}. There are no super types."
logger.error(message, clazz)
throw IllegalArgumentException(message)
}

1 -> {
return superTypes.single()
}

else -> {
val message = "The bound type could not be determined for " +
"${clazz.simpleName.asString()}. There are multiple super types: " +
superTypes.joinToString { it.declaration.simpleName.asString() } +
"."
logger.error(message, clazz)
throw IllegalArgumentException(message)
}
}
}

internal fun KSDeclaration.requireQualifiedName(): String =
requireNotNull(qualifiedName?.asString()) {
"Qualified name was null for $this"
}

internal fun KClass<*>.requireQualifiedName(): String =
requireNotNull(qualifiedName) {
"Qualified name was null for $this"
}
}

@AutoService(SymbolProcessorProvider::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ class ScreenInjectionGenerator(private val codeGenerator: CodeGenerator, private
.addAnnotation(suppressAnnotation)
.addFunction(bindScreenFactoryToFactoryMultibindsFunction)
.addFunction(screenFactoryProvider)
.also { screenRegistrationFunction?.let { it1 -> it.addFunction(it1) } }
.addFunction(screenRegistrationFunction)
.also {
if (screenFactoryToParentFactoryBindingFunction != null) {
it.addFunction(screenFactoryToParentFactoryBindingFunction)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import kotlinx.parcelize.Parcelize
import me.tatarka.inject.annotations.Inject
import org.junit.Rule
import org.junit.Test
import si.inova.kotlinova.navigation.di.BackstackScope
import si.inova.kotlinova.navigation.screenkeys.NoArgsScreenKey
import si.inova.kotlinova.navigation.screenkeys.ScreenKey
import si.inova.kotlinova.navigation.screens.InjectNavigationScreen
Expand All @@ -44,6 +45,7 @@ import si.inova.kotlinova.navigation.services.ScopedService
import si.inova.kotlinova.navigation.services.SingleScreenViewModel
import si.inova.kotlinova.navigation.testutils.insertTestNavigation
import si.inova.kotlinova.navigation.testutils.removeBackstackFromMemory
import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding

private var lastReceivedKey: Any? = null

Expand Down Expand Up @@ -117,6 +119,15 @@ class ServicesTest {
lastReceivedKey shouldBe key
}

@Test
internal fun allowScreensInjectInterfacesOfScopedServices() {
rule.insertTestNavigation(ScreenWithBasicServiceWithInterfaceKey)

rule.onNodeWithText("Number: 0").assertIsDisplayed()
rule.onNodeWithText("Increase").performClick()
rule.onNodeWithText("Number: 1").assertIsDisplayed()
}

@Parcelize
object ScreenWithBasicServiceKey : NoArgsScreenKey()

Expand Down Expand Up @@ -182,4 +193,32 @@ class ServicesTest {
lastReceivedKey = key
}
}

@Parcelize
object ScreenWithBasicServiceWithInterfaceKey : NoArgsScreenKey()

@InjectNavigationScreen
class ScreenWithBasicServiceWithInterface(
private val service: BasicServiceWithInterface
) : Screen<ScreenWithBasicServiceWithInterfaceKey>() {
@Composable
override fun Content(key: ScreenWithBasicServiceWithInterfaceKey) {
Column {
Text("Number: ${service.data.collectAsStateWithLifecycle().value}")
Button(onClick = { service.data.update { it + 1 } }) {
Text("Increase")
}
}
}
}

@InjectScopedService
@ContributesBinding(BackstackScope::class)
class BasicServiceWithInterfaceImpl @Inject constructor() : BasicServiceWithInterface {
override val data = MutableStateFlow(0)
}

interface BasicServiceWithInterface : ScopedService {
val data: MutableStateFlow<Int>
}
}

0 comments on commit 8ef2638

Please sign in to comment.