Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import io.homeassistant.companion.android.frontend.download.DownloadResult
import io.homeassistant.companion.android.frontend.download.FrontendDownloadManager
import io.homeassistant.companion.android.frontend.error.FrontendConnectionError
import io.homeassistant.companion.android.frontend.error.FrontendConnectionErrorStateProvider
import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalBusRepository
import io.homeassistant.companion.android.frontend.externalbus.WebViewScript
import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType
import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage
import io.homeassistant.companion.android.frontend.handler.FrontendBusObserver
import io.homeassistant.companion.android.frontend.handler.FrontendHandlerEvent
import io.homeassistant.companion.android.frontend.js.BridgeState
Expand Down Expand Up @@ -67,6 +69,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
initialPath: String?,
webViewClientFactory: HAWebViewClientFactory,
private val frontendBusObserver: FrontendBusObserver,
private val externalBusRepository: FrontendExternalBusRepository,
private val urlManager: FrontendUrlManager,
private val connectivityCheckRepository: ConnectivityCheckRepository,
private val permissionManager: PermissionManager,
Expand All @@ -80,6 +83,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
savedStateHandle: SavedStateHandle,
webViewClientFactory: HAWebViewClientFactory,
frontendBusObserver: FrontendBusObserver,
externalBusRepository: FrontendExternalBusRepository,
urlManager: FrontendUrlManager,
connectivityCheckRepository: ConnectivityCheckRepository,
permissionManager: PermissionManager,
Expand All @@ -90,6 +94,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
initialPath = savedStateHandle.toRoute<FrontendRoute>().path,
webViewClientFactory = webViewClientFactory,
frontendBusObserver = frontendBusObserver,
externalBusRepository = externalBusRepository,
urlManager = urlManager,
connectivityCheckRepository = connectivityCheckRepository,
permissionManager = permissionManager,
Expand Down Expand Up @@ -322,6 +327,24 @@ internal class FrontendViewModel @VisibleForTesting constructor(
permissionManager.clearPendingPermissionRequest()
}

/**
* Called by the host after the NFC tag-write flow completes.
*
* Sends a `result` response back to the frontend correlated by [messageId]. Matches the legacy
* behavior of always reporting `success = true` with an empty payload: the underlying
* `NfcSetupActivity` only returns a non-zero result code on successful write, and the frontend
* silently ignores responses whose id it no longer tracks.
*
* @param messageId The correlation id received back from the activity result. Corresponds to
* the id of the originating `tag/write` request on success, or `0` (`RESULT_CANCELED`) on
* cancellation.
*/
fun onNfcWriteCompleted(messageId: Int) {
viewModelScope.launch {
externalBusRepository.send(ResultMessage.success(messageId))
}
}

private fun loadServer() {
urlFlowJob?.cancel()
urlFlowJob = viewModelScope.launch {
Expand Down Expand Up @@ -396,6 +419,10 @@ internal class FrontendViewModel @VisibleForTesting constructor(
handleDownloadResult(result.result)
}

is FrontendHandlerEvent.WriteNfcTag -> {
_events.tryEmit(FrontendEvent.LaunchNfcWrite(messageId = result.messageId, tagId = result.tagId))
}

is FrontendHandlerEvent.ConfigSent,
is FrontendHandlerEvent.UnknownMessage,
-> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@ data class OpenAssistPayload(
@SerialName("haptic")
data class HapticMessage(override val id: Int? = null, val payload: HapticType) : IncomingExternalBusMessage

/**
* Message requesting the app to open the NFC tag-write flow.
*
* The optional [TagWritePayload.tag] is a pre-filled tag identifier. When null or missing, the
* user is prompted to enter/scan a tag manually. The response is a [ResultMessage] correlated via
* [id] once the user completes or cancels the flow.
Comment thread
TimoPtr marked this conversation as resolved.
Outdated
*/
@Serializable
@SerialName("tag/write")
data class TagWriteMessage(override val id: Int? = null, val payload: TagWritePayload = TagWritePayload()) :
IncomingExternalBusMessage

@Serializable
data class TagWritePayload(val tag: String? = null)

/**
* Message carrying blob data for a file download initiated by the frontend.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement

/**
Expand Down Expand Up @@ -39,6 +40,13 @@ data class ResultMessage(

companion object {

fun success(id: Int?): ResultMessage {
return ResultMessage(
id = id,
result = JsonObject(emptyMap()),
)
}

/**
* Creates a config response with app capabilities.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,13 @@ sealed interface FrontendHandlerEvent {
* The ViewModel should process the [result] to emit appropriate UI feedback.
*/
data class DownloadCompleted(val result: DownloadResult) : FrontendHandlerEvent

/**
* Frontend requested the NFC tag-write flow to be launched.
*
* @param messageId The correlation id from the incoming `tag/write` message; used to respond back
* to the frontend once the flow completes.
* @param tagId Optional pre-filled tag identifier. When null, the user is prompted to enter one.
*/
data class WriteNfcTag(val messageId: Int, val tagId: String?) : FrontendHandlerEvent
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import io.homeassistant.companion.android.frontend.externalbus.incoming.Incoming
import io.homeassistant.companion.android.frontend.externalbus.incoming.OpenAssistMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.OpenAssistSettingsMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.OpenSettingsMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.TagWriteMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.ThemeUpdateMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.UnknownIncomingMessage
import io.homeassistant.companion.android.frontend.externalbus.outgoing.ConfigResult
Expand Down Expand Up @@ -163,6 +164,14 @@ class FrontendMessageHandler @Inject constructor(

is HapticMessage -> FrontendHandlerEvent.PerformHaptic(message.payload)

is TagWriteMessage -> {
Timber.d("Tag write request received with id: ${message.id}")
FrontendHandlerEvent.WriteNfcTag(
messageId = message.id ?: -1,
tagId = message.payload.tag,
Comment thread
TimoPtr marked this conversation as resolved.
)
}

is HandleBlobMessage -> {
Timber.d("handleBlob called with filename=${message.filename}")
val result = downloadManager.handleBlob(data = message.data, filename = message.filename)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,15 @@ sealed interface FrontendEvent {

/** Open a URI externally using the host-provided external link handler. */
data class OpenExternalLink(val uri: Uri) : FrontendEvent

/**
* Launch the NFC tag-write flow.
*
* The host is responsible for launching the corresponding activity contract and forwarding the
* result back to the ViewModel via [FrontendViewModel.onNfcWriteCompleted].
*
* @param messageId Correlation id from the originating `tag/write` request.
* @param tagId Optional pre-filled tag identifier.
*/
data class LaunchNfcWrite(val messageId: Int, val tagId: String?) : FrontendEvent
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency with other events: why not navigate to / open NFC write?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.homeassistant.companion.android.frontend.navigation

import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
Expand All @@ -18,6 +19,7 @@ import io.homeassistant.companion.android.common.data.servers.ServerManager.Comp
import io.homeassistant.companion.android.frontend.FrontendScreen
import io.homeassistant.companion.android.frontend.FrontendViewModel
import io.homeassistant.companion.android.launch.HAStartDestinationRoute
import io.homeassistant.companion.android.nfc.WriteNfcTag
import io.homeassistant.companion.android.settings.SettingsActivity
import io.homeassistant.companion.android.util.getActivity
import io.homeassistant.companion.android.webview.WebViewActivity
Expand Down Expand Up @@ -71,6 +73,10 @@ internal fun NavGraphBuilder.frontendScreen(
composable<FrontendRoute> {
val viewModel: FrontendViewModel = hiltViewModel()

val nfcWriteLauncher = rememberLauncherForActivityResult(WriteNfcTag()) { resultCode ->
viewModel.onNfcWriteCompleted(resultCode)
Comment thread
TimoPtr marked this conversation as resolved.
Outdated
}

FrontendEventHandler(
events = viewModel.events,
onShowSnackbar = onShowSnackbar,
Expand All @@ -86,6 +92,9 @@ internal fun NavGraphBuilder.frontendScreen(
)
},
onOpenExternalLink = onOpenExternalLink,
onLaunchNfcWrite = { messageId, tagId ->
nfcWriteLauncher.launch(WriteNfcTag.Input(tagId = tagId, messageId = messageId))
},
)

FrontendScreen(
Expand Down Expand Up @@ -126,6 +135,7 @@ internal fun FrontendEventHandler(
onNavigateToSettings: (SettingsActivity.Deeplink?) -> Unit,
onNavigateToAssist: (serverId: Int, pipelineId: String?, startListening: Boolean) -> Unit,
onOpenExternalLink: suspend (Uri) -> Unit,
onLaunchNfcWrite: (messageId: Int, tagId: String?) -> Unit,
) {
val resources = LocalResources.current
LaunchedEffect(Unit) {
Expand All @@ -150,6 +160,10 @@ internal fun FrontendEventHandler(
is FrontendEvent.OpenExternalLink -> {
onOpenExternalLink(event.uri)
}

is FrontendEvent.LaunchNfcWrite -> {
onLaunchNfcWrite(event.messageId, event.tagId)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import io.homeassistant.companion.android.common.data.connectivity.ConnectivityC
import io.homeassistant.companion.android.frontend.download.DownloadResult
import io.homeassistant.companion.android.frontend.download.FrontendDownloadManager
import io.homeassistant.companion.android.frontend.error.FrontendConnectionError
import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalBusRepository
import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType
import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage
import io.homeassistant.companion.android.frontend.handler.FrontendBusObserver
import io.homeassistant.companion.android.frontend.handler.FrontendHandlerEvent
import io.homeassistant.companion.android.frontend.js.FrontendJsBridgeFactory
Expand Down Expand Up @@ -38,6 +40,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
Expand All @@ -54,6 +57,7 @@ class FrontendViewModelTest {

private val webViewClientFactory: HAWebViewClientFactory = mockk(relaxed = true)
private val frontendBusObserver: FrontendBusObserver = mockk(relaxed = true)
private val externalBusRepository: FrontendExternalBusRepository = mockk(relaxed = true)
private val urlManager: FrontendUrlManager = mockk(relaxed = true)
private val connectivityCheckRepository: ConnectivityCheckRepository = mockk(relaxed = true)
private val permissionManager: PermissionManager = mockk(relaxed = true)
Expand All @@ -79,6 +83,7 @@ class FrontendViewModelTest {
initialPath = path,
webViewClientFactory = webViewClientFactory,
frontendBusObserver = frontendBusObserver,
externalBusRepository = externalBusRepository,
urlManager = urlManager,
connectivityCheckRepository = connectivityCheckRepository,
permissionManager = permissionManager,
Expand Down Expand Up @@ -568,6 +573,44 @@ class FrontendViewModelTest {
assertTrue(events.any { it is FrontendEvent.NavigateToAssistSettings })
job.cancel()
}

@Test
fun `Given WriteNfcTag handler event when collected then LaunchNfcWrite is emitted`() = runTest {
val messageFlow = MutableSharedFlow<FrontendHandlerEvent>()
every { frontendBusObserver.messageResults() } returns messageFlow
every { urlManager.serverUrlFlow(any(), any()) } returns flowOf(
UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId),
)

val viewModel = createViewModel()

viewModel.events.test {
advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds)

messageFlow.emit(FrontendHandlerEvent.WriteNfcTag(messageId = 42, tagId = "tag-abc"))

assertEquals(FrontendEvent.LaunchNfcWrite(messageId = 42, tagId = "tag-abc"), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}

@Test
fun `Given onNfcWriteCompleted when called then sends empty-result ResultMessage back to frontend`() = runTest {
every { urlManager.serverUrlFlow(any(), any()) } returns flowOf(
UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId),
)

val viewModel = createViewModel()

viewModel.onNfcWriteCompleted(messageId = 42)
advanceUntilIdle()

coVerify {
externalBusRepository.send(
ResultMessage(id = 42, success = true, result = JsonObject(emptyMap())),
)
}
}
}

@Nested
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,30 @@ class IncomingExternalBusMessageTest {
assertEquals("file.pdf", blobMessage.filename)
}

@Test
fun `Given tag-write JSON with tag then parses to TagWriteMessage with tag`() {
val json = """{"type":"tag/write","id":11,"payload":{"tag":"abc-123"}}"""

val message = frontendExternalBusJson.decodeFromString<IncomingExternalBusMessage>(json)

assertInstanceOf(TagWriteMessage::class.java, message)
val tagMessage = message as TagWriteMessage
assertEquals(11, tagMessage.id)
assertEquals("abc-123", tagMessage.payload.tag)
}

@Test
fun `Given tag-write JSON without payload then parses to TagWriteMessage with null tag`() {
val json = """{"type":"tag/write","id":12}"""

val message = frontendExternalBusJson.decodeFromString<IncomingExternalBusMessage>(json)

assertInstanceOf(TagWriteMessage::class.java, message)
val tagMessage = message as TagWriteMessage
assertEquals(12, tagMessage.id)
assertNull(tagMessage.payload.tag)
}

@Test
fun `Given unknown type JSON then parses to UnknownIncomingMessage`() {
val json = """{"type":"future-feature","id":99,"payload":{"data":"something"}}"""
Expand Down
Loading
Loading