From a99abc466557f98e8c8d8fd65d96970f4ade8b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Tue, 19 Mar 2024 23:35:19 +0100 Subject: [PATCH 01/10] Basic barcode scanner functionality --- app/build.gradle.kts | 5 ++ app/src/main/AndroidManifest.xml | 5 ++ .../android/barcode/BarcodeScannerActivity.kt | 57 ++++++++++++ .../barcode/BarcodeScannerViewModel.kt | 29 ++++++ .../barcode/view/BarcodeScannerView.kt | 88 +++++++++++++++++++ .../android/util/ContextExtensions.kt | 8 ++ .../android/webview/WebViewActivity.kt | 4 +- app/src/main/res/values/styles.xml | 6 ++ gradle/libs.versions.toml | 2 + 9 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt create mode 100644 app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt create mode 100644 app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b0c8806cb2e..d23218ae57c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,6 +52,7 @@ android { } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility(libs.versions.javaVersion.get()) targetCompatibility(libs.versions.javaVersion.get()) } @@ -125,6 +126,8 @@ android { dependencies { implementation(project(":common")) + coreLibraryDesugaring(libs.tools.desugar.jdk) + implementation(libs.blurView) implementation(libs.kotlin.stdlib) @@ -189,6 +192,8 @@ dependencies { implementation(libs.reorderable) implementation(libs.changeLog) + implementation(libs.zxing) + implementation(libs.car.core) "fullImplementation"(libs.car.projected) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 81bee8d3cb3..3a6e8525add 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -393,6 +393,11 @@ + + Unit, + onResult: (String) -> Unit, + onCancel: () -> Unit +) { + val context = LocalContext.current + val barcodeView = remember { + val style = R.style.HomeAssistantBarcodeScanner + DecoratedBarcodeView(ContextThemeWrapper(context, style)).apply { + val activity = context.getActivity() ?: return@apply + statusView.isVisible = false // Hide default UI + + val captureManager = CaptureManager(activity, this) + captureManager.initializeFromIntent(null, null) + captureManager.decode() + decodeContinuous { result -> + result.text.ifBlank { null }?.let { + onResult(it) + } + } + } + } + + Box { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { barcodeView } + ) + if (hasPermission) { + DisposableEffect("barcodeView") { + barcodeView.resume() + onDispose { + barcodeView.pause() + } + } + } + // TODO https://stackoverflow.com/a/73533699/4214819 + + Column(modifier = Modifier.safeDrawingPadding()) { + IconButton(onClick = onCancel) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(commonR.string.cancel), + tint = Color.White + ) + } + Row { + Button(onClick = requestPermission) { + Text(text = "Grant camera permission") + } + Button(onClick = requestPermission) { + Text(text = "Toggle flashlight") + } + } + } + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/util/ContextExtensions.kt b/app/src/main/java/io/homeassistant/companion/android/util/ContextExtensions.kt index 0a410179228..58313803d24 100644 --- a/app/src/main/java/io/homeassistant/companion/android/util/ContextExtensions.kt +++ b/app/src/main/java/io/homeassistant/companion/android/util/ContextExtensions.kt @@ -1,10 +1,12 @@ package io.homeassistant.companion.android.util import android.content.Context +import android.content.ContextWrapper import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build import android.util.TypedValue +import androidx.activity.ComponentActivity import androidx.annotation.ColorRes import androidx.core.content.ContextCompat import androidx.core.content.getSystemService @@ -32,3 +34,9 @@ fun Context.hasActiveConnection(): Boolean { cm.activeNetworkInfo?.isConnected == true } } + +fun Context.getActivity(): ComponentActivity? = when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null +} diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt index 1969cc93e32..3842b2b64f8 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -77,6 +77,7 @@ import io.homeassistant.companion.android.BuildConfig import io.homeassistant.companion.android.R import io.homeassistant.companion.android.assist.AssistActivity import io.homeassistant.companion.android.authenticator.Authenticator +import io.homeassistant.companion.android.barcode.BarcodeScannerActivity import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.HomeAssistantApis import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository @@ -776,8 +777,9 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi ) } "config_screen/show" -> + // TODO restore startActivity( - SettingsActivity.newInstance(this@WebViewActivity) + BarcodeScannerActivity.newInstance(this@WebViewActivity) ) "tag/write" -> writeNfcTag.launch( diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index ea7ff3bb369..82425b0d0d0 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -136,4 +136,10 @@ @drawable/ic_baseline_volume_up_24 @string/mute_unmute + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c1a46ee049..f0d77d8b765 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ webkit = "1.10.0" wear-remote-interactions = "1.0.0" workRuntimeKtx = "2.9.0" horologist = "0.5.25" +zxing = "4.3.0" [plugins] android-application = { id = "com.android.application", version.ref = "androidPlugin" } @@ -171,6 +172,7 @@ wear-remote-interactions = { module = "androidx.wear:wear-remote-interactions", wear-tiles = { module = "androidx.wear.tiles:tiles", version.ref = "wear-tiles" } wear-tooling = { module = "androidx.wear:wear-tooling-preview", version.ref = "wear-tooling" } webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } +zxing = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxing" } [bundles] horologist = ["horologist-layout", "horologist-composables"] From 4a07604b033247ab7e40a0eb468c767d79bb6641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sun, 24 Mar 2024 21:25:45 +0100 Subject: [PATCH 02/10] 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 --- .../barcode/view/BarcodeScannerOverlay.kt | 66 ++++++++++++++++ .../barcode/view/BarcodeScannerView.kt | 76 +++++++++++++++---- 2 files changed, 128 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerOverlay.kt diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerOverlay.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerOverlay.kt new file mode 100644 index 00000000000..9211b8f4ef8 --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerOverlay.kt @@ -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 an viewfinder for the barcode 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(0x77000000) diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt index cb887de8d7e..abed6efb65d 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt @@ -1,11 +1,19 @@ package io.homeassistant.companion.android.barcode.view import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.material.Button import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -14,11 +22,17 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.isVisible import com.journeyapps.barcodescanner.CaptureManager @@ -51,6 +65,7 @@ fun BarcodeScannerView( } } } + var flashlightOn by remember { mutableStateOf(false) } Box { AndroidView( @@ -64,23 +79,56 @@ fun BarcodeScannerView( barcodeView.pause() } } + // TODO https://stackoverflow.com/a/66549433/4214819 } - // TODO https://stackoverflow.com/a/73533699/4214819 + Spacer( + modifier = Modifier + .background(barcodeScannerOverlayColor) + .fillMaxWidth() + .windowInsetsTopHeight(WindowInsets.systemBars) + ) + Spacer( + modifier = Modifier + .background(barcodeScannerOverlayColor) + .fillMaxWidth() + .windowInsetsBottomHeight(WindowInsets.systemBars) + .align(Alignment.BottomStart) + ) - Column(modifier = Modifier.safeDrawingPadding()) { - IconButton(onClick = onCancel) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(commonR.string.cancel), - tint = Color.White - ) - } - Row { - Button(onClick = requestPermission) { - Text(text = "Grant camera permission") + Box( + modifier = Modifier + .safeDrawingPadding() + .fillMaxSize() + ) { + val configuration = LocalConfiguration.current + val systemBarsPadding = WindowInsets.systemBars.asPaddingValues() + val screenHeight = configuration.screenHeightDp.dp - systemBarsPadding.calculateTopPadding() - systemBarsPadding.calculateBottomPadding() + val screenWidth = configuration.screenWidthDp.dp + val cutoutSize = minOf(minOf(screenHeight, screenWidth) - 48.dp, 320.dp) + + BarcodeScannerOverlay(modifier = Modifier.fillMaxSize(), cutout = cutoutSize) + Column { + IconButton(onClick = onCancel) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(commonR.string.cancel), + tint = Color.White + ) } - Button(onClick = requestPermission) { - Text(text = "Toggle flashlight") + Row { + Button(onClick = requestPermission) { + Text(text = "Grant camera permission") + } + Button(onClick = { + if (flashlightOn) { + barcodeView.setTorchOff() + } else { + barcodeView.setTorchOn() + } + flashlightOn = !flashlightOn + }) { + Text(text = "Toggle flashlight") + } } } } From 1e781a28e7717bf148b0fcc04c3a93e10330cde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sun, 24 Mar 2024 22:44:14 +0100 Subject: [PATCH 03/10] 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 --- app/build.gradle.kts | 1 + .../android/barcode/BarcodeScannerActivity.kt | 34 ++++++----- .../barcode/view/BarcodeScannerOverlay.kt | 4 +- .../barcode/view/BarcodeScannerView.kt | 58 ++++++++++++++----- app/src/main/res/values/styles.xml | 6 -- gradle/libs.versions.toml | 1 + 6 files changed, 69 insertions(+), 35 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d23218ae57c..e49a33200a0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -182,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) diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt index acb41fa29d8..0ac26173a1e 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt @@ -13,6 +13,7 @@ import androidx.activity.viewModels import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.BaseActivity import io.homeassistant.companion.android.barcode.view.BarcodeScannerView +import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme @AndroidEntryPoint class BarcodeScannerActivity : BaseActivity() { @@ -38,20 +39,25 @@ class BarcodeScannerActivity : BaseActivity() { super.onCreate(savedInstanceState) setContent { - BarcodeScannerView( - hasPermission = viewModel.hasPermission, - requestPermission = { - cameraPermission.launch(Manifest.permission.CAMERA) - }, - onResult = { - // TODO return to WebViewActivity - Log.d(TAG, "Decoded $it") - Toast.makeText(this, it, Toast.LENGTH_SHORT).show() - }, - onCancel = { - finish() - } - ) + HomeAssistantAppTheme { + BarcodeScannerView( + title = "Scan QR code", + subtitle = "Find the code on your device", + action = "Enter code manually", + hasPermission = viewModel.hasPermission, + requestPermission = { + cameraPermission.launch(Manifest.permission.CAMERA) + }, + onResult = { + // TODO return to WebViewActivity + Log.d(TAG, "Decoded $it") + Toast.makeText(this, it, Toast.LENGTH_SHORT).show() + }, + onCancel = { forAction -> + finish() + } + ) + } } } } diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerOverlay.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerOverlay.kt index 9211b8f4ef8..30e3d6904d5 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerOverlay.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerOverlay.kt @@ -15,7 +15,7 @@ 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 an viewfinder for the barcode scanner's camera. + * 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 @@ -63,4 +63,4 @@ fun BarcodeScannerOverlay( } } -val barcodeScannerOverlayColor = Color(0x77000000) +val barcodeScannerOverlayColor = Color(0xAA000000) diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt index abed6efb65d..78d8eb158a1 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt @@ -1,6 +1,5 @@ package io.homeassistant.companion.android.barcode.view -import androidx.appcompat.view.ContextThemeWrapper import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,6 +9,8 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight @@ -17,11 +18,12 @@ import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.material.Button import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,25 +37,29 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.isVisible +import androidx.lifecycle.compose.LifecycleResumeEffect import com.journeyapps.barcodescanner.CaptureManager import com.journeyapps.barcodescanner.DecoratedBarcodeView -import io.homeassistant.companion.android.R import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.util.getActivity @Composable fun BarcodeScannerView( + title: String, + subtitle: String, + action: String?, hasPermission: Boolean, requestPermission: () -> Unit, onResult: (String) -> Unit, - onCancel: () -> Unit + onCancel: (Boolean) -> Unit ) { val context = LocalContext.current val barcodeView = remember { - val style = R.style.HomeAssistantBarcodeScanner - DecoratedBarcodeView(ContextThemeWrapper(context, style)).apply { + DecoratedBarcodeView(context).apply { val activity = context.getActivity() ?: return@apply - statusView.isVisible = false // Hide default UI + // Hide default UI + viewFinder.isVisible = false + statusView.isVisible = false val captureManager = CaptureManager(activity, this) captureManager.initializeFromIntent(null, null) @@ -67,29 +73,31 @@ fun BarcodeScannerView( } var flashlightOn by remember { mutableStateOf(false) } + // Main screen structure: + // - Starting with composables that should go edge to edge (background) + // - Box with overlay fitting inside insets for main contents/controls Box { AndroidView( modifier = Modifier.fillMaxSize(), factory = { barcodeView } ) if (hasPermission) { - DisposableEffect("barcodeView") { + LifecycleResumeEffect("barcodeView") { barcodeView.resume() - onDispose { + onPauseOrDispose { barcodeView.pause() } } - // TODO https://stackoverflow.com/a/66549433/4214819 } Spacer( modifier = Modifier - .background(barcodeScannerOverlayColor) + .background(barcodeScannerOverlayColor) // matching overlay .fillMaxWidth() .windowInsetsTopHeight(WindowInsets.systemBars) ) Spacer( modifier = Modifier - .background(barcodeScannerOverlayColor) + .background(barcodeScannerOverlayColor) // matching overlay .fillMaxWidth() .windowInsetsBottomHeight(WindowInsets.systemBars) .align(Alignment.BottomStart) @@ -108,7 +116,7 @@ fun BarcodeScannerView( BarcodeScannerOverlay(modifier = Modifier.fillMaxSize(), cutout = cutoutSize) Column { - IconButton(onClick = onCancel) { + IconButton(onClick = { onCancel(false) }) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(commonR.string.cancel), @@ -130,6 +138,30 @@ fun BarcodeScannerView( Text(text = "Toggle flashlight") } } + Column( + modifier = Modifier + .fillMaxWidth(if (screenWidth > screenHeight) 0.5f else 1f) + .padding(horizontal = 16.dp) + .padding(top = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = title, + color = Color.White, + style = MaterialTheme.typography.h6 + ) + Text( + text = subtitle, + color = Color.White, + style = MaterialTheme.typography.subtitle1 + ) + action?.let { + Spacer(Modifier.height(32.dp)) + TextButton(onClick = { onCancel(true) }) { + Text(it) + } + } + } } } } diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 82425b0d0d0..ea7ff3bb369 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -136,10 +136,4 @@ @drawable/ic_baseline_volume_up_24 @string/mute_unmute - - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f0d77d8b765..be30bfe0c63 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -94,6 +94,7 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-watchface-complications-data-source-ktx = { module = "androidx.wear.watchface:watchface-complications-data-source-ktx", version.ref = "watchfaceComplicationsDataSourceKtx" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } From bfe256c756d6d3fa706882d8ccd0450aea23aff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Wed, 27 Mar 2024 23:40:57 +0100 Subject: [PATCH 04/10] 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 --- .../android/barcode/BarcodeScannerActivity.kt | 66 ++++++-- .../barcode/BarcodeScannerViewModel.kt | 5 + .../barcode/view/BarcodeScannerView.kt | 149 +++++++++++++----- .../android/webview/WebViewActivity.kt | 2 +- common/src/main/res/values/strings.xml | 2 + 5 files changed, 172 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt index 0ac26173a1e..4e1ca273685 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt @@ -3,16 +3,24 @@ 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 android.util.Log import android.widget.Toast +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.toArgb 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.util.compose.HomeAssistantAppTheme @AndroidEntryPoint @@ -21,9 +29,20 @@ class BarcodeScannerActivity : BaseActivity() { companion object { private const val TAG = "BarcodeScannerActivity" - fun newInstance(context: Context): Intent { + private const val EXTRA_TITLE = "title" + private const val EXTRA_SUBTITLE = "subtitle" + private const val EXTRA_ACTION = "action" + + fun newInstance( + context: Context, + title: String, + subtitle: String, + action: String? + ): Intent { return Intent(context, BarcodeScannerActivity::class.java).apply { - // TODO extras? + putExtra(EXTRA_TITLE, title) + putExtra(EXTRA_SUBTITLE, subtitle) + putExtra(EXTRA_ACTION, action) } } } @@ -32,32 +51,59 @@ class BarcodeScannerActivity : BaseActivity() { private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { viewModel.checkPermission() + requestSilently = false } + private var requestSilently by mutableStateOf(true) + override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() + val overlaySystemBarStyle = SystemBarStyle.dark(barcodeScannerOverlayColor.toArgb()) + enableEdgeToEdge(overlaySystemBarStyle, overlaySystemBarStyle) super.onCreate(savedInstanceState) + 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 = "Scan QR code", - subtitle = "Find the code on your device", - action = "Enter code manually", + title = title!!, + subtitle = subtitle!!, + action = action, + hasFlashlight = viewModel.hasFlashlight, hasPermission = viewModel.hasPermission, - requestPermission = { - cameraPermission.launch(Manifest.permission.CAMERA) - }, + requestPermission = { requestPermission(false) }, + didRequestPermission = !requestSilently, onResult = { - // TODO return to WebViewActivity + // TODO return to WebViewActivity / external bus Log.d(TAG, "Decoded $it") Toast.makeText(this, it, Toast.LENGTH_SHORT).show() }, onCancel = { forAction -> + // TODO return to WebViewActivity / external bus 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 + } + } } diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt index 70ebda1da61..53de89fe680 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt @@ -19,8 +19,13 @@ class BarcodeScannerViewModel @Inject constructor( var hasPermission by mutableStateOf(false) private set + var hasFlashlight by mutableStateOf(false) + private set + init { checkPermission() + + hasFlashlight = app.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_FLASH) } fun checkPermission() { diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt index 78d8eb158a1..083a43bc985 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt @@ -1,46 +1,60 @@ package io.homeassistant.companion.android.barcode.view -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.layout.windowInsetsTopHeight -import androidx.compose.material.Button +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarResult import androidx.compose.material.Text import androidx.compose.material.TextButton +import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FlashlightOff +import androidx.compose.material.icons.filled.FlashlightOn +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.isVisible import androidx.lifecycle.compose.LifecycleResumeEffect import com.journeyapps.barcodescanner.CaptureManager import com.journeyapps.barcodescanner.DecoratedBarcodeView import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.util.compose.darkColorBackground import io.homeassistant.companion.android.util.getActivity @Composable @@ -48,7 +62,9 @@ fun BarcodeScannerView( title: String, subtitle: String, action: String?, + hasFlashlight: Boolean, hasPermission: Boolean, + didRequestPermission: Boolean, requestPermission: () -> Unit, onResult: (String) -> Unit, onCancel: (Boolean) -> Unit @@ -71,7 +87,6 @@ fun BarcodeScannerView( } } } - var flashlightOn by remember { mutableStateOf(false) } // Main screen structure: // - Starting with composables that should go edge to edge (background) @@ -89,21 +104,8 @@ fun BarcodeScannerView( } } } - Spacer( - modifier = Modifier - .background(barcodeScannerOverlayColor) // matching overlay - .fillMaxWidth() - .windowInsetsTopHeight(WindowInsets.systemBars) - ) - Spacer( - modifier = Modifier - .background(barcodeScannerOverlayColor) // matching overlay - .fillMaxWidth() - .windowInsetsBottomHeight(WindowInsets.systemBars) - .align(Alignment.BottomStart) - ) - Box( + BoxWithConstraints( modifier = Modifier .safeDrawingPadding() .fillMaxSize() @@ -114,33 +116,46 @@ fun BarcodeScannerView( val screenWidth = configuration.screenWidthDp.dp val cutoutSize = minOf(minOf(screenHeight, screenWidth) - 48.dp, 320.dp) - BarcodeScannerOverlay(modifier = Modifier.fillMaxSize(), cutout = cutoutSize) - Column { - IconButton(onClick = { onCancel(false) }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(commonR.string.cancel), - tint = Color.White - ) - } - Row { - Button(onClick = requestPermission) { - Text(text = "Grant camera permission") - } - Button(onClick = { - if (flashlightOn) { - barcodeView.setTorchOff() - } else { - barcodeView.setTorchOn() + val scaffoldState = rememberScaffoldState() + if (!hasPermission && didRequestPermission) { + LaunchedEffect("permission") { + scaffoldState.snackbarHostState.showSnackbar( + context.getString(commonR.string.missing_camera_permission), + context.getString(commonR.string.settings), + SnackbarDuration.Indefinite + ).let { result -> + if (result == SnackbarResult.ActionPerformed) { + requestPermission() } - flashlightOn = !flashlightOn - }) { - Text(text = "Toggle flashlight") } } + } + + BarcodeScannerOverlay(modifier = Modifier.fillMaxSize(), cutout = cutoutSize) + Scaffold( + scaffoldState = scaffoldState, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = { onCancel(false) }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(commonR.string.cancel), + tint = Color.White + ) + } + }, + backgroundColor = Color.Transparent, + elevation = 0.dp + ) + }, + backgroundColor = Color.Transparent + ) { paddingValues -> Column( modifier = Modifier .fillMaxWidth(if (screenWidth > screenHeight) 0.5f else 1f) + .padding(paddingValues) .padding(horizontal = 16.dp) .padding(top = 32.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -163,6 +178,58 @@ fun BarcodeScannerView( } } } + if (hasFlashlight && hasPermission) { + // Align to bottom right of cutout - button size - margin + // Note in landscape that the cutout is pushed to the right half of the screen + val offsetX = if (screenWidth > screenHeight) { + screenWidth - (0.5 * ((0.5 * screenWidth) - cutoutSize)) - 48.dp - 8.dp + } else { + screenWidth - (0.5 * (screenWidth - cutoutSize)) - 48.dp - 8.dp + } + val offsetY = with(LocalDensity.current) { + (0.5 * constraints.maxHeight).toInt().toDp() + } + (0.5 * cutoutSize) - 48.dp - 8.dp + FlashlightButton( + onToggle = { turnOn -> + if (turnOn) { + barcodeView.setTorchOn() + } else { + barcodeView.setTorchOff() + } + }, + modifier = Modifier.offset(offsetX, offsetY) + ) + } } } } + +/** + * A 48x48dp dark round button/mini FAB to toggle the flashlight on the device on and off. + * The button size fits with the overlay radius. + */ +@Composable +fun FlashlightButton( + onToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + var flashlightOn by rememberSaveable { mutableStateOf(false) } + OutlinedButton( + modifier = modifier.size(48.dp), + elevation = ButtonDefaults.elevation(), + shape = CircleShape, + border = null, + contentPadding = PaddingValues(0.dp), + colors = ButtonDefaults.outlinedButtonColors(backgroundColor = darkColorBackground), + onClick = { + onToggle(!flashlightOn) + flashlightOn = !flashlightOn + } + ) { + Icon( + imageVector = if (flashlightOn) Icons.Default.FlashlightOff else Icons.Default.FlashlightOn, + contentDescription = stringResource(commonR.string.toggle_flashlight), + tint = Color.White + ) + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt index 3842b2b64f8..c5084f78fe8 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -779,7 +779,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi "config_screen/show" -> // TODO restore startActivity( - BarcodeScannerActivity.newInstance(this@WebViewActivity) + BarcodeScannerActivity.newInstance(this@WebViewActivity, "Scan QR code", "Find the code on your device", "Enter code manually") ) "tag/write" -> writeNfcTag.launch( diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index a083e967d50..f0747219cc7 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -933,6 +933,7 @@ Tile data updated Tiles %1$s was selected + Toggle flashlight Remotely control app & device Manage enabled sensors and use notification commands from this server Unable to process notification \"%1$s\" as text to speech. @@ -1081,6 +1082,7 @@ You can change how often this sensor is updated by using the \'%1$s\' setting. For more information about location updates, please check the documentation. This sensor updates on a non-standard schedule, please check the documentation. + \'Camera\' permission required Please grant Phone permission to make a phone call Please grant Nearby devices permission to control Bluetooth Notification channels allow you to control the look and feel of a certain type of notification. You can create notification channels by sending a notification with the channel parameter, refer to the help icon in the upper right hand corner to learn more. Below is a list of all notification channels created by your server and the app. Select the edit icon to adjust channel settings like ringtone and Do Not Disturb override. Select the delete icon to remove the channel from this list.\n\nDeleting a channel does not reset channel settings, if a notification is received with the same channel name then it will re-use its previous settings. You can only delete channels created by your server. From 6987f33e6fe2fa462d47b7abe5ffeb3e8a3e3bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Wed, 27 Mar 2024 23:58:28 +0100 Subject: [PATCH 05/10] Add dependency to automotive as we can't exclude features --- automotive/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/automotive/build.gradle.kts b/automotive/build.gradle.kts index c5eb09cf61d..5ab22f2caaf 100644 --- a/automotive/build.gradle.kts +++ b/automotive/build.gradle.kts @@ -218,6 +218,8 @@ dependencies { implementation(libs.reorderable) implementation(libs.changeLog) + implementation(libs.zxing) + implementation(libs.car.core) implementation(libs.car.automotive) } From 99455c04d66330773d4f2568a29f1cd61a7fc7b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Thu, 28 Mar 2024 00:00:37 +0100 Subject: [PATCH 06/10] More automotive dependencies --- automotive/build.gradle.kts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/automotive/build.gradle.kts b/automotive/build.gradle.kts index 5ab22f2caaf..78a6e1fdec1 100644 --- a/automotive/build.gradle.kts +++ b/automotive/build.gradle.kts @@ -87,6 +87,7 @@ android { } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility(libs.versions.javaVersion.get()) targetCompatibility(libs.versions.javaVersion.get()) } @@ -154,6 +155,8 @@ android { dependencies { implementation(project(":common")) + coreLibraryDesugaring(libs.tools.desugar.jdk) + implementation(libs.blurView) implementation(libs.kotlin.stdlib) @@ -208,6 +211,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) From 9ef00c8fbadb0263520948e17f14030c9d6e4e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Thu, 18 Apr 2024 20:02:16 +0200 Subject: [PATCH 07/10] Return information about barcode format --- .../android/barcode/BarcodeScannerActivity.kt | 16 +++++++++++++--- .../android/barcode/view/BarcodeScannerView.kt | 5 +++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt index 4e1ca273685..683d2d5a55b 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt @@ -17,11 +17,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.toArgb +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.util.compose.HomeAssistantAppTheme +import java.util.Locale @AndroidEntryPoint class BarcodeScannerActivity : BaseActivity() { @@ -76,10 +78,18 @@ class BarcodeScannerActivity : BaseActivity() { hasPermission = viewModel.hasPermission, requestPermission = { requestPermission(false) }, didRequestPermission = !requestSilently, - onResult = { + onResult = { text, format -> // TODO return to WebViewActivity / external bus - Log.d(TAG, "Decoded $it") - Toast.makeText(this, it, Toast.LENGTH_SHORT).show() + 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()) + } + Log.d(TAG, "Decoded $text ($format)") + Toast.makeText(this, text, Toast.LENGTH_SHORT).show() }, onCancel = { forAction -> // TODO return to WebViewActivity / external bus diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt index 083a43bc985..a18fbf9823a 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.unit.times import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.isVisible import androidx.lifecycle.compose.LifecycleResumeEffect +import com.google.zxing.BarcodeFormat import com.journeyapps.barcodescanner.CaptureManager import com.journeyapps.barcodescanner.DecoratedBarcodeView import io.homeassistant.companion.android.common.R as commonR @@ -66,7 +67,7 @@ fun BarcodeScannerView( hasPermission: Boolean, didRequestPermission: Boolean, requestPermission: () -> Unit, - onResult: (String) -> Unit, + onResult: (String, BarcodeFormat) -> Unit, onCancel: (Boolean) -> Unit ) { val context = LocalContext.current @@ -82,7 +83,7 @@ fun BarcodeScannerView( captureManager.decode() decodeContinuous { result -> result.text.ifBlank { null }?.let { - onResult(it) + onResult(it, result.barcodeFormat) } } } From b1a812a830bebcc160cca0c114244bbe003bc5a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Tue, 30 Apr 2024 18:21:35 +0200 Subject: [PATCH 08/10] Implement external bus for scanner --- .../android/barcode/BarcodeScannerAction.kt | 15 +++++ .../android/barcode/BarcodeScannerActivity.kt | 36 +++++++++-- .../barcode/BarcodeScannerViewModel.kt | 63 ++++++++++++++++++- .../barcode/view/BarcodeScannerView.kt | 8 ++- .../android/webview/WebViewActivity.kt | 20 +++++- .../webview/externalbus/ExternalBusMessage.kt | 1 + 6 files changed, 132 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerAction.kt diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerAction.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerAction.kt new file mode 100644 index 00000000000..f3162d02954 --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerAction.kt @@ -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 } + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt index 683d2d5a55b..cf673a084dc 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt @@ -6,24 +6,28 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.provider.Settings -import android.util.Log -import android.widget.Toast import androidx.activity.SystemBarStyle 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() { @@ -31,17 +35,20 @@ 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) @@ -63,6 +70,8 @@ class BarcodeScannerActivity : BaseActivity() { 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 @@ -79,7 +88,6 @@ class BarcodeScannerActivity : BaseActivity() { requestPermission = { requestPermission(false) }, didRequestPermission = !requestSilently, onResult = { text, format -> - // TODO return to WebViewActivity / external bus val frontendFormat = when (format) { BarcodeFormat.PDF_417 -> "pdf417" BarcodeFormat.MAXICODE, @@ -88,16 +96,32 @@ class BarcodeScannerActivity : BaseActivity() { BarcodeFormat.UPC_EAN_EXTENSION -> "unknown" else -> format.toString().lowercase(Locale.getDefault()) } - Log.d(TAG, "Decoded $text ($format)") - Toast.makeText(this, text, Toast.LENGTH_SHORT).show() + viewModel.sendScannerResult(messageId, text, frontendFormat) }, onCancel = { forAction -> - // TODO return to WebViewActivity / external bus + viewModel.sendScannerClosing(messageId, forAction) 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() { diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt index 53de89fe680..78c59f1d629 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt @@ -3,32 +3,93 @@ 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() + 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.tryEmit( + BarcodeScannerAction(type, message.getString("message")) + ) + BarcodeActionType.CLOSE -> frontendActionsFlow.tryEmit( + 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 = "bar_code/result", + success = true, + payload = mapOf( + "rawValue" to text, + "format" to format + ) + ) + ) + } + } + + fun sendScannerClosing(messageId: Int, forAction: Boolean) { + viewModelScope.launch { + externalBusRepository.send( + ExternalBusMessage( + id = messageId, + type = "bar_code/aborted", + success = true, + payload = mapOf( + "reason" to (if (forAction) "alternative_options" else "canceled") + ) + ) + ) + } + } } diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt index a18fbf9823a..6379b1d2e07 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/view/BarcodeScannerView.kt @@ -68,10 +68,14 @@ fun BarcodeScannerView( didRequestPermission: Boolean, requestPermission: () -> Unit, onResult: (String, BarcodeFormat) -> Unit, - onCancel: (Boolean) -> Unit + onCancel: (Boolean) -> Unit, + resultTimeoutMillis: Long = 1500L ) { val context = LocalContext.current val barcodeView = remember { + // The app remembers last time scanned to prevent spamming the frontend + var resultScanned = System.currentTimeMillis() + DecoratedBarcodeView(context).apply { val activity = context.getActivity() ?: return@apply // Hide default UI @@ -82,7 +86,9 @@ fun BarcodeScannerView( captureManager.initializeFromIntent(null, null) captureManager.decode() decodeContinuous { result -> + if ((System.currentTimeMillis() - resultScanned) < resultTimeoutMillis) return@decodeContinuous result.text.ifBlank { null }?.let { + resultScanned = System.currentTimeMillis() onResult(it, result.barcodeFormat) } } diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt index c5084f78fe8..38468ec9611 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -743,7 +743,8 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi "hasExoPlayer" to true, "canCommissionMatter" to canCommissionMatter, "canImportThreadCredentials" to canExportThread, - "hasAssist" to true + "hasAssist" to true, + "hasBarCodeScanner" to 1 ) ), callback = { @@ -777,9 +778,8 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi ) } "config_screen/show" -> - // TODO restore startActivity( - BarcodeScannerActivity.newInstance(this@WebViewActivity, "Scan QR code", "Find the code on your device", "Enter code manually") + SettingsActivity.newInstance(this@WebViewActivity) ) "tag/write" -> writeNfcTag.launch( @@ -797,6 +797,19 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi .create() alertDialog?.show() } + "bar_code/scan" -> { + val payload = if (json.has("payload")) json.getJSONObject("payload") else null + if (payload?.has("title") != true || !payload.has("description")) return@post + startActivity( + BarcodeScannerActivity.newInstance( + this@WebViewActivity, + messageId = json.getInt("id"), + title = payload.getString("title"), + subtitle = payload.getString("description"), + action = if (payload.has("alternative_option_label")) payload.getString("alternative_option_label").ifBlank { null } else null + ) + ) + } "exoplayer/play_hls" -> exoPlayHls(json) "exoplayer/stop" -> exoStopHls() "exoplayer/resize" -> exoResizeHls(json) @@ -1510,6 +1523,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi ) message.result?.let { map["result"] = it } message.error?.let { map["error"] = it } + message.payload?.let { map["payload"] = it } val json = JSONObject(map.toMap()) val script = "externalBus($json);" diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/externalbus/ExternalBusMessage.kt b/app/src/main/java/io/homeassistant/companion/android/webview/externalbus/ExternalBusMessage.kt index 741715b3e9b..76a4dd1465c 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/externalbus/ExternalBusMessage.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/externalbus/ExternalBusMessage.kt @@ -8,5 +8,6 @@ data class ExternalBusMessage( val success: Boolean, val result: Any? = null, val error: Any? = null, + val payload: Any? = null, val callback: ValueCallback? = null ) From e3738e6ddda81c63bab42e86e3dffc0170353027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Wed, 1 May 2024 11:40:38 +0200 Subject: [PATCH 09/10] 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 --- .../android/barcode/BarcodeScannerActivity.kt | 6 ++++++ .../android/barcode/BarcodeScannerViewModel.kt | 14 +++++++------- .../companion/android/webview/WebViewActivity.kt | 5 +++-- .../webview/externalbus/ExternalBusMessage.kt | 3 ++- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt index cf673a084dc..bc7ea43aadb 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerActivity.kt @@ -7,6 +7,7 @@ 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 @@ -106,6 +107,11 @@ class BarcodeScannerActivity : BaseActivity() { } } + onBackPressedDispatcher.addCallback(this) { + viewModel.sendScannerClosing(messageId, false) + finish() + } + lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.actionsFlow.collect { diff --git a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt index 78c59f1d629..04dbab40b2b 100644 --- a/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/barcode/BarcodeScannerViewModel.kt @@ -46,10 +46,10 @@ class BarcodeScannerViewModel @Inject constructor( listOf(BarcodeActionType.NOTIFY.externalBusType, BarcodeActionType.CLOSE.externalBusType) ).collect { message -> when (val type = BarcodeActionType.fromExternalBus(message.getString("type"))) { - BarcodeActionType.NOTIFY -> frontendActionsFlow.tryEmit( - BarcodeScannerAction(type, message.getString("message")) + BarcodeActionType.NOTIFY -> frontendActionsFlow.emit( + BarcodeScannerAction(type, message.getJSONObject("payload").getString("message")) ) - BarcodeActionType.CLOSE -> frontendActionsFlow.tryEmit( + BarcodeActionType.CLOSE -> frontendActionsFlow.emit( BarcodeScannerAction(type) ) else -> Log.w(TAG, "Received unexpected external bus message of type ${type?.name}") @@ -67,8 +67,8 @@ class BarcodeScannerViewModel @Inject constructor( externalBusRepository.send( ExternalBusMessage( id = messageId, - type = "bar_code/result", - success = true, + type = "command", + command = "bar_code/scan_result", payload = mapOf( "rawValue" to text, "format" to format @@ -83,8 +83,8 @@ class BarcodeScannerViewModel @Inject constructor( externalBusRepository.send( ExternalBusMessage( id = messageId, - type = "bar_code/aborted", - success = true, + type = "command", + command = "bar_code/aborted", payload = mapOf( "reason" to (if (forAction) "alternative_options" else "canceled") ) diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt index 38468ec9611..fbf176bc23a 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -1518,9 +1518,10 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi override fun sendExternalBusMessage(message: ExternalBusMessage) { val map = mutableMapOf( "id" to message.id, - "type" to message.type, - "success" to message.success + "type" to message.type ) + message.command?.let { map["command"] = it } + message.success?.let { map["success"] = it } message.result?.let { map["result"] = it } message.error?.let { map["error"] = it } message.payload?.let { map["payload"] = it } diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/externalbus/ExternalBusMessage.kt b/app/src/main/java/io/homeassistant/companion/android/webview/externalbus/ExternalBusMessage.kt index 76a4dd1465c..cbbfd89593f 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/externalbus/ExternalBusMessage.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/externalbus/ExternalBusMessage.kt @@ -5,7 +5,8 @@ import android.webkit.ValueCallback data class ExternalBusMessage( val id: Any, val type: String, - val success: Boolean, + val command: String? = null, + val success: Boolean? = null, val result: Any? = null, val error: Any? = null, val payload: Any? = null, From 15ca46dc00484b7f61414b5e42c5b5861cb548c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Wed, 1 May 2024 12:26:09 +0200 Subject: [PATCH 10/10] Improve feature availability check - Make sure the device actually has a camera and is not automotive --- .../companion/android/webview/WebViewActivity.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt index fbf176bc23a..a35aeb9e6ff 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -731,6 +731,15 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi val hasNfc = pm.hasSystemFeature(PackageManager.FEATURE_NFC) val canCommissionMatter = presenter.appCanCommissionMatterDevice() val canExportThread = presenter.appCanExportThreadCredentials() + val hasBarCodeScanner = + if ( + pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) && + !pm.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE) + ) { + 1 + } else { + 0 + } sendExternalBusMessage( ExternalBusMessage( id = JSONObject(message).get("id"), @@ -744,7 +753,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi "canCommissionMatter" to canCommissionMatter, "canImportThreadCredentials" to canExportThread, "hasAssist" to true, - "hasBarCodeScanner" to 1 + "hasBarCodeScanner" to hasBarCodeScanner ) ), callback = {