Skip to content

Commit 968b49c

Browse files
TimoPtrCopilotjpelgrom
authored
Migrate TagReaderActivity to use BottomSheet and add confirmation buttons (#6814)
* Support next.home-assistant.io in Debug * Make TagReaderActivity standalone and translucent * Add preference to stored allowed tag and clearing in dev settings * Introduce PainterResourceUtil to load app icon --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joris Pelgröm <jpelgrom@users.noreply.github.com>
1 parent 61fa4ad commit 968b49c

38 files changed

Lines changed: 1364 additions & 122 deletions

File tree

app/src/debug/AndroidManifest.xml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,24 @@
2828
android:theme="@android:style/Theme.Material.Light.NoActionBar"
2929
android:name="io.homeassistant.companion.android.HiltComponentActivity"
3030
/>
31+
32+
<!--
33+
Support for next.home-assistant.io links when developing (merged with non-next links in main manifest).
34+
-->
35+
<activity
36+
android:name=".nfc.TagReaderActivity"
37+
android:exported="true">
38+
<intent-filter android:autoVerify="true">
39+
<action android:name="android.intent.action.VIEW" />
40+
<category android:name="android.intent.category.DEFAULT" />
41+
<category android:name="android.intent.category.BROWSABLE" />
42+
43+
<data
44+
android:scheme="https"
45+
android:host="next.home-assistant.io"
46+
android:pathPrefix="/tag/" />
47+
</intent-filter>
48+
</activity>
3149
</application>
3250

3351
</manifest>

app/src/main/AndroidManifest.xml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,11 @@
476476
<activity
477477
android:name=".nfc.TagReaderActivity"
478478
android:label="@string/tag_reader_title"
479-
android:exported="true">
479+
android:launchMode="singleTask"
480+
android:taskAffinity="io.homeassistant.companion.android.tag.reader"
481+
android:autoRemoveFromRecents="true"
482+
android:exported="true"
483+
android:theme="@style/Theme.HomeAssistant.TranslucentOverlay">
480484
<tools:validation testUrl="https://www.home-assistant.io/tag/123e4567-e89b-12d3-a456-426614174000" />
481485

482486
<intent-filter>
@@ -526,7 +530,7 @@
526530
android:taskAffinity="io.homeassistant.companion.android.assist"
527531
android:autoRemoveFromRecents="true"
528532
android:showWhenLocked="true"
529-
android:theme="@style/Theme.HomeAssistant.Assist">
533+
android:theme="@style/Theme.HomeAssistant.TranslucentOverlay">
530534
<intent-filter>
531535
<action android:name="android.intent.action.ASSIST" />
532536
<category android:name="android.intent.category.DEFAULT" />
Lines changed: 24 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,48 @@
11
package io.homeassistant.companion.android.nfc
22

33
import android.content.Intent
4-
import android.net.Uri
54
import android.nfc.NfcAdapter
65
import android.os.Bundle
7-
import android.widget.Toast
86
import androidx.activity.compose.setContent
9-
import androidx.lifecycle.lifecycleScope
7+
import androidx.activity.viewModels
8+
import androidx.compose.runtime.getValue
9+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1010
import dagger.hilt.android.AndroidEntryPoint
1111
import io.homeassistant.companion.android.BaseActivity
12-
import io.homeassistant.companion.android.common.R as commonR
13-
import io.homeassistant.companion.android.common.data.servers.ServerManager
14-
import io.homeassistant.companion.android.nfc.views.TagReaderView
15-
import io.homeassistant.companion.android.util.UrlUtil
16-
import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme
17-
import javax.inject.Inject
18-
import kotlinx.coroutines.async
19-
import kotlinx.coroutines.awaitAll
20-
import kotlinx.coroutines.launch
21-
import timber.log.Timber
12+
import io.homeassistant.companion.android.common.compose.theme.HATheme
13+
import io.homeassistant.companion.android.nfc.views.TagReaderScreen
2214

2315
@AndroidEntryPoint
2416
class TagReaderActivity : BaseActivity() {
2517

26-
@Inject
27-
lateinit var serverManager: ServerManager
18+
private val viewModel: TagReaderViewModel by viewModels()
2819

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

23+
val isNfcTag = intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED
24+
val isQrTag = intent.action == Intent.ACTION_VIEW
25+
3226
setContent {
33-
HomeAssistantAppTheme {
34-
TagReaderView()
27+
HATheme {
28+
val state by viewModel.uiState.collectAsStateWithLifecycle()
29+
30+
TagReaderScreen(
31+
state = state,
32+
onAllowOnce = viewModel::onAllowOnce,
33+
onAllowAlways = viewModel::onAllowAlways,
34+
onDismissed = viewModel::onDismissed,
35+
onErrorAcknowledged = viewModel::onErrorAcknowledged,
36+
onFinished = ::finish,
37+
)
3538
}
3639
}
3740

38-
lifecycleScope.launch {
39-
if (intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED || intent.action == Intent.ACTION_VIEW) {
40-
val isNfcTag = intent.action == NfcAdapter.ACTION_NDEF_DISCOVERED
41-
42-
val url =
43-
if (isNfcTag) {
44-
NFCUtil.extractUrlFromNFCIntent(intent)
45-
} else {
46-
intent.data
47-
}
48-
try {
49-
handleTag(url, isNfcTag)
50-
} catch (e: Exception) {
51-
showProcessingError(isNfcTag)
52-
Timber.e(e, "Unable to handle url (${if (isNfcTag) "nfc" else "qr"}}): $url")
53-
}
54-
}
55-
finish()
56-
}
57-
}
58-
59-
private suspend fun handleTag(url: Uri?, isNfcTag: Boolean) {
60-
// https://www.home-assistant.io/tag/5f0ba733-172f-430d-a7f8-e4ad940c88d7
61-
62-
val nfcTagId = UrlUtil.splitNfcTagId(url)
63-
Timber.d("Tag ID: $nfcTagId")
64-
if (nfcTagId != null && serverManager.isRegistered()) {
65-
serverManager.servers().map {
66-
lifecycleScope.async {
67-
try {
68-
serverManager.integrationRepository(it.id).scanTag(hashMapOf("tag_id" to nfcTagId))
69-
Timber.d("Tag scanned to HA successfully")
70-
} catch (e: Exception) {
71-
Timber.e(e, "Tag not scanned to HA")
72-
}
73-
}
74-
}.awaitAll()
41+
if (isNfcTag || isQrTag) {
42+
val url = if (isNfcTag) NFCUtil.extractUrlFromNFCIntent(intent) else intent.data
43+
viewModel.onIntentReceived(url = url, isNfcTag = isNfcTag)
7544
} else {
76-
showProcessingError(isNfcTag)
45+
finish()
7746
}
7847
}
79-
80-
private fun showProcessingError(isNfcTag: Boolean) {
81-
Toast.makeText(
82-
this,
83-
if (isNfcTag) commonR.string.nfc_processing_tag_error else commonR.string.qrcode_processing_tag_error,
84-
Toast.LENGTH_LONG,
85-
).show()
86-
}
8748
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package io.homeassistant.companion.android.nfc
2+
3+
import androidx.annotation.StringRes
4+
5+
/**
6+
* UI states surfaced by [TagReaderViewModel] and consumed by the tag reader screen.
7+
*/
8+
sealed interface TagReaderUiState {
9+
/** Initial state. No UI is rendered. */
10+
data object Initial : TagReaderUiState
11+
12+
/**
13+
* The scanned tag id need approving from the user before any
14+
* server action is taken.
15+
*/
16+
data class ApprovingTag(val tagId: String) : TagReaderUiState
17+
18+
/**
19+
* The tag is being scanned to all registered servers.
20+
*/
21+
data object Scanning : TagReaderUiState
22+
23+
/**
24+
* A user-facing error occurred.
25+
*/
26+
data class Error(@StringRes val messageRes: Int) : TagReaderUiState
27+
28+
/** Terminal state: the activity should call `finish()`. */
29+
data object Done : TagReaderUiState
30+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package io.homeassistant.companion.android.nfc
2+
3+
import android.net.Uri
4+
import androidx.annotation.VisibleForTesting
5+
import androidx.lifecycle.ViewModel
6+
import androidx.lifecycle.viewModelScope
7+
import dagger.hilt.android.lifecycle.HiltViewModel
8+
import io.homeassistant.companion.android.common.R as commonR
9+
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
10+
import io.homeassistant.companion.android.common.data.servers.ServerManager
11+
import io.homeassistant.companion.android.util.UrlUtil
12+
import javax.inject.Inject
13+
import kotlin.coroutines.cancellation.CancellationException
14+
import kotlin.time.Duration.Companion.seconds
15+
import kotlinx.coroutines.async
16+
import kotlinx.coroutines.awaitAll
17+
import kotlinx.coroutines.coroutineScope
18+
import kotlinx.coroutines.delay
19+
import kotlinx.coroutines.flow.MutableStateFlow
20+
import kotlinx.coroutines.flow.StateFlow
21+
import kotlinx.coroutines.flow.asStateFlow
22+
import kotlinx.coroutines.launch
23+
import timber.log.Timber
24+
25+
/**
26+
* Lower bound on how long the [TagReaderUiState.Scanning] state stays visible. Servers may
27+
* answer faster than the user can perceive the loading sheet, so we keep it on screen for
28+
* at least this long before transitioning to [TagReaderUiState.Done].
29+
*/
30+
@VisibleForTesting
31+
internal val MIN_SCANNING_DURATION = 1.5.seconds
32+
33+
/**
34+
* Drives the NFC / QR tag reader flow.
35+
*
36+
* Reads the tag id from the incoming URL, decides whether it can be auto-scanned
37+
* based on whether it is already present in the allowed tags preference, or requires
38+
* manual user approval when it is not yet allowed, and exposes the result as [uiState].
39+
*/
40+
@HiltViewModel
41+
class TagReaderViewModel @Inject constructor(
42+
private val serverManager: ServerManager,
43+
private val prefsRepository: PrefsRepository,
44+
) : ViewModel() {
45+
46+
private val _uiState = MutableStateFlow<TagReaderUiState>(TagReaderUiState.Initial)
47+
val uiState: StateFlow<TagReaderUiState> = _uiState.asStateFlow()
48+
49+
/**
50+
* Called by the activity once the launching intent has been parsed. Drives
51+
* a single tag-handling cycle and updates [uiState] accordingly.
52+
*/
53+
fun onIntentReceived(url: Uri?, isNfcTag: Boolean) {
54+
viewModelScope.launch {
55+
val tagId = UrlUtil.splitNfcTagId(url)
56+
if (tagId == null) {
57+
Timber.w("Tag intent had no tag id, isNfcTag=$isNfcTag")
58+
_uiState.value = TagReaderUiState.Error(errorMessageRes(isNfcTag))
59+
return@launch
60+
}
61+
if (!serverManager.isRegistered()) {
62+
Timber.w("Tag scanned but no server is registered")
63+
_uiState.value = TagReaderUiState.Error(errorMessageRes(isNfcTag))
64+
return@launch
65+
}
66+
if (prefsRepository.getAllowedTags().contains(tagId)) {
67+
scanAndFinish(tagId)
68+
} else {
69+
_uiState.value = TagReaderUiState.ApprovingTag(tagId)
70+
}
71+
}
72+
}
73+
74+
/**
75+
* Called when the user taps "Allow Once" in the approval bottom sheet. Transitions
76+
* the sheet into its scanning state and dispatches the tag to all registered servers.
77+
*
78+
* Has no effect unless the current state is [TagReaderUiState.ApprovingTag].
79+
*/
80+
fun onAllowOnce() {
81+
val current = _uiState.value as? TagReaderUiState.ApprovingTag ?: return
82+
viewModelScope.launch {
83+
scanAndFinish(current.tagId)
84+
}
85+
}
86+
87+
/**
88+
* Called when the user taps "Allow always" in the approval bottom sheet. Persists
89+
* the tag id so future scans of the same tag skip the
90+
* approval step, then proceeds with the scan in the same way as [onAllowOnce].
91+
*
92+
* Has no effect unless the current state is [TagReaderUiState.ApprovingTag].
93+
*/
94+
fun onAllowAlways() {
95+
val current = _uiState.value as? TagReaderUiState.ApprovingTag ?: return
96+
viewModelScope.launch {
97+
prefsRepository.addAllowedTag(current.tagId)
98+
scanAndFinish(current.tagId)
99+
}
100+
}
101+
102+
/**
103+
* Called once the user-facing error has been dismissed. Transitions to [TagReaderUiState.Done]
104+
* so the activity can finish.
105+
*/
106+
fun onErrorAcknowledged() {
107+
if (_uiState.value is TagReaderUiState.Error) {
108+
_uiState.value = TagReaderUiState.Done
109+
}
110+
}
111+
112+
/**
113+
* Called when the user dismisses the approval.
114+
* Transitions to [TagReaderUiState.Done] so the activity can finish.
115+
*/
116+
fun onDismissed() {
117+
_uiState.value = TagReaderUiState.Done
118+
}
119+
120+
private suspend fun scanAndFinish(tagId: String) {
121+
_uiState.value = TagReaderUiState.Scanning
122+
coroutineScope {
123+
launch { delay(MIN_SCANNING_DURATION) }
124+
launch { scanTag(tagId) }
125+
}
126+
_uiState.value = TagReaderUiState.Done
127+
}
128+
129+
private suspend fun scanTag(tagId: String) {
130+
coroutineScope {
131+
serverManager.servers().map { server ->
132+
async {
133+
try {
134+
serverManager.integrationRepository(server.id)
135+
.scanTag(mapOf("tag_id" to tagId))
136+
Timber.d("Tag scanned to HA serverId=${server.id}")
137+
} catch (e: CancellationException) {
138+
throw e
139+
} catch (e: Exception) {
140+
Timber.e(e, "Tag not scanned to HA serverId=${server.id}")
141+
}
142+
}
143+
}.awaitAll()
144+
}
145+
}
146+
147+
private fun errorMessageRes(isNfcTag: Boolean): Int = if (isNfcTag) {
148+
commonR.string.nfc_processing_tag_error
149+
} else {
150+
commonR.string.qrcode_processing_tag_error
151+
}
152+
}

0 commit comments

Comments
 (0)