-
-
Notifications
You must be signed in to change notification settings - Fork 956
Migrate TagReaderActivity to use BottomSheet and add confirmation buttons #6814
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
53ae307
6d0e8b8
6c549e2
47f2977
3af21fb
8c072b3
a493bd6
c16b0e0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
|
|
@@ -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" /> | ||
|
|
||
| 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.