-
-
Notifications
You must be signed in to change notification settings - Fork 674
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add QR code scanner for frontend (#4303)
* Basic barcode scanner functionality * Add overlay with cutout and toggle flashlight - Add a overlay with cutout in the middle / on the side, matching design and Google's barcode scanner - Working button for toggling the flashlight on and off * Fix background camera use, complete more UI - Fix camera remaining active when the activity is paused (for example, by going to another app) - Set the app's theme - Add more UI parts: title, subtitle, optional action button * Complete scanner UI - Request permission when launched, and add snackbar when permission is denied - Add flashlight button and position it to line up with the frame - Make title/subtitle/action dynamic - Fix double scrim for system bars on older API levels * Add dependency to automotive as we can't exclude features * More automotive dependencies * Return information about barcode format * Implement external bus for scanner * Fix external bus type - The type for external bus messages should be command, with the bar_code/* in the key command - Send aborted when closing the scanner using back * Improve feature availability check - Make sure the device actually has a camera and is not automotive
- Loading branch information
Showing
13 changed files
with
629 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
15 changes: 15 additions & 0 deletions
15
app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerAction.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package io.homeassistant.companion.android.barcode | ||
|
||
data class BarcodeScannerAction( | ||
val type: BarcodeActionType, | ||
val message: String? = null | ||
) | ||
|
||
enum class BarcodeActionType(val externalBusType: String) { | ||
NOTIFY("bar_code/notify"), | ||
CLOSE("bar_code/close"); | ||
|
||
companion object { | ||
fun fromExternalBus(type: String) = entries.firstOrNull { it.externalBusType == type } | ||
} | ||
} |
149 changes: 149 additions & 0 deletions
149
app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
package io.homeassistant.companion.android.barcode | ||
|
||
import android.Manifest | ||
import android.content.Context | ||
import android.content.Intent | ||
import android.net.Uri | ||
import android.os.Bundle | ||
import android.provider.Settings | ||
import androidx.activity.SystemBarStyle | ||
import androidx.activity.addCallback | ||
import androidx.activity.compose.setContent | ||
import androidx.activity.enableEdgeToEdge | ||
import androidx.activity.result.contract.ActivityResultContracts | ||
import androidx.activity.viewModels | ||
import androidx.appcompat.app.AlertDialog | ||
import androidx.compose.runtime.getValue | ||
import androidx.compose.runtime.mutableStateOf | ||
import androidx.compose.runtime.setValue | ||
import androidx.compose.ui.graphics.toArgb | ||
import androidx.lifecycle.Lifecycle | ||
import androidx.lifecycle.lifecycleScope | ||
import androidx.lifecycle.repeatOnLifecycle | ||
import com.google.zxing.BarcodeFormat | ||
import dagger.hilt.android.AndroidEntryPoint | ||
import io.homeassistant.companion.android.BaseActivity | ||
import io.homeassistant.companion.android.barcode.view.BarcodeScannerView | ||
import io.homeassistant.companion.android.barcode.view.barcodeScannerOverlayColor | ||
import io.homeassistant.companion.android.common.R as commonR | ||
import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme | ||
import java.util.Locale | ||
import kotlinx.coroutines.launch | ||
|
||
@AndroidEntryPoint | ||
class BarcodeScannerActivity : BaseActivity() { | ||
|
||
companion object { | ||
private const val TAG = "BarcodeScannerActivity" | ||
|
||
private const val EXTRA_MESSAGE_ID = "message_id" | ||
private const val EXTRA_TITLE = "title" | ||
private const val EXTRA_SUBTITLE = "subtitle" | ||
private const val EXTRA_ACTION = "action" | ||
|
||
fun newInstance( | ||
context: Context, | ||
messageId: Int, | ||
title: String, | ||
subtitle: String, | ||
action: String? | ||
): Intent { | ||
return Intent(context, BarcodeScannerActivity::class.java).apply { | ||
putExtra(EXTRA_MESSAGE_ID, messageId) | ||
putExtra(EXTRA_TITLE, title) | ||
putExtra(EXTRA_SUBTITLE, subtitle) | ||
putExtra(EXTRA_ACTION, action) | ||
} | ||
} | ||
} | ||
|
||
private val viewModel: BarcodeScannerViewModel by viewModels() | ||
|
||
private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { | ||
viewModel.checkPermission() | ||
requestSilently = false | ||
} | ||
|
||
private var requestSilently by mutableStateOf(true) | ||
|
||
override fun onCreate(savedInstanceState: Bundle?) { | ||
val overlaySystemBarStyle = SystemBarStyle.dark(barcodeScannerOverlayColor.toArgb()) | ||
enableEdgeToEdge(overlaySystemBarStyle, overlaySystemBarStyle) | ||
super.onCreate(savedInstanceState) | ||
|
||
val messageId = intent.getIntExtra(EXTRA_MESSAGE_ID, -1) | ||
|
||
val title = if (intent.hasExtra(EXTRA_TITLE)) intent.getStringExtra(EXTRA_TITLE) else null | ||
val subtitle = if (intent.hasExtra(EXTRA_SUBTITLE)) intent.getStringExtra(EXTRA_SUBTITLE) else null | ||
if (title == null || subtitle == null) finish() // Invalid state | ||
val action = if (intent.hasExtra(EXTRA_ACTION)) intent.getStringExtra(EXTRA_ACTION) else null | ||
|
||
setContent { | ||
HomeAssistantAppTheme { | ||
BarcodeScannerView( | ||
title = title!!, | ||
subtitle = subtitle!!, | ||
action = action, | ||
hasFlashlight = viewModel.hasFlashlight, | ||
hasPermission = viewModel.hasPermission, | ||
requestPermission = { requestPermission(false) }, | ||
didRequestPermission = !requestSilently, | ||
onResult = { text, format -> | ||
val frontendFormat = when (format) { | ||
BarcodeFormat.PDF_417 -> "pdf417" | ||
BarcodeFormat.MAXICODE, | ||
BarcodeFormat.RSS_14, | ||
BarcodeFormat.RSS_EXPANDED, | ||
BarcodeFormat.UPC_EAN_EXTENSION -> "unknown" | ||
else -> format.toString().lowercase(Locale.getDefault()) | ||
} | ||
viewModel.sendScannerResult(messageId, text, frontendFormat) | ||
}, | ||
onCancel = { forAction -> | ||
viewModel.sendScannerClosing(messageId, forAction) | ||
finish() | ||
} | ||
) | ||
} | ||
} | ||
|
||
onBackPressedDispatcher.addCallback(this) { | ||
viewModel.sendScannerClosing(messageId, false) | ||
finish() | ||
} | ||
|
||
lifecycleScope.launch { | ||
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { | ||
viewModel.actionsFlow.collect { | ||
when (it.type) { | ||
BarcodeActionType.NOTIFY -> { | ||
if (it.message.isNullOrBlank()) return@collect | ||
AlertDialog.Builder(this@BarcodeScannerActivity) | ||
.setMessage(it.message) | ||
.setPositiveButton(commonR.string.ok, null) | ||
.show() | ||
} | ||
BarcodeActionType.CLOSE -> finish() | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
override fun onResume() { | ||
super.onResume() | ||
viewModel.checkPermission() | ||
if (!viewModel.hasPermission && requestSilently) { | ||
requestPermission(true) | ||
} | ||
} | ||
|
||
private fun requestPermission(inContext: Boolean) { | ||
if (inContext) { | ||
cameraPermission.launch(Manifest.permission.CAMERA) | ||
} else { | ||
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) | ||
requestSilently = true // Reset state to trigger new in context dialog/check when resumed | ||
} | ||
} | ||
} |
95 changes: 95 additions & 0 deletions
95
app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
package io.homeassistant.companion.android.barcode | ||
|
||
import android.Manifest | ||
import android.app.Application | ||
import android.content.pm.PackageManager | ||
import android.util.Log | ||
import androidx.compose.runtime.getValue | ||
import androidx.compose.runtime.mutableStateOf | ||
import androidx.compose.runtime.setValue | ||
import androidx.core.content.ContextCompat | ||
import androidx.lifecycle.AndroidViewModel | ||
import androidx.lifecycle.viewModelScope | ||
import dagger.hilt.android.lifecycle.HiltViewModel | ||
import io.homeassistant.companion.android.webview.externalbus.ExternalBusMessage | ||
import io.homeassistant.companion.android.webview.externalbus.ExternalBusRepository | ||
import javax.inject.Inject | ||
import kotlinx.coroutines.flow.MutableSharedFlow | ||
import kotlinx.coroutines.flow.asSharedFlow | ||
import kotlinx.coroutines.launch | ||
|
||
@HiltViewModel | ||
class BarcodeScannerViewModel @Inject constructor( | ||
private val externalBusRepository: ExternalBusRepository, | ||
val app: Application | ||
) : AndroidViewModel(app) { | ||
|
||
companion object { | ||
private const val TAG = "BarcodeScannerViewModel" | ||
} | ||
|
||
var hasPermission by mutableStateOf(false) | ||
private set | ||
|
||
var hasFlashlight by mutableStateOf(false) | ||
private set | ||
|
||
private val frontendActionsFlow = MutableSharedFlow<BarcodeScannerAction>() | ||
val actionsFlow = frontendActionsFlow.asSharedFlow() | ||
|
||
init { | ||
checkPermission() | ||
hasFlashlight = app.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_FLASH) | ||
|
||
viewModelScope.launch { | ||
externalBusRepository.receive( | ||
listOf(BarcodeActionType.NOTIFY.externalBusType, BarcodeActionType.CLOSE.externalBusType) | ||
).collect { message -> | ||
when (val type = BarcodeActionType.fromExternalBus(message.getString("type"))) { | ||
BarcodeActionType.NOTIFY -> frontendActionsFlow.emit( | ||
BarcodeScannerAction(type, message.getJSONObject("payload").getString("message")) | ||
) | ||
BarcodeActionType.CLOSE -> frontendActionsFlow.emit( | ||
BarcodeScannerAction(type) | ||
) | ||
else -> Log.w(TAG, "Received unexpected external bus message of type ${type?.name}") | ||
} | ||
} | ||
} | ||
} | ||
|
||
fun checkPermission() { | ||
hasPermission = ContextCompat.checkSelfPermission(app, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED | ||
} | ||
|
||
fun sendScannerResult(messageId: Int, text: String, format: String) { | ||
viewModelScope.launch { | ||
externalBusRepository.send( | ||
ExternalBusMessage( | ||
id = messageId, | ||
type = "command", | ||
command = "bar_code/scan_result", | ||
payload = mapOf( | ||
"rawValue" to text, | ||
"format" to format | ||
) | ||
) | ||
) | ||
} | ||
} | ||
|
||
fun sendScannerClosing(messageId: Int, forAction: Boolean) { | ||
viewModelScope.launch { | ||
externalBusRepository.send( | ||
ExternalBusMessage( | ||
id = messageId, | ||
type = "command", | ||
command = "bar_code/aborted", | ||
payload = mapOf( | ||
"reason" to (if (forAction) "alternative_options" else "canceled") | ||
) | ||
) | ||
) | ||
} | ||
} | ||
} |
66 changes: 66 additions & 0 deletions
66
app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerOverlay.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
package io.homeassistant.companion.android.barcode.view | ||
|
||
import androidx.compose.foundation.Canvas | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.ui.Modifier | ||
import androidx.compose.ui.geometry.CornerRadius | ||
import androidx.compose.ui.geometry.Offset | ||
import androidx.compose.ui.geometry.Size | ||
import androidx.compose.ui.graphics.BlendMode | ||
import androidx.compose.ui.graphics.Color | ||
import androidx.compose.ui.graphics.nativeCanvas | ||
import androidx.compose.ui.platform.LocalDensity | ||
import androidx.compose.ui.unit.Dp | ||
import androidx.compose.ui.unit.dp | ||
|
||
/** | ||
* A semi-transparent overlay with a rounded square cutout in the middle (portrait) or on | ||
* the right half (landscape), to use as a QR code viewfinder for the scanner's camera. | ||
* Based on https://stackoverflow.com/a/73533699/4214819. | ||
*/ | ||
@Composable | ||
fun BarcodeScannerOverlay( | ||
modifier: Modifier, | ||
cutout: Dp | ||
) { | ||
val widthInPx: Float | ||
val heightInPx: Float | ||
val cornerInPx: Float | ||
|
||
with(LocalDensity.current) { | ||
widthInPx = cutout.toPx() | ||
heightInPx = cutout.toPx() | ||
cornerInPx = 28.dp.toPx() // Material 3 extra large rounding | ||
} | ||
|
||
Canvas(modifier = modifier) { | ||
val canvasWidth = size.width | ||
val canvasHeight = size.height | ||
|
||
with(drawContext.canvas.nativeCanvas) { | ||
val checkPoint = saveLayer(null, null) | ||
|
||
// Destination | ||
drawRect(barcodeScannerOverlayColor) | ||
|
||
// Source | ||
drawRoundRect( | ||
topLeft = Offset( | ||
x = if (canvasWidth > canvasHeight) { | ||
(canvasWidth / 2) + (((canvasWidth / 2) - widthInPx) / 2) | ||
} else { | ||
(canvasWidth - widthInPx) / 2 | ||
}, | ||
y = (canvasHeight - heightInPx) / 2 | ||
), | ||
size = Size(widthInPx, heightInPx), | ||
cornerRadius = CornerRadius(cornerInPx, cornerInPx), | ||
color = Color.Transparent, | ||
blendMode = BlendMode.Clear | ||
) | ||
restoreToCount(checkPoint) | ||
} | ||
} | ||
} | ||
|
||
val barcodeScannerOverlayColor = Color(0xAA000000) |
Oops, something went wrong.