Skip to content

Commit

Permalink
Merge pull request #9 from hotwired/bridge-delegate-improvements
Browse files Browse the repository at this point in the history
Improve the `BridgeDelegate` to handle all bridge/webview related functionality
  • Loading branch information
jayohms authored Apr 6, 2023
2 parents 54966ca + d7501c0 commit 35b91bc
Show file tree
Hide file tree
Showing 16 changed files with 384 additions and 54 deletions.
4 changes: 3 additions & 1 deletion strada/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,13 @@ dependencies {

implementation 'androidx.core:core-ktx:1.9.0'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1'
implementation 'androidx.lifecycle:lifecycle-common:2.6.1'

testImplementation 'junit:junit:4.13.2'
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.lifecycle:lifecycle-runtime-testing:2.6.1'
testImplementation 'org.robolectric:robolectric:4.9.2'
testImplementation 'org.mockito:mockito-core:4.11.0'
testImplementation 'org.mockito:mockito-core:5.2.0'
testImplementation 'com.nhaarman:mockito-kotlin:1.6.0'
}

Expand Down
24 changes: 16 additions & 8 deletions strada/src/main/kotlin/dev/hotwire/strada/Bridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,64 +9,72 @@ private const val bridgeGlobal = "window.nativeBridge"
private const val bridgeJavascriptInterface = "Strada"

@Suppress("unused")
class Bridge(val webView: WebView) {
class Bridge(private val webView: WebView) {
internal var repository = Repository()
private var componentsAreRegistered: Boolean = false

var delegate: BridgeDelegate? = null
var componentsAreRegistered: Boolean = false
private set
var delegate: BridgeDelegate<*>? = null

init {
// The JavascriptInterface must be added before the page is loaded
webView.addJavascriptInterface(this, bridgeJavascriptInterface)
}

fun register(component: String) {
logEvent("bridgeWillRegisterComponent", component)
val javascript = generateJavaScript("register", component.toJsonElement())
evaluate(javascript)
}

fun register(components: List<String>) {
logEvent("bridgeWillRegisterComponents", components.joinToString())
val javascript = generateJavaScript("register", components.toJsonElement())
evaluate(javascript)
}

fun unregister(component: String) {
logEvent("bridgeWillUnregisterComponent", component)
val javascript = generateJavaScript("unregister", component.toJsonElement())
evaluate(javascript)
}

fun send(message: Message) {
logMessage("bridgeWillSendMessage", message)
val internalMessage = InternalMessage.fromMessage(message)
val javascript = generateJavaScript("send", internalMessage.toJson().toJsonElement())
evaluate(javascript)
}

fun load() {
logEvent("bridgeWillLoad")
evaluate(userScript())
}

fun reset() {
logEvent("bridgeDidReset")
componentsAreRegistered = false
}

fun isReady(): Boolean {
return componentsAreRegistered
}

@JavascriptInterface
fun bridgeDidInitialize() {
log("bridge initialized")
logEvent("bridgeDidInitialize")
runOnUiThread {
delegate?.bridgeDidInitialize()
}
}

@JavascriptInterface
fun bridgeDidUpdateSupportedComponents() {
log("bridge components registered")
logEvent("bridgeDidUpdateSupportedComponents")
componentsAreRegistered = true
}

@JavascriptInterface
fun bridgeDidReceiveMessage(message: String?) {
log("message received: $message")
runOnUiThread {
InternalMessage.fromJson(message)?.let {
delegate?.bridgeDidReceiveMessage(it.toMessage())
Expand All @@ -81,7 +89,7 @@ class Bridge(val webView: WebView) {
}

internal fun evaluate(javascript: String) {
log("evaluating $javascript")
logEvent("evaluatingJavascript", javascript)
webView.evaluateJavascript(javascript) {}
}

Expand Down
13 changes: 11 additions & 2 deletions strada/src/main/kotlin/dev/hotwire/strada/BridgeComponent.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
package dev.hotwire.strada

abstract class BridgeComponent(
abstract class BridgeComponent<in D : BridgeDestination>(
val name: String,
private val delegate: BridgeDelegate
private val delegate: BridgeDelegate<D>
) {
abstract fun handle(message: Message)

fun send(message: Message) {
delegate.bridge?.send(message) ?: run {
logEvent("bridgeMessageFailedToSend", "bridge is not available")
}
}

open fun onStart() {}
open fun onStop() {}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package dev.hotwire.strada

class BridgeComponentFactory<in D : BridgeDelegate, out C : BridgeComponent> constructor(
class BridgeComponentFactory<D : BridgeDestination, out C : BridgeComponent<D>> constructor(
val name: String,
private val creator: (name: String, delegate: D) -> C
private val creator: (name: String, delegate: BridgeDelegate<D>) -> C
) {
fun create(delegate: D) = creator(name, delegate)
fun create(delegate: BridgeDelegate<D>) = creator(name, delegate)
}
100 changes: 96 additions & 4 deletions strada/src/main/kotlin/dev/hotwire/strada/BridgeDelegate.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,100 @@
package dev.hotwire.strada

abstract class BridgeDelegate(
componentFactories: List<BridgeComponentFactory<*, *>>
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner

@Suppress("unused")
class BridgeDelegate<D : BridgeDestination>(
val destination: D,
private val componentFactories: List<BridgeComponentFactory<D, BridgeComponent<D>>>
) {
abstract fun bridgeDidInitialize()
abstract fun bridgeDidReceiveMessage(message: Message)
internal var bridge: Bridge? = null
private var destinationIsActive = true
private val components = hashMapOf<String, BridgeComponent<D>>()

val activeComponents: List<BridgeComponent<D>>
get() = when (destinationIsActive) {
true -> components.map { it.value }
else -> emptyList()
}

init {
observeLifeCycle()
}

fun loadBridgeInWebView() {
bridge?.load()
}

fun resetBridge() {
bridge?.reset()
}

fun onWebViewAttached(bridge: Bridge?) {
this.bridge = bridge
this.bridge?.delegate = this

if (shouldReloadBridge()) {
loadBridgeInWebView()
}
}

fun onWebViewDetached() {
bridge?.delegate = null
bridge = null
}

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)
true
} else {
logMessage("bridgeDidIgnoreMessage", message)
false
}
}

private fun shouldReloadBridge(): Boolean {
return destination.bridgeWebViewIsReady() && bridge?.isReady() == false
}

// Lifecycle events

private fun observeLifeCycle() {
destination.bridgeDestinationLifecycleOwner().lifecycle.addObserver(object :
DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) { onStart() }
override fun onStop(owner: LifecycleOwner) { onStop() }
})
}

private fun onStart() {
destinationIsActive = true
activeComponents.forEach { it.onStart() }
}

private fun onStop() {
destinationIsActive = false
activeComponents.forEach { it.onStop() }
}

// Retrieve component(s) by type

inline fun <reified C> component(): C? {
return activeComponents.filterIsInstance<C>().firstOrNull()
}

inline fun <reified C> forEachComponent(action: (C) -> Unit) {
activeComponents.filterIsInstance<C>().forEach { action(it) }
}

private fun getOrCreateComponent(name: String): BridgeComponent<D>? {
val factory = componentFactories.firstOrNull { it.name == name } ?: return null
return components.getOrPut(name) { factory.create(this) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package dev.hotwire.strada

import androidx.lifecycle.LifecycleOwner

interface BridgeDestination {
fun bridgeDestinationLocation(): String
fun bridgeDestinationLifecycleOwner(): LifecycleOwner
fun bridgeWebViewIsReady(): Boolean
}
7 changes: 0 additions & 7 deletions strada/src/main/kotlin/dev/hotwire/strada/Helpers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package dev.hotwire.strada

import android.os.Handler
import android.os.Looper
import android.util.Log

/**
* Guarantees main thread execution, posting a Runnable on
Expand All @@ -15,9 +14,3 @@ internal fun runOnUiThread(func: () -> Unit) {
else -> Handler(mainLooper).post { func() }
}
}

internal fun log(message: String) {
if (BuildConfig.DEBUG) {
Log.d("Strada", "[bridge] $message")
}
}
24 changes: 14 additions & 10 deletions strada/src/main/kotlin/dev/hotwire/strada/InternalMessage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,20 @@ package dev.hotwire.strada

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement

@Serializable
internal data class InternalMessage(
@SerialName("id") val id: String,
@SerialName("component") val component: String,
@SerialName("event") val event: String,
@SerialName("data") val data: JsonElement = Json.parseToJsonElement("{}")
@SerialName("data") val data: JsonElement = "{}".parseToJsonElement()
) {
fun toMessage() = Message(
id = id,
component = component,
event = event,
metadata = data.decode<InternalDataMetadata>()?.let { Metadata(url = it.metadata.url) },
jsonData = data.toJson()
)

Expand All @@ -25,14 +24,19 @@ internal data class InternalMessage(
id = message.id,
component = message.component,
event = message.event,
data = Json.parseToJsonElement(message.jsonData)
data = message.jsonData.parseToJsonElement()
)

fun fromJson(json: String?) = try {
json?.let { Json.decodeFromString<InternalMessage>(it) }
} catch (e: Exception) {
log("Invalid message: $json")
null
}
fun fromJson(json: String?) = json?.decode<InternalMessage>()
}
}

@Serializable
internal data class InternalDataMetadata(
@SerialName("metadata") val metadata: InternalMetadata
)

@Serializable
internal data class InternalMetadata(
@SerialName("url") val url: String
)
19 changes: 16 additions & 3 deletions strada/src/main/kotlin/dev/hotwire/strada/JsonExtensions.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
package dev.hotwire.strada

import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement

internal inline fun <reified T> T.toJsonElement() = Json.encodeToJsonElement(this)
internal fun String.parseToJsonElement() = json.parseToJsonElement(this)

internal inline fun <reified T> T.toJson() = Json.encodeToString(this)
internal inline fun <reified T> T.toJsonElement() = json.encodeToJsonElement(this)

internal inline fun <reified T> T.toJson() = json.encodeToString(this)

internal inline fun <reified T> JsonElement.decode(): T? = try {
Json.decodeFromJsonElement<T>(this)
json.decodeFromJsonElement<T>(this)
} catch (e: Exception) {
StradaLog.e("jsonElementDecodeException: ${e.stackTraceToString()}")
null
}

internal inline fun <reified T> String.decode(): T? = try {
json.decodeFromString<T>(this)
} catch (e: Exception) {
StradaLog.e("jsonStringDecodeException: ${e.stackTraceToString()}")
null
}

private val json = Json { ignoreUnknownKeys = true }
10 changes: 10 additions & 0 deletions strada/src/main/kotlin/dev/hotwire/strada/Message.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ data class Message(
*/
val event: String,

/**
* The metadata associated with the message, which includes its url
*/
val metadata: Metadata?,

/**
* Data, represented in a json object string, to send along with the message.
* For a "page" component, this might be `{"title": "Page Title"}`
Expand All @@ -35,6 +40,11 @@ data class Message(
id = this.id,
component = this.component,
event = event,
metadata = this.metadata,
jsonData = jsonData
)
}

data class Metadata(
val url: String
)
Loading

0 comments on commit 35b91bc

Please sign in to comment.