Skip to content

Commit

Permalink
Add QR code scanner for frontend (#4303)
Browse files Browse the repository at this point in the history
* 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
jpelgrom committed May 3, 2024
1 parent 8185d6e commit 674f5d4
Show file tree
Hide file tree
Showing 13 changed files with 629 additions and 4 deletions.
6 changes: 6 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ android {
}

compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility(libs.versions.javaVersion.get())
targetCompatibility(libs.versions.javaVersion.get())
}
Expand Down Expand Up @@ -125,6 +126,8 @@ android {
dependencies {
implementation(project(":common"))

coreLibraryDesugaring(libs.tools.desugar.jdk)

implementation(libs.blurView)

implementation(libs.kotlin.stdlib)
Expand Down Expand Up @@ -179,6 +182,7 @@ dependencies {
implementation(libs.compose.uiTooling)
implementation(libs.activity.compose)
implementation(libs.navigation.compose)
implementation(libs.androidx.lifecycle.runtime.compose)

implementation(libs.iconics.core)
implementation(libs.iconics.compose)
Expand All @@ -189,6 +193,8 @@ dependencies {
implementation(libs.reorderable)
implementation(libs.changeLog)

implementation(libs.zxing)

implementation(libs.car.core)
"fullImplementation"(libs.car.projected)
}
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,11 @@
</intent-filter>
</activity>

<activity
android:name=".barcode.BarcodeScannerActivity"
android:exported="false"
android:theme="@style/Theme.HomeAssistant.Config" />

<activity android:name=".assist.AssistActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
Expand Down
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 }
}
}
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
}
}
}
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")
)
)
)
}
}
}
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)

0 comments on commit 674f5d4

Please sign in to comment.