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
15 changes: 15 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:name="io.homeassistant.companion.android.HiltComponentActivity"
/>

<activity
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.

Suggested change
<activity
<!--
Support for next.home-assistant.io links when developing (merged with non-next links in main manifest).
-->
<activity

android:name=".nfc.TagReaderActivity"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:scheme="https"
android:host="next.home-assistant.io"
android:pathPrefix="/tag/" />
</intent-filter>
</activity>
</application>

</manifest>
9 changes: 7 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,12 @@
<activity
android:name=".nfc.TagReaderActivity"
android:label="@string/tag_reader_title"
android:exported="true">
android:launchMode="singleTask"
android:taskAffinity="io.homeassistant.companion.android.tag.reader"
android:autoRemoveFromRecents="true"
android:showWhenLocked="true"
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.

This might be nice for some use cases but also removes the security of having to unlock the device to actually scan a tag. Now anyone with your device can trigger tag automations.

android:exported="true"
android:theme="@style/Theme.HomeAssistant.TranslucentOverlay">
<tools:validation testUrl="https://www.home-assistant.io/tag/123e4567-e89b-12d3-a456-426614174000" />

<intent-filter>
Expand Down Expand Up @@ -519,7 +524,7 @@
android:taskAffinity="io.homeassistant.companion.android.assist"
android:autoRemoveFromRecents="true"
android:showWhenLocked="true"
android:theme="@style/Theme.HomeAssistant.Assist">
android:theme="@style/Theme.HomeAssistant.TranslucentOverlay">
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,87 +1,48 @@
package io.homeassistant.companion.android.nfc

import android.content.Intent
import android.net.Uri
import android.nfc.NfcAdapter
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.lifecycle.lifecycleScope
import androidx.activity.viewModels
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.BaseActivity
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.nfc.views.TagReaderView
import io.homeassistant.companion.android.util.UrlUtil
import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme
import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import timber.log.Timber
import io.homeassistant.companion.android.common.compose.theme.HATheme
import io.homeassistant.companion.android.nfc.views.TagReaderScreen

@AndroidEntryPoint
class TagReaderActivity : BaseActivity() {

@Inject
lateinit var serverManager: ServerManager
private val viewModel: TagReaderViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val isNfcTag = intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED
val isQrTag = intent.action == Intent.ACTION_VIEW

setContent {
HomeAssistantAppTheme {
TagReaderView()
HATheme {
val state by viewModel.uiState.collectAsStateWithLifecycle()

TagReaderScreen(
state = state,
onAllowOnce = viewModel::onAllowOnce,
onAllowAlways = viewModel::onAllowAlways,
onDismissed = viewModel::onDismissed,
onErrorAcknowledged = viewModel::onErrorAcknowledged,
onFinished = ::finish,
)
}
}

lifecycleScope.launch {
if (intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED || intent.action == Intent.ACTION_VIEW) {
val isNfcTag = intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED

val url =
if (isNfcTag) {
NFCUtil.extractUrlFromNFCIntent(intent)
} else {
intent.data
}
try {
handleTag(url, isNfcTag)
} catch (e: Exception) {
showProcessingError(isNfcTag)
Timber.e(e, "Unable to handle url (${if (isNfcTag) "nfc" else "qr"}}): $url")
}
}
finish()
}
}

private suspend fun handleTag(url: Uri?, isNfcTag: Boolean) {
// https://www.home-assistant.io/tag/5f0ba733-172f-430d-a7f8-e4ad940c88d7

val nfcTagId = UrlUtil.splitNfcTagId(url)
Timber.d("Tag ID: $nfcTagId")
if (nfcTagId != null && serverManager.isRegistered()) {
serverManager.servers().map {
lifecycleScope.async {
try {
serverManager.integrationRepository(it.id).scanTag(hashMapOf("tag_id" to nfcTagId))
Timber.d("Tag scanned to HA successfully")
} catch (e: Exception) {
Timber.e(e, "Tag not scanned to HA")
}
}
}.awaitAll()
if (isNfcTag || isQrTag) {
val url = if (isNfcTag) NFCUtil.extractUrlFromNFCIntent(intent) else intent.data
viewModel.onIntentReceived(url = url, isNfcTag = isNfcTag)
} else {
showProcessingError(isNfcTag)
finish()
}
Comment on lines 20 to 46
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.

I'm not sure we need that, I didn't have any issue during my tests testing multiples.

}

private fun showProcessingError(isNfcTag: Boolean) {
Toast.makeText(
this,
if (isNfcTag) commonR.string.nfc_processing_tag_error else commonR.string.qrcode_processing_tag_error,
Toast.LENGTH_LONG,
).show()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.homeassistant.companion.android.nfc

import androidx.annotation.StringRes

/**
* UI states surfaced by [TagReaderViewModel] and consumed by the tag reader screen.
*/
sealed interface TagReaderUiState {
/** Initial state. No UI is rendered. */
data object Initial : TagReaderUiState

/**
* The scanned tag id need approving from the user before any
* server action is taken.
*/
data class ApprovingTag(val tagId: String) : TagReaderUiState

/**
* The tag is being scanned to all registered servers.
*/
data object Scanning : TagReaderUiState

/**
* A user-facing error occurred.
*/
data class Error(@StringRes val messageRes: Int) : TagReaderUiState

/** Terminal state: the activity should call `finish()`. */
data object Done : TagReaderUiState
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package io.homeassistant.companion.android.nfc

import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.util.UrlUtil
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber

/**
* Lower bound on how long the [TagReaderUiState.Scanning] state stays visible. Servers may
* answer faster than the user can perceive the loading sheet, so we keep it on screen for
* at least this long before transitioning to [TagReaderUiState.Done].
*/
@VisibleForTesting
internal val MIN_SCANNING_DURATION = 1.5.seconds

/**
* Drives the NFC / QR tag reader flow.
*
* Reads the tag id from the incoming URL, decides whether it can be auto-scanned
* based on whether it is already present in the allowed tags preference, or requires
* manual user approval when it is not yet allowed, and exposes the result as [uiState].
*/
@HiltViewModel
class TagReaderViewModel @Inject constructor(
private val serverManager: ServerManager,
private val prefsRepository: PrefsRepository,
) : ViewModel() {

private val _uiState = MutableStateFlow<TagReaderUiState>(TagReaderUiState.Initial)
val uiState: StateFlow<TagReaderUiState> = _uiState.asStateFlow()

/**
* Called by the activity once the launching intent has been parsed. Drives
* a single tag-handling cycle and updates [uiState] accordingly.
*/
fun onIntentReceived(url: Uri?, isNfcTag: Boolean) {
viewModelScope.launch {
val tagId = UrlUtil.splitNfcTagId(url)
if (tagId == null) {
Timber.w("Tag intent had no tag id, isNfcTag=$isNfcTag")
_uiState.value = TagReaderUiState.Error(errorMessageRes(isNfcTag))
return@launch
}
if (!serverManager.isRegistered()) {
Timber.w("Tag scanned but no server is registered")
_uiState.value = TagReaderUiState.Error(errorMessageRes(isNfcTag))
return@launch
}
if (prefsRepository.allowedTags().contains(tagId)) {
scanAndFinish(tagId)
} else {
_uiState.value = TagReaderUiState.ApprovingTag(tagId)
}
}
}

/**
* Called when the user taps "Allow Once" in the approval bottom sheet. Transitions
* the sheet into its scanning state and dispatches the tag to all registered servers.
*
* Has no effect unless the current state is [TagReaderUiState.ApprovingTag].
*/
fun onAllowOnce() {
val current = _uiState.value as? TagReaderUiState.ApprovingTag ?: return
viewModelScope.launch {
scanAndFinish(current.tagId)
}
}

/**
* Called when the user taps "Allow always" in the approval bottom sheet. Persists
* the tag id so future scans of the same tag skip the
* approval step, then proceeds with the scan in the same way as [onAllowOnce].
*
* Has no effect unless the current state is [TagReaderUiState.ApprovingTag].
*/
fun onAllowAlways() {
val current = _uiState.value as? TagReaderUiState.ApprovingTag ?: return
viewModelScope.launch {
prefsRepository.addAllowedTag(current.tagId)
scanAndFinish(current.tagId)
}
}

/**
* Called once the user-facing error has been dismissed. Transitions to [TagReaderUiState.Done]
* so the activity can finish.
*/
fun onErrorAcknowledged() {
if (_uiState.value is TagReaderUiState.Error) {
_uiState.value = TagReaderUiState.Done
}
}

/**
* Called when the user dismisses the approval.
* Transitions to [TagReaderUiState.Done] so the activity can finish.
*/
fun onDismissed() {
_uiState.value = TagReaderUiState.Done
}

private suspend fun scanAndFinish(tagId: String) {
_uiState.value = TagReaderUiState.Scanning
coroutineScope {
launch { delay(MIN_SCANNING_DURATION) }
launch { scanTag(tagId) }
}
_uiState.value = TagReaderUiState.Done
}

private suspend fun scanTag(tagId: String) {
coroutineScope {
serverManager.servers().map { server ->
async {
try {
serverManager.integrationRepository(server.id)
.scanTag(mapOf("tag_id" to tagId))
Timber.d("Tag scanned to HA serverId=${server.id}")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "Tag not scanned to HA serverId=${server.id}")
}
}
}.awaitAll()
}
}

private fun errorMessageRes(isNfcTag: Boolean): Int = if (isNfcTag) {
commonR.string.nfc_processing_tag_error
} else {
commonR.string.qrcode_processing_tag_error
}
}
Loading
Loading