Skip to content
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

Add QR code scanner for frontend #4303

Merged
merged 12 commits into from
May 3, 2024
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)
Loading