diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 3cccc9f..e222763 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -8,6 +8,12 @@ jobs:
publish-release:
runs-on: ubuntu-latest
steps:
+ - name: Setup Java
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
- name: Checkout code
uses: actions/checkout@v2
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9ae7e2b..9212a22 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -6,6 +6,12 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
+ - name: Setup Java
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
- name: Checkout code
uses: actions/checkout@v2
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..21a92d9
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+
+/gradle.xml
+/jarRepositories.xml
+/kotlinc.xml
+/misc.xml
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b589d56
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 2624adb..08791bb 100644
--- a/build.gradle
+++ b/build.gradle
@@ -9,7 +9,7 @@ buildscript {
}
dependencies {
- classpath 'com.android.tools.build:gradle:7.3.1'
+ classpath 'com.android.tools.build:gradle:8.1.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
}
}
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 7454180..ccebba7 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 0729743..0c85a1f 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
-#Mon Feb 13 14:19:36 EST 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip
+networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index 1b6c787..79a61d4 100755
--- a/gradlew
+++ b/gradlew
@@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -80,10 +80,10 @@ do
esac
done
-APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
-
-APP_NAME="Gradle"
+# This is normally unused
+# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
@@ -143,12 +143,16 @@ fi
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -205,6 +209,12 @@ set -- \
org.gradle.wrapper.GradleWrapperMain \
"$@"
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
diff --git a/gradlew.bat b/gradlew.bat
index ac1b06f..6689b85 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -14,7 +14,7 @@
@rem limitations under the License.
@rem
-@if "%DEBUG%" == "" @echo off
+@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@@ -25,7 +25,8 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto execute
+if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end
@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
+if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
diff --git a/strada/build.gradle b/strada/build.gradle
index bf00ff3..7a00894 100644
--- a/strada/build.gradle
+++ b/strada/build.gradle
@@ -41,12 +41,12 @@ android {
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = "17"
}
buildTypes {
@@ -64,19 +64,29 @@ android {
testOptions {
unitTests.includeAndroidResources = true
unitTests.returnDefaultValues = true
+
+ kotlinOptions {
+ freeCompilerArgs += [
+ '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi'
+ ]
+ }
}
+
+ namespace 'dev.hotwire.strada'
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.core:core-ktx:1.9.0'
- implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1'
+ implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0'
implementation 'androidx.lifecycle:lifecycle-common:2.6.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'androidx.test:core:1.5.0'
+ testImplementation 'org.assertj:assertj-core:3.24.2'
testImplementation 'androidx.lifecycle:lifecycle-runtime-testing:2.6.1'
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4"
testImplementation 'org.robolectric:robolectric:4.9.2'
testImplementation 'org.mockito:mockito-core:5.2.0'
testImplementation 'com.nhaarman:mockito-kotlin:1.6.0'
diff --git a/strada/src/main/assets/js/strada.js b/strada/src/main/assets/js/strada.js
index 77c4e86..ec7ae12 100644
--- a/strada/src/main/assets/js/strada.js
+++ b/strada/src/main/assets/js/strada.js
@@ -51,8 +51,8 @@
return this.supportedComponents.includes(component)
}
- // Send message to web
- send(message) {
+ // Reply to web with message
+ replyWith(message) {
if (this.isStradaAvailable) {
this.webBridge.receive(JSON.parse(message))
}
diff --git a/strada/src/main/kotlin/dev/hotwire/strada/Bridge.kt b/strada/src/main/kotlin/dev/hotwire/strada/Bridge.kt
index 49f81c1..9936029 100644
--- a/strada/src/main/kotlin/dev/hotwire/strada/Bridge.kt
+++ b/strada/src/main/kotlin/dev/hotwire/strada/Bridge.kt
@@ -46,10 +46,10 @@ class Bridge internal constructor(webView: WebView) {
evaluate(javascript)
}
- internal fun send(message: Message) {
- logMessage("bridgeWillSendMessage", message)
+ internal fun replyWith(message: Message) {
+ logEvent("bridgeWillReplyWithMessage", message.toString())
val internalMessage = InternalMessage.fromMessage(message)
- val javascript = generateJavaScript("send", internalMessage.toJson().toJsonElement())
+ val javascript = generateJavaScript("replyWith", internalMessage.toJson().toJsonElement())
evaluate(javascript)
}
diff --git a/strada/src/main/kotlin/dev/hotwire/strada/BridgeComponent.kt b/strada/src/main/kotlin/dev/hotwire/strada/BridgeComponent.kt
index 17b08e8..ece92ee 100644
--- a/strada/src/main/kotlin/dev/hotwire/strada/BridgeComponent.kt
+++ b/strada/src/main/kotlin/dev/hotwire/strada/BridgeComponent.kt
@@ -4,14 +4,120 @@ abstract class BridgeComponent(
val name: String,
private val delegate: BridgeDelegate
) {
- abstract fun handle(message: Message)
+ private val receivedMessages = hashMapOf()
- fun send(message: Message) {
- delegate.bridge?.send(message) ?: run {
- logEvent("bridgeMessageFailedToSend", "bridge is not available")
- }
+ /**
+ * Returns the last received message for a given `event`, if available.
+ */
+ protected fun receivedMessageFor(event: String): Message? {
+ return receivedMessages[event]
+ }
+
+ /**
+ * Called when a message is received from the web bridge. Handle the
+ * message for its `event` type for the custom component's behavior.
+ */
+ abstract fun onReceive(message: Message)
+
+ /**
+ * This passes a received message to onReceive(message), caching it
+ * for use with replyTo(event) and receivedMessageFor(event).
+ *
+ * NOTE: This should not be called directly from within a component,
+ * but is available to use for testing.
+ */
+ fun didReceive(message: Message) {
+ receivedMessages[message.event] = message
+ onReceive(message)
+ }
+
+ /**
+ * This passes the start lifecycle event to onStart().
+ *
+ * NOTE: This should not be called directly from within a component,
+ * but is available to use for testing.
+ */
+ fun didStart() {
+ onStart()
}
+ /**
+ * This passes the stop lifecycle event to onStop().
+ *
+ * NOTE: This should not be called directly from within a component,
+ * but is available to use for testing.
+ */
+ fun didStop() {
+ onStop()
+ }
+
+ /**
+ * Called when the component's destination starts (and is active)
+ * based on its lifecycle events. You can use this as an opportunity
+ * to update the component's state/view.
+ */
open fun onStart() {}
+
+ /**
+ * Called when the component's destination stops (and is inactive)
+ * based on its lifecycle events. You can use this as an opportunity
+ * to update the component's state/view.
+ */
open fun onStop() {}
+
+ /**
+ * Reply to the web with a received message, optionally replacing its
+ * `event` or `jsonData`.
+ */
+ fun replyWith(message: Message): Boolean {
+ return reply(message)
+ }
+
+ /**
+ * Reply to the web with the last received message for a given `event`
+ * with its original `jsonData`.
+ *
+ * NOTE: If a message has not been received for the given `event`, the
+ * reply will be ignored.
+ */
+ fun replyTo(event: String): Boolean {
+ val message = receivedMessageFor(event) ?: run {
+ logWarning("bridgeMessageFailedToReply", "message for event '$event' was not received")
+ return false
+ }
+
+ return reply(message)
+ }
+
+ /**
+ * Reply to the web with the last received message for a given `event`,
+ * replacing its `jsonData`.
+ *
+ * NOTE: If a message has not been received for the given `event`, the
+ * reply will be ignored.
+ */
+ fun replyTo(event: String, jsonData: String): Boolean {
+ val message = receivedMessageFor(event) ?: run {
+ logWarning("bridgeMessageFailedToReply", "message for event '$event' was not received")
+ return false
+ }
+
+ return reply(message.replacing(jsonData = jsonData))
+ }
+
+ /**
+ * Reply to the web with the last received message for a given `event`,
+ * replacing its `jsonData` with encoded json from the provided `data`
+ * object.
+ *
+ * NOTE: If a message has not been received for the given `event`, the
+ * reply will be ignored.
+ */
+ inline fun replyTo(event: String, data: T): Boolean {
+ return replyTo(event, jsonData = StradaJsonConverter.toJson(data))
+ }
+
+ private fun reply(message: Message): Boolean {
+ return delegate.replyWith(message)
+ }
}
diff --git a/strada/src/main/kotlin/dev/hotwire/strada/BridgeDelegate.kt b/strada/src/main/kotlin/dev/hotwire/strada/BridgeDelegate.kt
index b275b49..d25c07e 100644
--- a/strada/src/main/kotlin/dev/hotwire/strada/BridgeDelegate.kt
+++ b/strada/src/main/kotlin/dev/hotwire/strada/BridgeDelegate.kt
@@ -6,22 +6,16 @@ import androidx.lifecycle.LifecycleOwner
@Suppress("unused")
class BridgeDelegate(
+ val location: String,
val destination: D,
private val componentFactories: List>>
-) {
+) : DefaultLifecycleObserver {
internal var bridge: Bridge? = null
- private var destinationIsActive = true
- private val components = hashMapOf>()
+ private var destinationIsActive: Boolean = false
+ private val initializedComponents = hashMapOf>()
val activeComponents: List>
- get() = when (destinationIsActive) {
- true -> components.map { it.value }
- else -> emptyList()
- }
-
- init {
- observeLifeCycle()
- }
+ get() = initializedComponents.map { it.value }.takeIf { destinationIsActive }.orEmpty()
fun onColdBootPageCompleted() {
bridge?.load()
@@ -41,7 +35,7 @@ class BridgeDelegate(
bridge?.load()
}
} else {
- logEvent("bridgeNotInitializedForWebView", destination.bridgeDestinationLocation())
+ logWarning("bridgeNotInitializedForWebView", location)
}
}
@@ -50,17 +44,26 @@ class BridgeDelegate(
bridge = null
}
+ fun replyWith(message: Message): Boolean {
+ bridge?.replyWith(message) ?: run {
+ logWarning("bridgeMessageFailedToReply", "bridge is not available")
+ return false
+ }
+
+ return true
+ }
+
internal fun bridgeDidInitialize() {
bridge?.register(componentFactories.map { it.name })
}
internal fun bridgeDidReceiveMessage(message: Message): Boolean {
- return if (destination.bridgeDestinationLocation() == message.metadata?.url) {
- logMessage("bridgeDidReceiveMessage", message)
- getOrCreateComponent(message.component)?.handle(message)
+ return if (destinationIsActive && location == message.metadata?.url) {
+ logEvent("bridgeDidReceiveMessage", message.toString())
+ getOrCreateComponent(message.component)?.didReceive(message)
true
} else {
- logMessage("bridgeDidIgnoreMessage", message)
+ logWarning("bridgeDidIgnoreMessage", message.toString())
false
}
}
@@ -71,22 +74,21 @@ class BridgeDelegate(
// Lifecycle events
- private fun observeLifeCycle() {
- destination.bridgeDestinationLifecycleOwner().lifecycle.addObserver(object :
- DefaultLifecycleObserver {
- override fun onStart(owner: LifecycleOwner) { onStart() }
- override fun onStop(owner: LifecycleOwner) { onStop() }
- })
+ override fun onStart(owner: LifecycleOwner) {
+ logEvent("bridgeDestinationDidStart", location)
+ destinationIsActive = true
+ activeComponents.forEach { it.didStart() }
}
- private fun onStart() {
- destinationIsActive = true
- activeComponents.forEach { it.onStart() }
+ override fun onStop(owner: LifecycleOwner) {
+ activeComponents.forEach { it.didStop() }
+ destinationIsActive = false
+ logEvent("bridgeDestinationDidStop", location)
}
- private fun onStop() {
+ override fun onDestroy(owner: LifecycleOwner) {
destinationIsActive = false
- activeComponents.forEach { it.onStop() }
+ logEvent("bridgeDestinationDidDestroy", location)
}
// Retrieve component(s) by type
@@ -101,6 +103,6 @@ class BridgeDelegate(
private fun getOrCreateComponent(name: String): BridgeComponent? {
val factory = componentFactories.firstOrNull { it.name == name } ?: return null
- return components.getOrPut(name) { factory.create(this) }
+ return initializedComponents.getOrPut(name) { factory.create(this) }
}
}
diff --git a/strada/src/main/kotlin/dev/hotwire/strada/BridgeDestination.kt b/strada/src/main/kotlin/dev/hotwire/strada/BridgeDestination.kt
index 3ea6f53..ab297e7 100644
--- a/strada/src/main/kotlin/dev/hotwire/strada/BridgeDestination.kt
+++ b/strada/src/main/kotlin/dev/hotwire/strada/BridgeDestination.kt
@@ -1,9 +1,5 @@
package dev.hotwire.strada
-import androidx.lifecycle.LifecycleOwner
-
interface BridgeDestination {
- fun bridgeDestinationLocation(): String
- fun bridgeDestinationLifecycleOwner(): LifecycleOwner
fun bridgeWebViewIsReady(): Boolean
}
diff --git a/strada/src/main/kotlin/dev/hotwire/strada/JsonExtensions.kt b/strada/src/main/kotlin/dev/hotwire/strada/JsonExtensions.kt
index 930a07f..e70c50a 100644
--- a/strada/src/main/kotlin/dev/hotwire/strada/JsonExtensions.kt
+++ b/strada/src/main/kotlin/dev/hotwire/strada/JsonExtensions.kt
@@ -16,14 +16,14 @@ internal inline fun T.toJson() = json.encodeToString(this)
internal inline fun JsonElement.decode(): T? = try {
json.decodeFromJsonElement(this)
} catch (e: Exception) {
- StradaLog.e("jsonElementDecodeException: ${e.stackTraceToString()}")
+ logError("jsonElementDecodeException", e)
null
}
internal inline fun String.decode(): T? = try {
json.decodeFromString(this)
} catch (e: Exception) {
- StradaLog.e("jsonStringDecodeException: ${e.stackTraceToString()}")
+ logError("jsonStringDecodeException", e)
null
}
diff --git a/strada/src/main/kotlin/dev/hotwire/strada/Message.kt b/strada/src/main/kotlin/dev/hotwire/strada/Message.kt
index 8513d33..d3984b5 100644
--- a/strada/src/main/kotlin/dev/hotwire/strada/Message.kt
+++ b/strada/src/main/kotlin/dev/hotwire/strada/Message.kt
@@ -1,9 +1,9 @@
package dev.hotwire.strada
-data class Message(
+data class Message constructor(
/**
- * A unique identifier for this message. You can reply to messages by sending
- * the same message back, or creating a new message with the same id
+ * A unique identifier for this message. When you reply to the web with
+ * a message, this identifier is used to find its previously sent message.
*/
val id: String,
@@ -43,6 +43,17 @@ data class Message(
metadata = this.metadata,
jsonData = jsonData
)
+
+ inline fun replacing(
+ event: String = this.event,
+ data: T
+ ): Message {
+ return replacing(event, StradaJsonConverter.toJson(data))
+ }
+
+ inline fun data(): T? {
+ return StradaJsonConverter.toObject(jsonData)
+ }
}
data class Metadata(
diff --git a/strada/src/main/kotlin/dev/hotwire/strada/Strada.kt b/strada/src/main/kotlin/dev/hotwire/strada/Strada.kt
new file mode 100644
index 0000000..6171b93
--- /dev/null
+++ b/strada/src/main/kotlin/dev/hotwire/strada/Strada.kt
@@ -0,0 +1,10 @@
+package dev.hotwire.strada
+
+object Strada {
+ val config: StradaConfig = StradaConfig()
+
+ fun userAgentSubstring(componentFactories: List>): String {
+ val components = componentFactories.joinToString(" ") { it.name }
+ return "bridge-components: [$components]"
+ }
+}
diff --git a/strada/src/main/kotlin/dev/hotwire/strada/StradaConfig.kt b/strada/src/main/kotlin/dev/hotwire/strada/StradaConfig.kt
new file mode 100644
index 0000000..9003d79
--- /dev/null
+++ b/strada/src/main/kotlin/dev/hotwire/strada/StradaConfig.kt
@@ -0,0 +1,17 @@
+package dev.hotwire.strada
+
+class StradaConfig internal constructor() {
+ /**
+ * Set a custom JSON converter to easily decode Message.dataJson to a data
+ * object in received messages and to encode a data object back to json to
+ * reply with a custom message back to the web.
+ */
+ var jsonConverter: StradaJsonConverter? = null
+
+ /**
+ * Enable debug logging to see message communication from/to the WebView.
+ *
+ * NOTE: You should not enable debug logging in production builds.
+ */
+ var debugLoggingEnabled = false
+}
diff --git a/strada/src/main/kotlin/dev/hotwire/strada/StradaJsonConverter.kt b/strada/src/main/kotlin/dev/hotwire/strada/StradaJsonConverter.kt
new file mode 100644
index 0000000..278dbb9
--- /dev/null
+++ b/strada/src/main/kotlin/dev/hotwire/strada/StradaJsonConverter.kt
@@ -0,0 +1,63 @@
+package dev.hotwire.strada
+
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import java.lang.Exception
+
+abstract class StradaJsonConverter {
+ companion object {
+ const val NO_CONVERTER =
+ "A Strada.config.jsonConverter must be set to encode or decode json"
+
+ const val INVALID_CONVERTER =
+ "The configured json converter must implement a StradaJsonTypeConverter " +
+ "or use the provided KotlinXJsonConverter."
+
+ inline fun toObject(jsonData: String): T? {
+ val converter = requireNotNull(Strada.config.jsonConverter) { NO_CONVERTER }
+
+ return when (converter) {
+ is KotlinXJsonConverter -> converter.toObject(jsonData)
+ is StradaJsonTypeConverter -> converter.toObject(jsonData, T::class.java)
+ else -> throw IllegalStateException(INVALID_CONVERTER)
+ }
+ }
+
+ inline fun toJson(data: T): String {
+ val converter = requireNotNull(Strada.config.jsonConverter) { NO_CONVERTER }
+
+ return when (converter) {
+ is KotlinXJsonConverter -> converter.toJson(data)
+ is StradaJsonTypeConverter -> converter.toJson(data, T::class.java)
+ else -> throw IllegalStateException(INVALID_CONVERTER)
+ }
+ }
+ }
+}
+
+abstract class StradaJsonTypeConverter : StradaJsonConverter() {
+ abstract fun toObject(jsonData: String, type: Class): T?
+ abstract fun toJson(data: T, type: Class): String
+}
+
+class KotlinXJsonConverter : StradaJsonConverter() {
+ val json = Json { ignoreUnknownKeys = true }
+
+ inline fun toObject(jsonData: String): T? {
+ return try {
+ json.decodeFromString(jsonData)
+ } catch(e: Exception) {
+ logException(e)
+ null
+ }
+ }
+
+ inline fun toJson(data: T): String {
+ return json.encodeToString(data)
+ }
+
+ fun logException(e: Exception) {
+ logError("kotlinXJsonConverterFailedWithError", e)
+ }
+}
diff --git a/strada/src/main/kotlin/dev/hotwire/strada/StradaLog.kt b/strada/src/main/kotlin/dev/hotwire/strada/StradaLog.kt
index c2779bf..91c63d4 100644
--- a/strada/src/main/kotlin/dev/hotwire/strada/StradaLog.kt
+++ b/strada/src/main/kotlin/dev/hotwire/strada/StradaLog.kt
@@ -3,30 +3,34 @@ package dev.hotwire.strada
import android.util.Log
@Suppress("unused")
-object StradaLog {
+internal object StradaLog {
private const val DEFAULT_TAG = "StradaLog"
- /**
- * Enable debug logging to see message communication from/to the WebView.
- */
- var debugLoggingEnabled = false
+ private val debugEnabled get() = Strada.config.debugLoggingEnabled
- internal fun d(msg: String) = log(Log.DEBUG, DEFAULT_TAG, msg)
+ internal fun d(msg: String) = log(Log.DEBUG, msg)
- internal fun e(msg: String) = log(Log.ERROR, DEFAULT_TAG, msg)
+ internal fun w(msg: String) = log(Log.WARN, msg)
- private fun log(logLevel: Int, tag: String, msg: String) {
+ internal fun e(msg: String) = log(Log.ERROR, msg)
+
+ private fun log(logLevel: Int, msg: String) {
when (logLevel) {
- Log.DEBUG -> if (debugLoggingEnabled) Log.d(tag, msg)
- Log.ERROR -> Log.e(tag, msg)
+ Log.DEBUG -> if (debugEnabled) Log.d(DEFAULT_TAG, msg)
+ Log.WARN -> Log.w(DEFAULT_TAG, msg)
+ Log.ERROR -> Log.e(DEFAULT_TAG, msg)
}
}
}
-internal fun logMessage(event: String, message: Message) {
- logEvent(event, message.toString())
-}
-
internal fun logEvent(event: String, details: String = "") {
StradaLog.d("$event ".padEnd(35, '.') + " [$details]")
}
+
+internal fun logWarning(event: String, details: String) {
+ StradaLog.w("$event ".padEnd(35, '.') + " [$details]")
+}
+
+internal fun logError(event: String, error: Exception) {
+ StradaLog.e("$event: ${error.stackTraceToString()}")
+}
diff --git a/strada/src/test/kotlin/dev/hotwire/strada/BridgeComponentFactoryTest.kt b/strada/src/test/kotlin/dev/hotwire/strada/BridgeComponentFactoryTest.kt
index 8ad7adc..dfe0bc1 100644
--- a/strada/src/test/kotlin/dev/hotwire/strada/BridgeComponentFactoryTest.kt
+++ b/strada/src/test/kotlin/dev/hotwire/strada/BridgeComponentFactoryTest.kt
@@ -1,6 +1,5 @@
package dev.hotwire.strada
-import androidx.lifecycle.testing.TestLifecycleOwner
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -8,47 +7,15 @@ import org.junit.Test
class BridgeComponentFactoryTest {
@Test
fun createComponents() {
- val factories = listOf(
- BridgeComponentFactory("one", ::OneBridgeComponent),
- BridgeComponentFactory("two", ::TwoBridgeComponent)
- )
-
- val delegate = BridgeDelegate(
- destination = AppBridgeDestination(),
- componentFactories = factories
- )
+ val factories = TestData.componentFactories
+ val delegate = TestData.bridgeDelegate
val componentOne = factories[0].create(delegate)
assertEquals("one", componentOne.name)
- assertTrue(componentOne is OneBridgeComponent)
+ assertTrue(componentOne is TestData.OneBridgeComponent)
val componentTwo = factories[1].create(delegate)
assertEquals("two", componentTwo.name)
- assertTrue(componentTwo is TwoBridgeComponent)
- }
-
- class AppBridgeDestination : BridgeDestination {
- override fun bridgeDestinationLocation() = "https://37signals.com"
- override fun bridgeDestinationLifecycleOwner() = TestLifecycleOwner()
- override fun bridgeWebViewIsReady() = true
- }
-
- private abstract class AppBridgeComponent(
- name: String,
- delegate: BridgeDelegate
- ) : BridgeComponent(name, delegate)
-
- private class OneBridgeComponent(
- name: String,
- delegate: BridgeDelegate
- ) : AppBridgeComponent(name, delegate) {
- override fun handle(message: Message) {}
- }
-
- private class TwoBridgeComponent(
- name: String,
- delegate: BridgeDelegate
- ) : AppBridgeComponent(name, delegate) {
- override fun handle(message: Message) {}
+ assertTrue(componentTwo is TestData.TwoBridgeComponent)
}
}
diff --git a/strada/src/test/kotlin/dev/hotwire/strada/BridgeComponentTest.kt b/strada/src/test/kotlin/dev/hotwire/strada/BridgeComponentTest.kt
new file mode 100644
index 0000000..d8e7e19
--- /dev/null
+++ b/strada/src/test/kotlin/dev/hotwire/strada/BridgeComponentTest.kt
@@ -0,0 +1,132 @@
+package dev.hotwire.strada
+
+import com.nhaarman.mockito_kotlin.*
+import kotlinx.serialization.Serializable
+import org.assertj.core.api.Assertions
+import org.assertj.core.api.Assertions.assertThatThrownBy
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+
+class BridgeComponentTest {
+ private lateinit var component: TestData.OneBridgeComponent
+ private val delegate: BridgeDelegate = mock()
+
+ private val message = Message(
+ id = "1",
+ component = "one",
+ event = "connect",
+ metadata = Metadata("https://37signals.com"),
+ jsonData = """{"title":"Page-title","subtitle":"Page-subtitle"}"""
+ )
+
+ @Before
+ fun setup() {
+ Strada.config.jsonConverter = KotlinXJsonConverter()
+ component = TestData.OneBridgeComponent("one", delegate)
+ }
+
+ @Test
+ fun didReceive() {
+ assertNull(component.receivedMessageForPublic("connect"))
+
+ component.didReceive(message)
+ assertEquals(message, component.receivedMessageForPublic("connect"))
+ }
+
+ @Test
+ fun didStart() {
+ assertEquals(false, component.onStartCalled)
+
+ component.didStart()
+ assertEquals(true, component.onStartCalled)
+ }
+
+ @Test
+ fun didStop() {
+ assertEquals(false, component.onStopCalled)
+
+ component.didStop()
+ assertEquals(true, component.onStopCalled)
+ }
+
+ @Test
+ fun didReceiveSavesLastMessage() {
+ val newJsonData = """{"title":"Page-title"}"""
+ val newMessage = message.replacing(jsonData = newJsonData)
+
+ component.didReceive(message)
+ assertEquals(message, component.receivedMessageForPublic("connect"))
+
+ component.didReceive(newMessage)
+ assertEquals(newMessage, component.receivedMessageForPublic("connect"))
+ }
+
+ @Test
+ fun replyWith() {
+ val newJsonData = """{"title":"Page-title"}"""
+ val newMessage = message.replacing(jsonData = newJsonData)
+
+ component.replyWith(newMessage)
+ verify(delegate).replyWith(eq(newMessage))
+ }
+
+ @Test
+ fun replyTo() {
+ component.didReceive(message)
+
+ component.replyTo("connect")
+ verify(delegate).replyWith(eq(message))
+ }
+
+ @Test
+ fun replyToReplacingJsonData() {
+ val newJsonData = """{"title":"Page-title"}"""
+ val newMessage = message.replacing(jsonData = newJsonData)
+
+ component.didReceive(message)
+
+ component.replyTo("connect", newJsonData)
+ verify(delegate).replyWith(eq(newMessage))
+ }
+
+ @Test
+ fun replyToReplacingData() {
+ val newJsonData = """{"title":"Page-title"}"""
+ val newMessage = message.replacing(jsonData = newJsonData)
+
+ component.didReceive(message)
+
+ component.replyTo("connect", MessageData(title = "Page-title"))
+ verify(delegate).replyWith(eq(newMessage))
+ }
+
+ @Test
+ fun replyToReplacingDataWithNoConverter() {
+ Strada.config.jsonConverter = null
+
+ component.didReceive(message)
+
+ assertThatThrownBy { component.replyTo("connect", MessageData(title = "Page-title")) }
+ .isInstanceOf(IllegalArgumentException::class.java)
+ .hasMessage(StradaJsonConverter.NO_CONVERTER)
+ }
+
+ @Test
+ fun replyToIgnoresNotReceived() {
+ component.replyTo("connect")
+ verify(delegate, never()).replyWith(any())
+ }
+
+ @Test
+ fun replyToReplacingJsonDataIgnoresNotReceived() {
+ val newJsonData = """{"title":"Page-title"}"""
+
+ component.replyTo("connect", newJsonData)
+ verify(delegate, never()).replyWith(any())
+ }
+
+ @Serializable
+ private class MessageData(val title: String)
+}
diff --git a/strada/src/test/kotlin/dev/hotwire/strada/BridgeDelegateTest.kt b/strada/src/test/kotlin/dev/hotwire/strada/BridgeDelegateTest.kt
index 8204c8e..1ffcd81 100644
--- a/strada/src/test/kotlin/dev/hotwire/strada/BridgeDelegateTest.kt
+++ b/strada/src/test/kotlin/dev/hotwire/strada/BridgeDelegateTest.kt
@@ -9,29 +9,39 @@ import com.nhaarman.mockito_kotlin.never
import com.nhaarman.mockito_kotlin.whenever
import org.junit.Assert.*
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
import org.mockito.Mockito.verify
class BridgeDelegateTest {
- private lateinit var delegate: BridgeDelegate
+ private lateinit var delegate: BridgeDelegate
+ private lateinit var lifecycleOwner: TestLifecycleOwner
private val bridge: Bridge = mock()
private val webView: WebView = mock()
private val factories = listOf(
- BridgeComponentFactory("one", ::OneBridgeComponent),
- BridgeComponentFactory("two", ::TwoBridgeComponent)
+ BridgeComponentFactory("one", TestData::OneBridgeComponent),
+ BridgeComponentFactory("two", TestData::TwoBridgeComponent)
)
+ @Rule
+ @JvmField
+ var coroutinesTestRule = CoroutinesTestRule()
+
@Before
fun setup() {
whenever(bridge.webView).thenReturn(webView)
Bridge.initialize(bridge)
delegate = BridgeDelegate(
- destination = AppBridgeDestination(),
+ location = "https://37signals.com",
+ destination = TestData.AppBridgeDestination(),
componentFactories = factories
)
delegate.bridge = bridge
+
+ lifecycleOwner = TestLifecycleOwner(Lifecycle.State.STARTED)
+ lifecycleOwner.lifecycle.addObserver(delegate)
}
@Test
@@ -62,9 +72,9 @@ class BridgeDelegateTest {
jsonData = """{"title":"Page-title","subtitle":"Page-subtitle"}"""
)
- assertNull(delegate.component())
+ assertNull(delegate.component())
assertEquals(true, delegate.bridgeDidReceiveMessage(message))
- assertNotNull(delegate.component())
+ assertNotNull(delegate.component())
}
@Test
@@ -80,6 +90,33 @@ class BridgeDelegateTest {
assertEquals(false, delegate.bridgeDidReceiveMessage(message))
}
+ @Test
+ fun replyWith() {
+ val message = Message(
+ id = "1",
+ component = "page",
+ event = "connect",
+ metadata = Metadata("https://37signals.com/another_url"),
+ jsonData = """{"title":"Page-title","subtitle":"Page-subtitle"}"""
+ )
+
+ assertEquals(true, delegate.replyWith(message))
+ }
+
+ @Test
+ fun replyWithFailsWithoutBridge() {
+ val message = Message(
+ id = "1",
+ component = "page",
+ event = "connect",
+ metadata = Metadata("https://37signals.com/another_url"),
+ jsonData = """{"title":"Page-title","subtitle":"Page-subtitle"}"""
+ )
+
+ delegate.bridge = null
+ assertEquals(false, delegate.replyWith(message))
+ }
+
@Test
fun onWebViewAttached() {
whenever(bridge.isReady()).thenReturn(false)
@@ -112,28 +149,21 @@ class BridgeDelegateTest {
assertNull(delegate.bridge)
}
- class AppBridgeDestination : BridgeDestination {
- override fun bridgeDestinationLocation() = "https://37signals.com"
- override fun bridgeDestinationLifecycleOwner() = TestLifecycleOwner(Lifecycle.State.STARTED)
- override fun bridgeWebViewIsReady() = true
- }
-
- private abstract class AppBridgeComponent(
- name: String,
- delegate: BridgeDelegate
- ) : BridgeComponent(name, delegate)
+ @Test
+ fun destinationIsInactive() {
+ val message = Message(
+ id = "1",
+ component = "one",
+ event = "connect",
+ metadata = Metadata("https://37signals.com"),
+ jsonData = """{"title":"Page-title","subtitle":"Page-subtitle"}"""
+ )
- private class OneBridgeComponent(
- name: String,
- delegate: BridgeDelegate
- ) : AppBridgeComponent(name, delegate) {
- override fun handle(message: Message) {}
- }
+ assertEquals(true, delegate.bridgeDidReceiveMessage(message))
+ assertNotNull(delegate.component())
- private class TwoBridgeComponent(
- name: String,
- delegate: BridgeDelegate
- ) : AppBridgeComponent(name, delegate) {
- override fun handle(message: Message) {}
+ lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
+ assertEquals(false, delegate.bridgeDidReceiveMessage(message))
+ assertNull(delegate.component())
}
}
diff --git a/strada/src/test/kotlin/dev/hotwire/strada/BridgeTest.kt b/strada/src/test/kotlin/dev/hotwire/strada/BridgeTest.kt
index ddd7b82..43fc4ef 100644
--- a/strada/src/test/kotlin/dev/hotwire/strada/BridgeTest.kt
+++ b/strada/src/test/kotlin/dev/hotwire/strada/BridgeTest.kt
@@ -2,8 +2,6 @@ package dev.hotwire.strada
import android.content.Context
import android.webkit.WebView
-import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.testing.TestLifecycleOwner
import com.nhaarman.mockito_kotlin.any
import com.nhaarman.mockito_kotlin.eq
import com.nhaarman.mockito_kotlin.mock
@@ -18,7 +16,7 @@ class BridgeTest {
private val webView: WebView = mock()
private val context: Context = mock()
private val repository: Repository = mock()
- private val delegate: BridgeDelegate = mock()
+ private val delegate: BridgeDelegate = mock()
@Before
fun setup() {
@@ -49,7 +47,7 @@ class BridgeTest {
}
@Test
- fun send() {
+ fun replyWith() {
val json = """{\"id\":\"1\",\"component\":\"page\",\"event\":\"connect\",\"data\":{\"title\":\"Page title\",\"subtitle\":\"Page subtitle\",\"html\":\"content\"}}"""
val data = """{"title":"Page title","subtitle":"Page subtitle","html":"content"}"""
val message = Message(
@@ -60,8 +58,8 @@ class BridgeTest {
jsonData = data
)
- val javascript = """window.nativeBridge.send("$json")"""
- bridge.send(message)
+ val javascript = """window.nativeBridge.replyWith("$json")"""
+ bridge.replyWith(message)
verify(webView).evaluateJavascript(eq(javascript), any())
}
@@ -133,12 +131,6 @@ class BridgeTest {
@Test
fun sanitizeFunctionName() {
- assertEquals(bridge.sanitizeFunctionName("send()"), "send")
- }
-
- class AppBridgeDestination : BridgeDestination {
- override fun bridgeDestinationLocation() = "https://37signals.com"
- override fun bridgeDestinationLifecycleOwner() = TestLifecycleOwner()
- override fun bridgeWebViewIsReady() = true
+ assertEquals(bridge.sanitizeFunctionName("replyWith()"), "replyWith")
}
}
diff --git a/strada/src/test/kotlin/dev/hotwire/strada/CoroutinesTestRule.kt b/strada/src/test/kotlin/dev/hotwire/strada/CoroutinesTestRule.kt
new file mode 100644
index 0000000..fbc9a7e
--- /dev/null
+++ b/strada/src/test/kotlin/dev/hotwire/strada/CoroutinesTestRule.kt
@@ -0,0 +1,21 @@
+package dev.hotwire.strada
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.*
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+class CoroutinesTestRule(
+ private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(TestCoroutineScheduler())
+) : TestWatcher() {
+
+ override fun starting(description: Description) {
+ super.starting(description)
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ override fun finished(description: Description) {
+ super.finished(description)
+ Dispatchers.resetMain()
+ }
+}
diff --git a/strada/src/test/kotlin/dev/hotwire/strada/MessageTest.kt b/strada/src/test/kotlin/dev/hotwire/strada/MessageTest.kt
index 7bd693c..c3fa8f7 100644
--- a/strada/src/test/kotlin/dev/hotwire/strada/MessageTest.kt
+++ b/strada/src/test/kotlin/dev/hotwire/strada/MessageTest.kt
@@ -1,11 +1,54 @@
package dev.hotwire.strada
+import kotlinx.serialization.Serializable
+import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
import org.junit.Test
class MessageTest {
+
+ @Before
+ fun setup() {
+ Strada.config.jsonConverter = KotlinXJsonConverter()
+ }
+
+ @Test
+ fun dataDecodesToObject() {
+ val metadata = Metadata("https://37signals.com")
+ val message = Message(
+ id = "1",
+ component = "page",
+ event = "connect",
+ metadata = metadata,
+ jsonData = """{"title":"Page-title","subtitle":"Page-subtitle"}"""
+ )
+
+ val data = message.data()
+
+ assertEquals("Page-title", data?.title)
+ assertEquals("Page-subtitle", data?.subtitle)
+ }
+
@Test
- fun replacing() {
+ fun dataDoesNotDecodeToInvalidObject() {
+ val metadata = Metadata("https://37signals.com")
+ val message = Message(
+ id = "1",
+ component = "page",
+ event = "connect",
+ metadata = metadata,
+ jsonData = """{"title":"Page-title","subtitle":"Page-subtitle"}"""
+ )
+
+ val data = message.data()
+
+ assertNull(data)
+ }
+
+ @Test
+ fun replacingJsonData() {
val metadata = Metadata("https://37signals.com")
val message = Message(
id = "1",
@@ -26,4 +69,74 @@ class MessageTest {
assertEquals(metadata, newMessage.metadata)
assertEquals("{}", newMessage.jsonData)
}
+
+ @Test
+ fun replacingData() {
+ val metadata = Metadata("https://37signals.com")
+ val message = Message(
+ id = "1",
+ component = "page",
+ event = "connect",
+ metadata = metadata,
+ jsonData = "{}"
+ )
+
+ val data = MessageData(title = "New-title", subtitle = "New-subtitle")
+
+ val newMessage = message.replacing(
+ event = "disconnect",
+ data = data
+ )
+
+ assertEquals("1", newMessage.id)
+ assertEquals("page", newMessage.component)
+ assertEquals("disconnect", newMessage.event)
+ assertEquals(metadata, newMessage.metadata)
+ assertEquals("""{"title":"New-title","subtitle":"New-subtitle"}""", newMessage.jsonData)
+ }
+
+ @Test
+ fun replacingDataWithNoConverter() {
+ Strada.config.jsonConverter = null
+
+ val message = Message(
+ id = "1",
+ component = "page",
+ event = "connect",
+ metadata = Metadata("https://37signals.com"),
+ jsonData = "{}"
+ )
+
+ val data = MessageData(title = "New-title", subtitle = "New-subtitle")
+
+ assertThatThrownBy { message.replacing(data = data) }
+ .isInstanceOf(IllegalArgumentException::class.java)
+ .hasMessage(StradaJsonConverter.NO_CONVERTER)
+ }
+
+ @Test
+ fun replacingDataWithInvalidConverter() {
+ Strada.config.jsonConverter = InvalidJsonConverter()
+
+ val message = Message(
+ id = "1",
+ component = "page",
+ event = "connect",
+ metadata = Metadata("https://37signals.com"),
+ jsonData = "{}"
+ )
+
+ val data = MessageData(title = "New-title", subtitle = "New-subtitle")
+
+ assertThatThrownBy { message.replacing(data = data) }
+ .isInstanceOf(IllegalStateException::class.java)
+ .hasMessage(StradaJsonConverter.INVALID_CONVERTER)
+ }
+
+ @Serializable
+ private class MessageData(val title: String, val subtitle: String)
+
+ private class InvalidMessageData()
+
+ private class InvalidJsonConverter : StradaJsonConverter()
}
diff --git a/strada/src/test/kotlin/dev/hotwire/strada/TestData.kt b/strada/src/test/kotlin/dev/hotwire/strada/TestData.kt
new file mode 100644
index 0000000..e1fa629
--- /dev/null
+++ b/strada/src/test/kotlin/dev/hotwire/strada/TestData.kt
@@ -0,0 +1,52 @@
+package dev.hotwire.strada
+
+object TestData {
+ val componentFactories = listOf(
+ BridgeComponentFactory("one", TestData::OneBridgeComponent),
+ BridgeComponentFactory("two", TestData::TwoBridgeComponent)
+ )
+
+ val bridgeDelegate = BridgeDelegate(
+ location = "https://37signals.com",
+ destination = AppBridgeDestination(),
+ componentFactories = componentFactories
+ )
+
+ class AppBridgeDestination : BridgeDestination {
+ override fun bridgeWebViewIsReady() = true
+ }
+
+ abstract class AppBridgeComponent(
+ name: String,
+ delegate: BridgeDelegate
+ ) : BridgeComponent(name, delegate)
+
+ class OneBridgeComponent(
+ name: String,
+ delegate: BridgeDelegate
+ ) : AppBridgeComponent(name, delegate) {
+ var onStartCalled = false
+ var onStopCalled = false
+
+ override fun onStart() {
+ onStartCalled = true
+ }
+
+ override fun onStop() {
+ onStopCalled = true
+ }
+
+ override fun onReceive(message: Message) {}
+
+ fun receivedMessageForPublic(event: String): Message? {
+ return receivedMessageFor(event)
+ }
+ }
+
+ class TwoBridgeComponent(
+ name: String,
+ delegate: BridgeDelegate
+ ) : AppBridgeComponent(name, delegate) {
+ override fun onReceive(message: Message) {}
+ }
+}
\ No newline at end of file
diff --git a/strada/src/test/kotlin/dev/hotwire/strada/UserAgentTest.kt b/strada/src/test/kotlin/dev/hotwire/strada/UserAgentTest.kt
new file mode 100644
index 0000000..f188160
--- /dev/null
+++ b/strada/src/test/kotlin/dev/hotwire/strada/UserAgentTest.kt
@@ -0,0 +1,17 @@
+package dev.hotwire.strada
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class UserAgentTest {
+ @Test
+ fun userAgentSubstring() {
+ val factories = listOf(
+ BridgeComponentFactory("one", TestData::OneBridgeComponent),
+ BridgeComponentFactory("two", TestData::TwoBridgeComponent)
+ )
+
+ val userAgentSubstring = Strada.userAgentSubstring(factories)
+ assertEquals(userAgentSubstring, "bridge-components: [one two]")
+ }
+}