diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 0e550688260..b356a73ce34 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,5 +1,5 @@ - + + + + + + + + + + + + + + + + + + + + + @@ -1506,10 +1561,21 @@ errorLine2=" ~~~~~~~~~~~~"> + + + + , + currentIndex: Int?, + onSelected: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val colorScheme = LocalHAColorScheme.current + ExposedDropdownMenuBoxM3( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = modifier, + ) { + TextFieldM3( + readOnly = true, + value = currentIndex?.let { keys[it] } ?: "", + onValueChange = { }, + label = { TextM3(label) }, + trailingIcon = { ExposedDropdownMenuDefaultsM3.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaultsM3.textFieldColors( + unfocusedContainerColor = colorScheme.colorSurfaceDefault, + focusedContainerColor = colorScheme.colorSurfaceDefault, + unfocusedLabelColor = colorScheme.colorTextSecondary, + focusedLabelColor = colorScheme.colorOnPrimaryNormal, + unfocusedTrailingIconColor = colorScheme.colorTextSecondary, + focusedTrailingIconColor = colorScheme.colorOnPrimaryNormal, + unfocusedTextColor = colorScheme.colorTextPrimary, + focusedTextColor = colorScheme.colorTextPrimary, + focusedIndicatorColor = colorScheme.colorOnPrimaryNormal, + unfocusedIndicatorColor = colorScheme.colorBorderNeutralQuiet, + ), + modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorTypeM3.PrimaryNotEditable), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = colorScheme.colorSurfaceDefault + ) { + keys.forEachIndexed { index, key -> + DropdownMenuItemM3( + text = { TextM3(key) }, + onClick = { + onSelected(index) + expanded = false + focusManager.clearFocus() + }, + colors = MenuItemColors( + textColor = colorScheme.colorTextPrimary, + leadingIconColor = colorScheme.colorOnPrimaryNormal, + trailingIconColor = colorScheme.colorOnPrimaryNormal, + disabledTextColor = colorScheme.colorTextSecondary, + disabledLeadingIconColor = colorScheme.colorTextDisabled, + disabledTrailingIconColor = colorScheme.colorTextDisabled, + ) + ) + } + } + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt index d321c420358..bc466ba1fd0 100755 --- a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt @@ -1,39 +1,61 @@ package io.homeassistant.companion.android.widgets.camera -import android.R.layout +import android.annotation.SuppressLint import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle -import android.view.View -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.AutoCompleteTextView -import android.widget.Spinner import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import io.homeassistant.companion.android.common.R as commonR +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint -import io.homeassistant.companion.android.common.R as commonR +import dagger.hilt.android.lifecycle.withCreationCallback +import io.homeassistant.companion.android.BaseActivity +import io.homeassistant.companion.android.common.compose.composable.HAAccentButton +import io.homeassistant.companion.android.common.compose.composable.HATopBar +import io.homeassistant.companion.android.common.compose.theme.HATheme import io.homeassistant.companion.android.common.data.integration.Entity -import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.CAMERA_DOMAIN -import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.IMAGE_DOMAIN -import io.homeassistant.companion.android.database.widget.CameraWidgetDao -import io.homeassistant.companion.android.database.widget.CameraWidgetEntity +import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse +import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse +import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryResponse +import io.homeassistant.companion.android.database.server.Server import io.homeassistant.companion.android.database.widget.WidgetTapAction -import io.homeassistant.companion.android.databinding.WidgetCameraConfigureBinding import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel -import io.homeassistant.companion.android.util.applySafeDrawingInsets -import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity -import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter +import io.homeassistant.companion.android.util.compose.HAExposedDropdownMenu +import io.homeassistant.companion.android.util.enableEdgeToEdgeCompat +import io.homeassistant.companion.android.util.compose.ServerExposedDropdownMenu +import io.homeassistant.companion.android.util.compose.entity.EntityPicker +import io.homeassistant.companion.android.util.safeBottomWindowInsets +import kotlin.collections.emptyList import kotlinx.coroutines.launch -import timber.log.Timber -// TODO Migrate to compose https://github.com/home-assistant/android/issues/6306 @AndroidEntryPoint -class CameraWidgetConfigureActivity : BaseWidgetConfigureActivity() { +class CameraWidgetConfigureActivity : BaseActivity() { companion object { + private const val FOR_ENTITY = "for_entity" + fun newInstance(context: Context, entityId: String): Intent { return Intent(context, CameraWidgetConfigureActivity::class.java).apply { putExtra(FOR_ENTITY, entityId) @@ -43,171 +65,177 @@ class CameraWidgetConfigureActivity : BaseWidgetConfigureActivity>() - private var selectedEntity: Entity? = null - - private var entityAdapter: SingleItemArrayAdapter? = null + private val viewModel: CameraWidgetConfigureViewModel by viewModels( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { factory -> + factory.create(intent.extras?.getString(FOR_ENTITY, null)) + } + }, + ) public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + enableEdgeToEdgeCompat() // Set the result to CANCELED. This will cause the widget host to cancel // out of the widget placement if the user presses the back button. setResult(RESULT_CANCELED) - - binding = WidgetCameraConfigureBinding.inflate(layoutInflater) - setContentView(binding.root) - binding.root.applySafeDrawingInsets() - - binding.addButton.setOnClickListener { - lifecycleScope.launch { - if (requestLauncherSetup) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isValidServerId() && selectedEntity != null) { - requestWidgetCreation() - } else { - showAddWidgetError() - } - } else { - updateWidget() - } - } - } - - // Find the widget id from the intent. - val intent = intent - val extras = intent.extras - if (extras != null) { - if (extras.containsKey(FOR_ENTITY)) { - binding.widgetTextConfigEntityId.setText(extras.getString(FOR_ENTITY)) + val widgetId = intent?.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + viewModel.onSetup(widgetId) + + setContent { + HATheme { + CameraWidgetConfigureScreen( + viewModel = viewModel, + onActionClick = { onActionClick() } + ) } - - appWidgetId = extras.getInt( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID, - ) - requestLauncherSetup = extras.getBoolean( - ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, - false, - ) } + } - // If this activity was started with an intent without an app widget ID, finish with an error. - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID && !requestLauncherSetup) { - finish() - return - } - initTapActionsSpinner() - + @SuppressLint("ObsoleteSdkInt") + private fun onActionClick() { lifecycleScope.launch { - val cameraWidget = dao.get(appWidgetId) - if (cameraWidget != null) { - setCurrentTapAction(tapAction = cameraWidget.tapAction) - binding.widgetTextConfigEntityId.setText(cameraWidget.entityId) - binding.addButton.setText(commonR.string.update_widget) - val entity = try { - serverManager.integrationRepository(cameraWidget.serverId).getEntity(cameraWidget.entityId) - } catch (e: Exception) { - Timber.e(e, "Unable to get entity information") - Toast.makeText(applicationContext, commonR.string.widget_entity_fetch_error, Toast.LENGTH_LONG) - .show() - null - } - - if (entity != null) { - selectedEntity = entity as Entity? + if(intent.extras?.getBoolean(ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, false) ?: false) { + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && viewModel.isValidSelection()) { + requestPinWidget() + } else { + showAddWidgetError() } + } else { + onUpdateWidget() } - - setupServerSelect(cameraWidget?.serverId) } + } - entityAdapter = SingleItemArrayAdapter(this) { it?.entityId ?: "" } - - binding.widgetTextConfigEntityId.setAdapter(entityAdapter) - binding.widgetTextConfigEntityId.onFocusChangeListener = dropDownOnFocus - binding.widgetTextConfigEntityId.onItemClickListener = entityDropDownOnItemClick - + @SuppressLint("ObsoleteSdkInt") + @RequiresApi(Build.VERSION_CODES.O) + private fun requestPinWidget() { + val context = this@CameraWidgetConfigureActivity lifecycleScope.launch { - serverManager.servers().forEach { server -> - launch { - try { - val fetchedEntities = serverManager.integrationRepository(server.id).getEntities().orEmpty() - .filter { it.domain == CAMERA_DOMAIN || it.domain == IMAGE_DOMAIN } - entities[server.id] = fetchedEntities - if (server.id == selectedServerId) setAdapterEntities(server.id) - } catch (e: Exception) { - // If entities fail to load, it's okay to pass - // an empty map to the dynamicFieldAdapter - Timber.e(e, "Failed to query entities") - } - } - } + viewModel.requestWidgetCreation(context) + finish() } } - override fun onServerSelected(serverId: Int) { - selectedEntity = null - binding.widgetTextConfigEntityId.setText("") - setAdapterEntities(serverId) + private suspend fun onUpdateWidget() { + try { + viewModel.updateWidgetConfiguration() + setResult(RESULT_OK) + viewModel.updateWidget(this@CameraWidgetConfigureActivity) + finish() + } catch (_: Exception) { + showUpdateWidgetError() + } } - override suspend fun getPendingDaoEntity(): CameraWidgetEntity { - val serverId = checkNotNull(selectedServerId) { "Selected server ID is null" } - val entityId = checkNotNull(selectedEntity?.entityId) { "Selected entity is null" } + private fun showAddWidgetError() { + Toast.makeText(applicationContext, commonR.string.widget_creation_error, Toast.LENGTH_LONG).show() + } - return CameraWidgetEntity( - id = appWidgetId, - serverId = serverId, - entityId = entityId, - tapAction = if (binding.tapActionList.selectedItemPosition == 0) { - WidgetTapAction.REFRESH - } else { - WidgetTapAction.OPEN - }, - ) + private fun showUpdateWidgetError() { + Toast.makeText(applicationContext, commonR.string.widget_update_error, Toast.LENGTH_LONG).show() } +} - override val widgetClass: Class<*> = CameraWidget::class.java +@Composable +private fun CameraWidgetConfigureScreen(viewModel: CameraWidgetConfigureViewModel, onActionClick: () -> Unit) { + val servers by viewModel.servers.collectAsStateWithLifecycle(emptyList()) + val entities by viewModel.entities.collectAsStateWithLifecycle() + val entityRegistry by viewModel.entityRegistry.collectAsStateWithLifecycle() + val deviceRegistry by viewModel.deviceRegistry.collectAsStateWithLifecycle() + val areaRegistry by viewModel.areaRegistry.collectAsStateWithLifecycle() + + CameraWidgetConfigureView( + servers = servers, + selectedServerId = viewModel.selectedServerId, + onServerSelected = viewModel::setServer, + entities = entities, + selectedEntityId = viewModel.selectedEntityId, + onEntitySelected = { viewModel.selectedEntityId = it }, + isUpdateWidget = viewModel.isUpdateWidget, + onTapSelected = { viewModel.selectedTapAction = WidgetTapAction.entries[it] }, + entityRegistry = entityRegistry, + deviceRegistry = deviceRegistry, + areaRegistry = areaRegistry, + onActionClick = onActionClick, + selectedTapAction = viewModel.selectedTapAction + ) - private fun setAdapterEntities(serverId: Int) { - entityAdapter?.let { adapter -> - adapter.clearAll() - if (entities[serverId] != null) { - adapter.addAll(entities[serverId].orEmpty().toMutableList()) - adapter.sort() - } - runOnUiThread { adapter.notifyDataSetChanged() } - } - } +} - private val dropDownOnFocus = View.OnFocusChangeListener { view, hasFocus -> - if (hasFocus && view is AutoCompleteTextView) { - view.showDropDown() - } - } +@Composable +private fun CameraWidgetConfigureView( + servers: List, + selectedServerId: Int, + onServerSelected: (Int) -> Unit, + entities: List, + selectedEntityId: String?, + onEntitySelected: (String?) -> Unit, + isUpdateWidget: Boolean, + onTapSelected: (Int) -> Unit, + onActionClick: () -> Unit, + entityRegistry: List? = null, + deviceRegistry: List? = null, + areaRegistry: List? = null, + selectedTapAction: WidgetTapAction? = null, +) { + Scaffold( + topBar = { + HATopBar( + title = { Text(stringResource(commonR.string.widget_camera_description)) }, + ) + }, + contentWindowInsets = WindowInsets.safeDrawing + ) {padding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(safeBottomWindowInsets()) + .padding(padding) + .padding(all = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if(servers.size > 1) { + ServerExposedDropdownMenu( + servers = servers, + current = selectedServerId, + onSelected = onServerSelected, + modifier = Modifier.padding(bottom = 16.dp), + ) + } - private val entityDropDownOnItemClick = - AdapterView.OnItemClickListener { parent, _, position, _ -> - selectedEntity = parent.getItemAtPosition(position) as Entity? - } + EntityPicker( + entities = entities, + selectedEntityId = selectedEntityId, + onEntitySelectedId = { onEntitySelected(it) }, + onEntityCleared = { onEntitySelected(null) }, + entityRegistry = entityRegistry, + deviceRegistry = deviceRegistry, + areaRegistry = areaRegistry, + addButtonText = stringResource(commonR.string.add_to_camera_widget), + ) - private fun initTapActionsSpinner() { - val tapActionValues = - listOf(getString(commonR.string.refresh), getString(commonR.string.widget_tap_action_open)) - binding.tapActionList.adapter = ArrayAdapter(this, layout.simple_spinner_dropdown_item, tapActionValues) - } + HAExposedDropdownMenu( + label = stringResource(commonR.string.widget_tap_action_label), + keys = listOf( + stringResource(commonR.string.refresh), + stringResource(commonR.string.widget_tap_action_open) + ), + currentIndex = selectedTapAction?.ordinal ?: 0, + onSelected = { onTapSelected(it) }, + modifier = Modifier.padding(bottom = 16.dp), + ) - private fun setCurrentTapAction(tapAction: WidgetTapAction) { - binding.tapActionList.setSelection(if (tapAction == WidgetTapAction.REFRESH) 0 else 1) + HAAccentButton( + onClick = { onActionClick() }, + text = stringResource(if(isUpdateWidget) commonR.string.update_widget else commonR.string.add_widget), + modifier = Modifier.fillMaxWidth() + ) + } } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureViewModel.kt new file mode 100644 index 00000000000..5956297b651 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureViewModel.kt @@ -0,0 +1,249 @@ +package io.homeassistant.companion.android.widgets.camera + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.CAMERA_DOMAIN +import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.IMAGE_DOMAIN +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse +import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse +import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryResponse +import io.homeassistant.companion.android.database.widget.CameraWidgetDao +import io.homeassistant.companion.android.database.widget.CameraWidgetEntity +import io.homeassistant.companion.android.database.widget.WidgetTapAction +import io.homeassistant.companion.android.widgets.ACTION_APPWIDGET_CREATED +import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + + +@HiltViewModel(assistedFactory = CameraWidgetConfigureViewModel.Factory::class) +class CameraWidgetConfigureViewModel @AssistedInject constructor( + private val cameraWidgetDao: CameraWidgetDao, + private val serverManager: ServerManager, + @Assisted preselectedEntityId : String?, +) : ViewModel() { + private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID + val servers = serverManager.serversFlow + var selectedServerId by mutableIntStateOf(ServerManager.SERVER_ID_ACTIVE) + private set + + @OptIn(ExperimentalCoroutinesApi::class) + val entities: StateFlow> = snapshotFlow { selectedServerId } + .distinctUntilChanged() + .mapLatest { serverId -> + if (serverManager.isRegistered()) { + try { + serverManager.integrationRepository(serverId) + .getEntities() + .orEmpty() + .filter { entity -> entity.domain == CAMERA_DOMAIN || entity.domain == IMAGE_DOMAIN } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to get entities") + emptyList() + } + } else { + Timber.w("No server registered") + emptyList() + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(500.milliseconds), emptyList()) + + @OptIn(ExperimentalCoroutinesApi::class) + val entityRegistry: StateFlow?> = snapshotFlow { selectedServerId } + .distinctUntilChanged() + .mapLatest { serverId -> + if (serverManager.isRegistered()) { + try { + serverManager.webSocketRepository(serverId).getEntityRegistry() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to get entity registry") + null + } + } else { + null + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(500.milliseconds), null) + + @OptIn(ExperimentalCoroutinesApi::class) + val deviceRegistry: StateFlow?> = snapshotFlow { selectedServerId } + .distinctUntilChanged() + .mapLatest { serverId -> + if (serverManager.isRegistered()) { + try { + serverManager.webSocketRepository(serverId).getDeviceRegistry() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to get device registry") + null + } + } else { + null + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(500.milliseconds), null) + + val areaRegistry: StateFlow?> = snapshotFlow { selectedServerId } + .distinctUntilChanged() + .mapLatest { serverId -> + if (serverManager.isRegistered()) { + try { + serverManager.webSocketRepository(serverId).getAreaRegistry() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to get area registry") + null + } + } else { + null + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(500.milliseconds), null) + + + private val selectedEntityMutex = Mutex() + var selectedEntityId by mutableStateOf(preselectedEntityId) + var selectedTapAction by mutableStateOf(WidgetTapAction.REFRESH) + var isUpdateWidget by mutableStateOf(false) + + + init { + viewModelScope.launch { + entities.collect { entities -> + selectedEntityMutex.withLock { + if(selectedEntityId == null) { + selectedEntityId = entities.firstOrNull()?.entityId + } + } + } + } + } + + fun onSetup(widgetId: Int) { + maybeLoadPreviousState(widgetId) + this.widgetId = widgetId + } + + private fun maybeLoadPreviousState(widgetId: Int) = viewModelScope.launch { + selectedEntityMutex.withLock { + if(this@CameraWidgetConfigureViewModel.widgetId == AppWidgetManager.INVALID_APPWIDGET_ID && selectedEntityId == null) { + cameraWidgetDao.get(widgetId)?.let { + isUpdateWidget = true + selectedServerId = it.serverId + selectedEntityId = it.entityId + selectedTapAction = it.tapAction + } + } + } + } + + fun setServer(serverId: Int) { + if(serverId == selectedServerId) return + selectedServerId = serverId + viewModelScope.launch { selectedEntityMutex.withLock { selectedEntityId = null } } + } + + suspend fun isValidSelection(): Boolean { + selectedEntityMutex.withLock { + return serverManager.getServer(selectedServerId) != null && + selectedEntityId in entities.value.map { it.entityId } + } + } + + suspend fun updateWidgetConfiguration() { + if(!isValidSelection()) { + Timber.d("Widget data is invalid") + throw IllegalArgumentException("Widget data is invalid") + } + if(widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + Timber.w("Widget ID is invalid") + throw IllegalArgumentException("Widget ID is invalid") + } + + val entity = getPendingDaoEntity() + cameraWidgetDao.add(entity) + } + + private suspend fun getPendingDaoEntity(): CameraWidgetEntity { + selectedEntityMutex.withLock { + return CameraWidgetEntity( + id = widgetId, + serverId = selectedServerId, + entityId = selectedEntityId!!, + tapAction = selectedTapAction + ) + } + } + + @RequiresApi(Build.VERSION_CODES.O) + suspend fun requestWidgetCreation(ctx: Context) { + cameraWidgetDao.getWidgetCountFlow().drop(1).onStart { + val appWidgetManager = AppWidgetManager.getInstance(ctx) + val provider = appWidgetManager.getInstalledProviders() + .first { it.provider.className == CameraWidget::class.java.name } + + appWidgetManager.requestPinAppWidget( + provider.provider, + null, + PendingIntent.getBroadcast( + ctx, + System.currentTimeMillis().toInt(), + Intent(ctx, CameraWidget::class.java).apply { + action = ACTION_APPWIDGET_CREATED + putExtra(EXTRA_WIDGET_ENTITY, getPendingDaoEntity()) + }, + PendingIntent.FLAG_MUTABLE + ) + ) + }.first() + } + + fun updateWidget(ctx: Context) { + val appContext = ctx.applicationContext + viewModelScope.launch { + val intent = Intent(appContext, CameraWidget::class.java).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId)) + } + appContext.sendBroadcast(intent) + } + } + + @AssistedFactory + interface Factory { + fun create(preselectedEntityId: String?) : CameraWidgetConfigureViewModel + } +} diff --git a/app/src/main/res/layout/widget_camera_configure.xml b/app/src/main/res/layout/widget_camera_configure.xml deleted file mode 100755 index bfde44d60c9..00000000000 --- a/app/src/main/res/layout/widget_camera_configure.xml +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/automotive/lint-baseline.xml b/automotive/lint-baseline.xml index 681c3ccd434..579d4157268 100644 --- a/automotive/lint-baseline.xml +++ b/automotive/lint-baseline.xml @@ -1,5 +1,5 @@ - + + message="Unnecessary; `SDK_INT` is always >= 26" + errorLine1=" @RequiresApi(Build.VERSION_CODES.O)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="${:automotive*fullDebug*MAIN*sourceProvider*0*javaDir*5}/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureViewModel.kt" + line="211" + column="5"/> + + + + + + + + + + + + + + + + + + + + @@ -3136,10 +3191,21 @@ errorLine2=" ~~~~~~~~~~~~"> + + + +