Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 @@ -14,6 +14,8 @@ 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.outgoing.ResultMessage
import io.homeassistant.companion.android.frontend.gesture.FrontendGestureHandler
import io.homeassistant.companion.android.frontend.gesture.GestureResult
import io.homeassistant.companion.android.frontend.handler.FrontendBusObserver
Expand Down Expand Up @@ -69,6 +71,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 @@ -83,6 +86,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
savedStateHandle: SavedStateHandle,
webViewClientFactory: HAWebViewClientFactory,
frontendBusObserver: FrontendBusObserver,
externalBusRepository: FrontendExternalBusRepository,
urlManager: FrontendUrlManager,
connectivityCheckRepository: ConnectivityCheckRepository,
permissionManager: PermissionManager,
Expand All @@ -94,6 +98,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 @@ -324,6 +329,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))
}
}

/**
* Handles a swipe gesture detected on the WebView.
*
Expand Down Expand Up @@ -426,6 +449,10 @@ internal class FrontendViewModel @VisibleForTesting constructor(
handleDownloadResult(result.result)
}

is FrontendHandlerEvent.WriteNfcTag -> {
_events.tryEmit(FrontendEvent.NavigateToNfcWrite(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. Once handled, a [io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage.success]
* should be sent back to the frontend with the [id].
*/
@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 @@ -16,6 +16,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 @@ -174,6 +175,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 @@ -44,4 +44,15 @@ sealed interface FrontendEvent {
* for forwarding the user's selection back to the ViewModel via [FrontendViewModel.switchServer].
*/
data object ShowServerSwitcher : FrontendEvent

/**
* Navigate to 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 NavigateToNfcWrite(val messageId: Int, val tagId: String?) : FrontendEvent
}
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 @@ -74,6 +76,10 @@ internal fun NavGraphBuilder.frontendScreen(
composable<FrontendRoute> {
val viewModel: FrontendViewModel = hiltViewModel()

val nfcWriteLauncher = rememberLauncherForActivityResult(WriteNfcTag()) { messageId ->
viewModel.onNfcWriteCompleted(messageId)
}

FrontendEventHandler(
events = viewModel.events,
onShowSnackbar = onShowSnackbar,
Expand All @@ -90,6 +96,9 @@ internal fun NavGraphBuilder.frontendScreen(
},
onOpenExternalLink = onOpenExternalLink,
onShowServerSwitcher = { onShowServerSwitcher(viewModel::switchServer) },
onNavigateToNfcWrite = { messageId, tagId ->
nfcWriteLauncher.launch(WriteNfcTag.Input(tagId = tagId, messageId = messageId))
},
)

FrontendScreen(
Expand Down Expand Up @@ -131,6 +140,7 @@ internal fun FrontendEventHandler(
onNavigateToAssist: (serverId: Int, pipelineId: String?, startListening: Boolean) -> Unit,
onOpenExternalLink: suspend (Uri) -> Unit,
onShowServerSwitcher: () -> Unit,
onNavigateToNfcWrite: (messageId: Int, tagId: String?) -> Unit,
) {
val resources = LocalResources.current
LaunchedEffect(Unit) {
Expand Down Expand Up @@ -163,6 +173,10 @@ internal fun FrontendEventHandler(
is FrontendEvent.ShowServerSwitcher -> {
onShowServerSwitcher()
}

is FrontendEvent.NavigateToNfcWrite -> {
onNavigateToNfcWrite(event.messageId, event.tagId)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ class NfcSetupActivity : BaseActivity() {
}

companion object {
const val EXTRA_TAG_VALUE = "tag_value"
const val EXTRA_MESSAGE_ID = "message_id"
private const val EXTRA_TAG_VALUE = "tag_value"
private const val EXTRA_MESSAGE_ID = "message_id"

const val NAV_WELCOME = "nfc_welcome"
const val NAV_READ = "nfc_read"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ class WriteNfcTag : ActivityResultContract<WriteNfcTag.Input, Int>() {
data class Input(val tagId: String? = null, val messageId: Int = -1)

override fun createIntent(context: Context, input: Input): Intent {
return Intent(context, NfcSetupActivity::class.java).apply {
putExtra(NfcSetupActivity.EXTRA_MESSAGE_ID, input.messageId)
putExtra(NfcSetupActivity.EXTRA_TAG_VALUE, input.tagId)
}
return NfcSetupActivity.newInstance(context, input.tagId, input.messageId)
}

override fun parseResult(resultCode: Int, intent: Intent?): Int {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import io.homeassistant.companion.android.common.util.GestureDirection
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.gesture.FrontendGestureHandler
import io.homeassistant.companion.android.frontend.gesture.GestureResult
import io.homeassistant.companion.android.frontend.handler.FrontendBusObserver
Expand Down Expand Up @@ -41,6 +43,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.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
Expand All @@ -58,6 +61,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 @@ -84,6 +88,7 @@ class FrontendViewModelTest {
initialPath = path,
webViewClientFactory = webViewClientFactory,
frontendBusObserver = frontendBusObserver,
externalBusRepository = externalBusRepository,
urlManager = urlManager,
connectivityCheckRepository = connectivityCheckRepository,
permissionManager = permissionManager,
Expand Down Expand Up @@ -629,6 +634,44 @@ class FrontendViewModelTest {
assertTrue(events.any { it is FrontendEvent.NavigateToAssistSettings })
job.cancel()
}

@Test
fun `Given WriteNfcTag handler event when collected then NavigateToNfcWrite 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.NavigateToNfcWrite(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