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]") + } +}