Skip to content

Commit

Permalink
Provide interface for external bus outside WebViewActivity (#4338)
Browse files Browse the repository at this point in the history
- Adds an interface to the app to interact with the external bus outside the WebViewActivity. This should make it possible for the frontend to control more native UI of the app.
  • Loading branch information
jpelgrom committed Apr 20, 2024
1 parent a363039 commit f37e34e
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 46 deletions.
@@ -1,6 +1,7 @@
package io.homeassistant.companion.android.webview

import android.net.http.SslError
import io.homeassistant.companion.android.webview.externalbus.ExternalBusMessage

interface WebView {
enum class ErrorType {
Expand All @@ -16,6 +17,8 @@ interface WebView {

fun setExternalAuth(script: String)

fun sendExternalBusMessage(message: ExternalBusMessage)

fun relaunchApp()

fun unlockAppIfNeeded()
Expand Down
Expand Up @@ -101,6 +101,7 @@ import io.homeassistant.companion.android.util.TLSWebViewClient
import io.homeassistant.companion.android.util.isStarted
import io.homeassistant.companion.android.websocket.WebsocketManager
import io.homeassistant.companion.android.webview.WebView.ErrorType
import io.homeassistant.companion.android.webview.externalbus.ExternalBusMessage
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Named
Expand Down Expand Up @@ -155,14 +156,17 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
}
}
private val writeNfcTag = registerForActivityResult(WriteNfcTag()) { messageId ->
webView.externalBus(
id = messageId,
type = "result",
success = true,
result = emptyMap<String, String>()
) {
Log.d(TAG, "NFC Write Complete $it")
}
sendExternalBusMessage(
ExternalBusMessage(
id = messageId,
type = "result",
success = true,
result = emptyMap<String, String>(),
callback = {
Log.d(TAG, "NFC Write Complete $it")
}
)
)
}
private val showWebFileChooser = registerForActivityResult(ShowWebFileChooser()) { result ->
mFilePathCallback?.onReceiveValue(result)
Expand Down Expand Up @@ -726,23 +730,26 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
val hasNfc = pm.hasSystemFeature(PackageManager.FEATURE_NFC)
val canCommissionMatter = presenter.appCanCommissionMatterDevice()
val canExportThread = presenter.appCanExportThreadCredentials()
webView.externalBus(
id = JSONObject(message).get("id"),
type = "result",
success = true,
result = JSONObject(
mapOf(
"hasSettingsScreen" to true,
"canWriteTag" to hasNfc,
"hasExoPlayer" to true,
"canCommissionMatter" to canCommissionMatter,
"canImportThreadCredentials" to canExportThread,
"hasAssist" to true
)
sendExternalBusMessage(
ExternalBusMessage(
id = JSONObject(message).get("id"),
type = "result",
success = true,
result = JSONObject(
mapOf(
"hasSettingsScreen" to true,
"canWriteTag" to hasNfc,
"hasExoPlayer" to true,
"canCommissionMatter" to canCommissionMatter,
"canImportThreadCredentials" to canExportThread,
"hasAssist" to true
)
),
callback = {
Log.d(TAG, "Callback $it")
}
)
) {
Log.d(TAG, "Callback $it")
}
)

// TODO This feature is deprecated and should be removed after 2022.6
getAndSetStatusBarNavigationBarColors()
Expand Down Expand Up @@ -793,6 +800,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
"exoplayer/resize" -> exoResizeHls(json)
"haptic" -> processHaptic(json.getJSONObject("payload").getString("hapticType"))
"theme-update" -> getAndSetStatusBarNavigationBarColors()
else -> presenter.onExternalBusMessage(json)
}
}
}
Expand Down Expand Up @@ -958,14 +966,16 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
exoPlayerView.visibility = View.VISIBLE
findViewById<ImageButton>(R.id.exo_ha_mute)?.setOnClickListener { exoToggleMute() }
}
webView.externalBus(
id = json.get("id"),
type = "result",
success = true,
result = null
) {
Log.d(TAG, "Callback $it")
}
sendExternalBusMessage(
ExternalBusMessage(
id = json.get("id"),
type = "result",
success = true,
callback = {
Log.d(TAG, "Callback $it")
}
)
)
}

fun exoStopHls() {
Expand Down Expand Up @@ -1490,28 +1500,21 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
)
}

private fun WebView.externalBus(
id: Any,
type: String,
success: Boolean,
result: Any? = null,
error: Any? = null,
callback: ValueCallback<String>?
) {
override fun sendExternalBusMessage(message: ExternalBusMessage) {
val map = mutableMapOf(
"id" to id,
"type" to type,
"success" to success
"id" to message.id,
"type" to message.type,
"success" to message.success
)
if (result != null) map["result"] = result
if (error != null) map["error"] = error
message.result?.let { map["result"] = it }
message.error?.let { map["error"] = it }

val json = JSONObject(map.toMap())
val script = "externalBus($json);"

Log.d(TAG, script)

this.evaluateJavascript(script, callback)
webView.evaluateJavascript(script, message.callback)
}

private fun downloadFile(url: String, contentDisposition: String, mimetype: String) {
Expand Down
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import android.content.IntentSender
import androidx.activity.result.ActivityResult
import kotlinx.coroutines.flow.Flow
import org.json.JSONObject

interface WebViewPresenter {

Expand Down Expand Up @@ -41,6 +42,8 @@ interface WebViewPresenter {

fun sessionTimeOut(): Int

fun onExternalBusMessage(message: JSONObject)

fun onStart(context: Context)

fun onFinish()
Expand Down
Expand Up @@ -17,6 +17,7 @@ import io.homeassistant.companion.android.matter.MatterManager
import io.homeassistant.companion.android.thread.ThreadManager
import io.homeassistant.companion.android.util.UrlUtil
import io.homeassistant.companion.android.util.UrlUtil.baseIsEqual
import io.homeassistant.companion.android.webview.externalbus.ExternalBusRepository
import java.net.SocketTimeoutException
import java.net.URL
import java.util.regex.Matcher
Expand All @@ -35,10 +36,12 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.json.JSONObject

class WebViewPresenterImpl @Inject constructor(
@ActivityContext context: Context,
private val serverManager: ServerManager,
private val externalBusRepository: ExternalBusRepository,
private val prefsRepository: PrefsRepository,
private val matterUseCase: MatterManager,
private val threadUseCase: ThreadManager
Expand All @@ -63,6 +66,16 @@ class WebViewPresenterImpl @Inject constructor(

init {
updateActiveServer()

mainScope.launch {
externalBusRepository.getSentFlow().collect {
try {
view.sendExternalBusMessage(it)
} catch (e: Exception) {
Log.w(TAG, "Unable to send message to external bus $it", e)
}
}
}
}

override fun onViewReady(path: String?) {
Expand Down Expand Up @@ -285,6 +298,12 @@ class WebViewPresenterImpl @Inject constructor(
prefsRepository.isAlwaysShowFirstViewOnAppStartEnabled()
}

override fun onExternalBusMessage(message: JSONObject) {
mainScope.launch {
externalBusRepository.received(message)
}
}

override fun sessionTimeOut(): Int = runBlocking {
serverManager.getServer(serverId)?.let {
serverManager.integrationRepository(serverId).getSessionTimeOut()
Expand Down
@@ -0,0 +1,12 @@
package io.homeassistant.companion.android.webview.externalbus

import android.webkit.ValueCallback

data class ExternalBusMessage(
val id: Any,
val type: String,
val success: Boolean,
val result: Any? = null,
val error: Any? = null,
val callback: ValueCallback<String>? = null
)
@@ -0,0 +1,16 @@
package io.homeassistant.companion.android.webview.externalbus

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class ExternalBusModule {

@Binds
@Singleton
abstract fun externalBusRepository(externalBusRepositoryImpl: ExternalBusRepositoryImpl): ExternalBusRepository
}
@@ -0,0 +1,34 @@
package io.homeassistant.companion.android.webview.externalbus

import kotlinx.coroutines.flow.Flow
import org.json.JSONObject

/**
* A repository to communicate with the external bus which is provided by the frontend,
* in contexts where there is no 'line of sight' to the webview (usually: other activity).
*
* The [WebViewActivity] or listener should be alive for this to work, and the repository
* does not guarantee that the the receiver will immediately receive the message as the
* system can limit background activity.
*/
interface ExternalBusRepository {

/** Send a message to the external bus (for native) */
suspend fun send(message: ExternalBusMessage)

/**
* Register to receive certain messages from the external bus (for native)
* @param types List of which message `type`s should be received
* @return Flow with received messages for the specified types
*/
fun receive(types: List<String>): Flow<JSONObject>

/** Send a message from the external bus to registered receivers (for webview) */
suspend fun received(message: JSONObject)

/**
* @return Flow with [ExternalBusMessage]s that should be sent on the external
* bus (for webview)
*/
fun getSentFlow(): Flow<ExternalBusMessage>
}
@@ -0,0 +1,43 @@
package io.homeassistant.companion.android.webview.externalbus

import android.util.Log
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.json.JSONObject

class ExternalBusRepositoryImpl @Inject constructor() : ExternalBusRepository {

companion object {
private const val TAG = "ExternalBusRepo"
}

private val externalBusFlow = MutableSharedFlow<ExternalBusMessage>(
// Don't suspend if the WebView is temporarily unavailable
extraBufferCapacity = 100
)
private val receiverFlows = mutableMapOf<List<String>, MutableSharedFlow<JSONObject>>()

override suspend fun send(message: ExternalBusMessage) {
externalBusFlow.emit(message)
}

override fun receive(types: List<String>): Flow<JSONObject> {
val flow = receiverFlows[types] ?: MutableSharedFlow()
receiverFlows[types] = flow
return flow.asSharedFlow()
}

override suspend fun received(message: JSONObject) {
if (!message.has("type")) return
val type = message.getString("type")
val receivers = receiverFlows.filter { it.key.contains(type) }
Log.d(TAG, "Sending message of type $type to ${receivers.size} receiver(s)")
receivers.forEach { (_, flow) ->
flow.emit(message)
}
}

override fun getSentFlow(): Flow<ExternalBusMessage> = externalBusFlow.asSharedFlow()
}

0 comments on commit f37e34e

Please sign in to comment.