Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce QuackInterceptorPlugin #782

Merged
merged 7 commits into from
Jul 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions bom/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,12 @@ plugins {
}

dependencies {
val ignoreProjects = listOf(
projects.bom.dependencyProject,
projects.uiSample.dependencyProject,
projects.utilBackendTest.dependencyProject,
projects.catalog.dependencyProject,
projects.materialIconGenerator.dependencyProject,
)

constraints {
rootProject.subprojects.forEach { project ->
if (project !in ignoreProjects) {
if (
project != projects.bom.dependencyProject &&
File(project.projectDir, "version.txt").exists()
) {
api(
ArtifactConfig.of(project).toString()
.also { artifact ->
Expand Down
3 changes: 3 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ include(
":util-backend-kotlinc",
":util-backend-test",
":util-compose-runtime-test",
":util-compose-snapshot-test",
":runtime",
":material",
":material-icon",
Expand All @@ -46,6 +47,8 @@ include(
":ui-plugin",
":ui-plugin:image",
":ui-plugin:image:gif",
":ui-plugin:interceptor",
":ui-plugin:interceptor:textfield",
":ui-sample",
":sugar-material",
":sugar-processor",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ import team.duckie.quackquack.ui.plugin.image.QuackImagePlugin
* 내부적으로 [QuackImagePlugin.CoilImageLoader]를 사용합니다.
*/
@Stable
public val QuackImageGifPlugin: QuackImagePlugin.CoilImageLoader = QuackImagePlugin.CoilImageLoader { _, _, _, _ ->
components {
add(
if (Build.VERSION.SDK_INT >= 28) ImageDecoderDecoder.Factory()
else GifDecoder.Factory(),
)
public val QuackImageGifPlugin: QuackImagePlugin.CoilImageLoader =
QuackImagePlugin.CoilImageLoader { _, _, _, _ ->
components {
add(
if (Build.VERSION.SDK_INT >= 28) ImageDecoderDecoder.Factory()
else GifDecoder.Factory(),
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import team.duckie.quackquack.ui.plugin.rememberQuackPlugins

@Ignore("GIF 녹화 안됨")
@RunWith(AndroidJUnit4::class)
class QuackImageGifPluginTest {
class QuackImageGifPluginSnapshot {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ public sealed interface QuackImagePlugin : QuackPlugin {
* @param contentDescription `QuackImage`의 `contentDescription` 인자로 제공된 값
* @param quackPluginLocal `Modifier.quackPluginLocal`로 제공된 값
*/
// compose-ui 의존성 없어서 Modifier.quackPluginLocal에 링크를 적용하지 않음
@Stable
public fun ImageLoader.Builder.builder(
public fun ImageLoader.Builder.quackBuild(
context: Context,
src: Any?,
contentDescription: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ private fun imageResultOf(drawable: Drawable, request: ImageRequest) =
private class QuackImageCoilBuilderIntercepter(
private val map: MutableMap<String, Any?> = mutableMapOf(),
) : QuackImagePlugin.CoilImageLoader {
override fun ImageLoader.Builder.builder(
override fun ImageLoader.Builder.quackBuild(
context: Context,
src: Any?,
contentDescription: String?,
Expand Down
55 changes: 55 additions & 0 deletions ui-plugin/interceptor/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Designed and developed by Duckie Team 2023.
*
* Licensed under the MIT.
* Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE
*/

@file:Suppress("UnstableApiUsage")

import org.jetbrains.dokka.gradle.DokkaMultiModuleTask

plugins {
quackquack("android-library")
quackquack("android-compose")
quackquack("kotlin-explicit-api")
quackquack("quack-publishing")
alias(libs.plugins.test.roborazzi)
}

tasks.withType<DokkaMultiModuleTask> {
dependsOn(":ui-plugin:dokkaHtmlMultiModule")
}

android {
namespace = "team.duckie.quackquack.ui.plugin.interceptor"

testOptions {
unitTests {
isIncludeAndroidResources = true
isReturnDefaultValues = true
all { test ->
test.systemProperty("robolectric.graphicsMode", "NATIVE")
}
}
}
}

dependencies {
api(projects.uiPlugin.orArtifact())
implementations(
libs.compose.runtime,
libs.compose.ui.core,
projects.material,
projects.utilModifier,
)
testImplementations(
libs.test.robolectric,
libs.test.junit.compose,
libs.test.kotest.assertion.core,
libs.test.kotlin.coroutines, // needed for compose-ui-test
libs.bundles.test.roborazzi,
projects.ui,
projects.utilComposeSnapshotTest,
)
}
8 changes: 8 additions & 0 deletions ui-plugin/interceptor/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Designed and developed by Duckie Team 2023.
~
~ Licensed under the MIT.
~ Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE
-->

<manifest />
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Designed and developed by Duckie Team 2023.
*
* Licensed under the MIT.
* Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE
*/

package team.duckie.quackquack.ui.plugin.interceptor

import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import team.duckie.quackquack.ui.plugin.QuackPlugin
import team.duckie.quackquack.ui.plugin.QuackPluginLocal
import team.duckie.quackquack.ui.plugin.quackPluginLocal

/** 꽥꽥 컴포넌트가 그려지기 전에 특정 조건을 가로채서 변경할 수 있는 플러그인입니다. */
@Immutable
public sealed interface QuackInterceptorPlugin : QuackPlugin {
/** 컴포넌트의 디자인 토큰을 가로채서 변경합니다. */
@Immutable
public fun interface DesignToken : QuackInterceptorPlugin {
/**
* 컴포넌트의 디자인 토큰을 가로채서 변경한 새로운 디자인 토큰을 반환합니다.
*
* @param componentName 플러그인이 적용될 컴포넌트의 이름
* @param componentDesignToken 플러그인이 적용되고 있는 컴포넌트에 적용된 디자인 토큰.
* 이 값은 이 플러그인이 적용되기 전에 미리 갖고 있던 디자인 토큰을 나타냅니다.
* @param componentModifier 플러그인이 적용되고 있는 컴포넌트가 사용하고 있는 [Modifier]
* @param quackPluginLocal [Modifier.quackPluginLocal]로 제공된 값
*/
@Stable
public fun intercept(
componentName: String,
componentDesignToken: Any,
componentModifier: Modifier,
quackPluginLocal: QuackPluginLocal?,
): Any
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Designed and developed by Duckie Team 2023.
*
* Licensed under the MIT.
* Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE
*/

package team.duckie.quackquack.ui.plugin.interceptor

import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import team.duckie.quackquack.ui.plugin.EmptyQuackPlugins
import team.duckie.quackquack.ui.plugin.LocalQuackPlugins
import team.duckie.quackquack.ui.plugin.QuackPluginLocal
import team.duckie.quackquack.ui.plugin.lastByTypeOrNull
import team.duckie.quackquack.util.modifier.getElementByTypeOrNull

/** 현재 실행 중인 call-site의 메서드 이름을 반환합니다. */
@PublishedApi
internal val currentMethodName: String
// https://stackoverflow.com/a/32329165/14299073
inline get() = Thread.currentThread().stackTrace[1].methodName

/** intercept된 스타일이 기존에 제공된 스타일과 다른 타입일 때 throw할 에러 메시지 */
@PublishedApi
@VisibleForTesting
internal const val InterceptedStyleTypeExceptionMessage: String =
"The intercepted style is of a different type than the original style."

/**
* 주어진 [디자인 토큰][style]에 [QuackInterceptorPlugin.DesignToken] 플러그인을 적용한 후,
* 새로운 [디자인 토큰][Style]을 반환합니다.
*
* 만약 [기존 디자인 토큰][style]과 새로운 디자인 토큰의 타입이 다르다면 [IllegalStateException]이
* 발생합니다. 새로운 디자인 토큰의 타입은 [Style]과 일치할 것이라 가정합니다.
*
* @param Style intercept의 결과로 생성될 디자인 토큰의 타입
* @param style 기존에 적용된 디자인 토큰
* @param modifier 현재 컴포넌트에 적용된 [Modifier]
*/
@Composable
public inline fun <reified Style> rememberInterceptedStyleSafely(style: Any, modifier: Modifier): Style {
val localQuackPlugins = LocalQuackPlugins.current

return remember(localQuackPlugins, style, modifier) {
localQuackPlugins.takeIf { it != EmptyQuackPlugins }?.let { plugins ->
val interceptorPlugin = plugins.lastByTypeOrNull<QuackInterceptorPlugin.DesignToken>()
?: return@let null
val quackPluginLocal = modifier.getElementByTypeOrNull<QuackPluginLocal>()

interceptorPlugin
.intercept(
componentName = currentMethodName,
componentDesignToken = style,
componentModifier = modifier,
quackPluginLocal = quackPluginLocal,
)
} ?: style
}.also { interceptedStyle ->
check(interceptedStyle is Style, lazyMessage = ::InterceptedStyleTypeExceptionMessage)
} as Style
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Designed and developed by Duckie Team 2023.
*
* Licensed under the MIT.
* Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE
*/

@file:OptIn(ExperimentalQuackQuackApi::class)

package team.duckie.quackquack.ui.plugin.interceptor

import com.github.takahirom.roborazzi.RoborazziRule.Ignore as NoSnapshot
import androidx.activity.ComponentActivity
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RoborazziRule
import io.kotest.assertions.throwables.shouldThrowWithMessage
import io.kotest.matchers.maps.shouldMatchExactly
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import team.duckie.quackquack.material.QuackColor
import team.duckie.quackquack.material.theme.QuackTheme
import team.duckie.quackquack.ui.QuackTag
import team.duckie.quackquack.ui.QuackTagStyle
import team.duckie.quackquack.ui.TagStyleMarker
import team.duckie.quackquack.ui.plugin.rememberQuackPlugins
import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi
import team.duckie.quackquack.util.compose.snapshot.test.SnapshotName
import team.duckie.quackquack.util.compose.snapshot.test.snapshotPath

@RunWith(AndroidJUnit4::class)
class QuackInterceptorPluginTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()

@get:Rule
val roborazzi = RoborazziRule(
composeRule = compose,
captureRoot = compose.onRoot(),
options = RoborazziRule.Options(
outputFileProvider = { description, _, fileExtension ->
val snapshotName = description.getAnnotation(SnapshotName::class.java)?.name ?: description.methodName
snapshotPath(
domain = "QuackInterceptorPlugin",
snapshotName = snapshotName,
isGif = fileExtension == "gif",
)
},
),
)

@SnapshotName("QuackTagRadiusStyleIntercepted")
@Test
fun `style intercept works fine`() {
val map = mutableMapOf<String, Any?>()

var interceptedStyle: QuackTagStyle<TagStyleMarker>? = null
val interceptedRadius = Int.MAX_VALUE.dp

compose.setContent {
QuackTheme(
plugins = rememberQuackPlugins {
+QuackInterceptorPlugin.DesignToken { componentName, componentDesignToken, componentModifier, _ ->
map["componentName"] = componentName
map["componentDesignToken"] = componentDesignToken
map["componentModifier"] = componentModifier

(if (componentName == "QuackTag") {
@Suppress("UNCHECKED_CAST")
componentDesignToken as QuackTagStyle<TagStyleMarker>
object : QuackTagStyle<TagStyleMarker> by componentDesignToken {
override val radius = interceptedRadius
override val colors =
componentDesignToken.colors.copy(
backgroundColor = QuackColor.Gray3,
contentColor = QuackColor.Black,
)
}
} else {
componentDesignToken
}).also { intercepttedResult ->
@Suppress("UNCHECKED_CAST")
interceptedStyle = intercepttedResult as QuackTagStyle<TagStyleMarker>
}
}
},
) {
QuackTag(
text = "Intercepted Tag",
style = QuackTagStyle.Filled,
onClick = {},
)
}
}

map.shouldMatchExactly(
"componentName" to { it shouldBe "QuackTag" },
"componentDesignToken" to { it.toString() shouldBe QuackTagStyle.Filled.toString() },
"componentModifier" to { it shouldBe Modifier },
)
interceptedStyle.shouldNotBeNull().radius shouldBe interceptedRadius
}

@NoSnapshot
@Test
fun InterceptedStyleTypeExceptionMessage() {
shouldThrowWithMessage<IllegalStateException>(InterceptedStyleTypeExceptionMessage) {
compose.setContent {
QuackTheme(
plugins = rememberQuackPlugins {
+QuackInterceptorPlugin.DesignToken { _, _, _, _ -> Unit }
},
) {
QuackTag(
text = "",
style = QuackTagStyle.Filled,
onClick = {},
)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#
# Designed and developed by Duckie Team 2023.
#
# Licensed under the MIT.
# Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE
#

sdk=33
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading