Skip to content

Commit

Permalink
Initial grid widget implementation
Browse files Browse the repository at this point in the history
Adds a new widget that allows to configure multiple actions based on the
existing ButtonWidget.

Fixes #1193 #4549
  • Loading branch information
mrdanielps committed Nov 3, 2024
1 parent 8de86eb commit c3eb2e3
Show file tree
Hide file tree
Showing 30 changed files with 2,406 additions and 13 deletions.
20 changes: 20 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,20 @@
android:resource="@xml/template_widget_info" />
</receiver>

<receiver android:name=".widgets.grid.GridWidget" android:label="@string/widget_grid_label"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="io.homeassistant.companion.android.widgets.GridWidget.CALL_SERVICE" />
<action android:name="io.homeassistant.companion.android.widgets.GridWidget.CALL_SERVICE_AUTH" />
<action android:name="io.homeassistant.companion.android.widgets.GridWidget.RECEIVE_DATA" />
</intent-filter>

<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/grid_widget_info" />
</receiver>

<activity android:name=".widgets.button.ButtonWidgetConfigureActivity"
android:configChanges="orientation|screenSize"
android:exported="true">
Expand Down Expand Up @@ -220,6 +234,12 @@
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity android:name=".widgets.grid.config.GridWidgetConfigureActivity"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>

<service android:name=".sensors.NotificationSensorManager"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,15 @@ class ButtonWidget : AppWidgetProvider() {
private fun authThenCallConfiguredAction(context: Context, appWidgetId: Int) {
Log.d(TAG, "Calling authentication, then configured action")

val intent = Intent(context, WidgetAuthenticationActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
val extras = Bundle().apply {
putInt(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
val intent = Intent(context, WidgetAuthenticationActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
putExtra(WidgetAuthenticationActivity.EXTRA_TARGET, ButtonWidget::class.java)
putExtra(WidgetAuthenticationActivity.EXTRA_ACTION, CALL_SERVICE)
putExtra(WidgetAuthenticationActivity.EXTRA_EXTRAS, extras)
}
context.startActivity(intent)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package io.homeassistant.companion.android.widgets.common

import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import android.util.Log
Expand All @@ -13,6 +12,9 @@ import io.homeassistant.companion.android.widgets.button.ButtonWidget
class WidgetAuthenticationActivity : AppCompatActivity() {
companion object {
private const val TAG = "WidgetAuthenticationA"
const val EXTRA_TARGET = "io.homeassistant.companion.android.widgets.common.WidgetAuthenticationActivity.EXTRA_TARGET"
const val EXTRA_ACTION = "io.homeassistant.companion.android.widgets.common.WidgetAuthenticationActivity.EXTRA_ACTION"
const val EXTRA_EXTRAS = "io.homeassistant.companion.android.widgets.common.WidgetAuthenticationActivity.EXTRA_EXTRAS"
}

private var authenticating = false
Expand All @@ -35,14 +37,14 @@ class WidgetAuthenticationActivity : AppCompatActivity() {
when (result) {
Authenticator.SUCCESS -> {
Log.d(TAG, "Authentication successful, calling requested service")
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1)
if (appWidgetId > -1) {
val intent = Intent(applicationContext, ButtonWidget::class.java).apply {
action = ButtonWidget.CALL_SERVICE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
sendBroadcast(intent)
val target = intent.getSerializableExtra(EXTRA_TARGET) ?: ButtonWidget::class.java
val targetAction = intent.getStringExtra(EXTRA_ACTION) ?: ButtonWidget.CALL_SERVICE
val extras = intent.getBundleExtra(EXTRA_EXTRAS) ?: Bundle()
val intent = Intent(applicationContext, target as Class<*>).apply {
action = targetAction
putExtras(extras)
}
sendBroadcast(intent)
finishAffinity()
}
Authenticator.CANCELED -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package io.homeassistant.companion.android.widgets.grid

import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.database.widget.GridWidgetDao
import io.homeassistant.companion.android.widgets.common.WidgetAuthenticationActivity
import java.util.regex.Pattern
import javax.inject.Inject
import kotlin.text.split
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch

@AndroidEntryPoint
class GridWidget : AppWidgetProvider() {
companion object {
private const val TAG = "GridWidget"
const val CALL_SERVICE =
"io.homeassistant.companion.android.widgets.grid.GridWidget.CALL_SERVICE"
const val CALL_SERVICE_AUTH =
"io.homeassistant.companion.android.widgets.grid.GridWidget.CALL_SERVICE_AUTH"
const val EXTRA_ACTION_ID =
"io.homeassistant.companion.android.widgets.grid.GridWidget.EXTRA_ACTION_ID"
}

@Inject
lateinit var serverManager: ServerManager

@Inject
lateinit var gridWidgetDao: GridWidgetDao

private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())

override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
val actionId = intent.getIntExtra(EXTRA_ACTION_ID, -1)

super.onReceive(context, intent)
when (action) {
CALL_SERVICE_AUTH -> authThenCallConfiguredAction(context, appWidgetId, actionId)
CALL_SERVICE -> callConfiguredAction(appWidgetId, actionId)
}
}

override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
appWidgetIds.forEach { appWidgetId ->
val gridConfig = gridWidgetDao.get(appWidgetId)?.asGridConfiguration()
appWidgetManager.updateAppWidget(appWidgetId, gridConfig.asRemoteViews(context, appWidgetId))
}
}

override fun onAppWidgetOptionsChanged(context: Context?, appWidgetManager: AppWidgetManager?, appWidgetId: Int, newOptions: Bundle?) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
}

override fun onDeleted(context: Context?, appWidgetIds: IntArray?) {
super.onDeleted(context, appWidgetIds)
}

override fun onEnabled(context: Context?) {
super.onEnabled(context)
}

override fun onDisabled(context: Context?) {
super.onDisabled(context)
}

override fun onRestored(context: Context?, oldWidgetIds: IntArray?, newWidgetIds: IntArray?) {
super.onRestored(context, oldWidgetIds, newWidgetIds)
}

private fun authThenCallConfiguredAction(context: Context, appWidgetId: Int, actionId: Int) {
Log.d(TAG, "Calling authentication, then configured action")

val extras = Bundle().apply {
putInt(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
putInt(EXTRA_ACTION_ID, actionId)
}
val intent = Intent(context, WidgetAuthenticationActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
putExtra(WidgetAuthenticationActivity.EXTRA_TARGET, GridWidget::class.java)
putExtra(WidgetAuthenticationActivity.EXTRA_ACTION, CALL_SERVICE)
putExtra(WidgetAuthenticationActivity.EXTRA_EXTRAS, extras)
}
context.startActivity(intent)
}

private fun callConfiguredAction(appWidgetId: Int, actionId: Int) {
Log.d(TAG, "Calling widget action")

val widget = gridWidgetDao.get(appWidgetId)
val item = widget?.items?.find { it.id == actionId }

mainScope.launch {
// Load the action call data from Shared Preferences
val domain = item?.domain
val action = item?.service
val actionDataJson = item?.serviceData

Log.d(
TAG,
"Action Call Data loaded:" + System.lineSeparator() +
"domain: " + domain + System.lineSeparator() +
"action: " + action + System.lineSeparator() +
"action_data: " + actionDataJson
)

if (domain == null || action == null || actionDataJson == null) {
Log.w(TAG, "Action Call Data incomplete. Aborting action call")
} else {
// If everything loaded correctly, package the action data and attempt the call
try {
// Convert JSON to HashMap
val actionDataMap: HashMap<String, Any> =
jacksonObjectMapper().readValue(actionDataJson)

if (actionDataMap["entity_id"] != null) {
val entityIdWithoutBrackets = Pattern.compile("\\[(.*?)\\]")
.matcher(actionDataMap["entity_id"].toString())
if (entityIdWithoutBrackets.find()) {
val value = entityIdWithoutBrackets.group(1)
if (value != null) {
if (value == "all" ||
value.split(",").contains("all")
) {
actionDataMap["entity_id"] = "all"
}
}
}
}

Log.d(TAG, "Sending action call to Home Assistant")
serverManager.integrationRepository(widget.gridWidget.serverId).callAction(domain, action, actionDataMap)
Log.d(TAG, "Action call sent successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to call action", e)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package io.homeassistant.companion.android.widgets.grid

import android.app.PendingIntent
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.RemoteViews
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.widget.RemoteViewsCompat
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.IconicsSize
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.padding
import com.mikepenz.iconics.utils.size
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.database.widget.GridWidgetEntity
import io.homeassistant.companion.android.database.widget.GridWidgetItemEntity
import io.homeassistant.companion.android.database.widget.GridWidgetWithItemsEntity
import io.homeassistant.companion.android.util.icondialog.getIconByMdiName
import io.homeassistant.companion.android.widgets.grid.GridWidget.Companion.CALL_SERVICE
import io.homeassistant.companion.android.widgets.grid.GridWidget.Companion.CALL_SERVICE_AUTH
import io.homeassistant.companion.android.widgets.grid.GridWidget.Companion.EXTRA_ACTION_ID
import io.homeassistant.companion.android.widgets.grid.config.GridConfiguration
import io.homeassistant.companion.android.widgets.grid.config.GridItem

fun GridConfiguration?.asRemoteViews(context: Context, widgetId: Int): RemoteViews {
val layout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
R.layout.widget_grid_wrapper_dynamiccolor
} else {
R.layout.widget_grid_wrapper_default
}
val remoteViews = RemoteViews(context.packageName, layout)

if (this != null) {
remoteViews.apply {
if (label.isNullOrEmpty()) {
setViewVisibility(R.id.widgetLabel, View.GONE)
} else {
setViewVisibility(R.id.widgetLabel, View.VISIBLE)
setTextViewText(R.id.widgetLabel, label)
}

val intent = Intent(context, GridWidget::class.java).apply {
action = if (requireAuthentication) CALL_SERVICE_AUTH else CALL_SERVICE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
}
setPendingIntentTemplate(
R.id.widgetGrid,
PendingIntent.getBroadcast(
context,
widgetId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
)

RemoteViewsCompat.setRemoteAdapter(
context = context,
remoteViews = this,
appWidgetId = widgetId,
viewId = R.id.widgetGrid,
items = items.asRemoteCollection(context)
)
}
}
return remoteViews
}

fun List<GridItem>.asRemoteCollection(context: Context) =
RemoteViewsCompat.RemoteCollectionItems.Builder().apply {
setHasStableIds(true)
forEach { addItem(context, it) }
}.build()

private fun RemoteViewsCompat.RemoteCollectionItems.Builder.addItem(context: Context, item: GridItem) {
addItem(item.id.toLong(), item.asRemoteViews(context))
}

private fun GridItem.asRemoteViews(context: Context) =
RemoteViews(context.packageName, R.layout.widget_grid_button).apply {
val icon = CommunityMaterial.getIconByMdiName(icon)
icon?.let {
val iconDrawable = DrawableCompat.wrap(
IconicsDrawable(context, icon).apply {
padding = IconicsSize.dp(2)
size = IconicsSize.dp(24)
}
)

setImageViewBitmap(R.id.widgetImageButton, iconDrawable.toBitmap())
}
setTextViewText(
R.id.widgetLabel,
label
)

val fillInIntent = Intent().apply {
Bundle().also { extras ->
extras.putInt(EXTRA_ACTION_ID, id)
putExtras(extras)
}
}
setOnClickFillInIntent(R.id.gridButtonLayout, fillInIntent)
}

fun GridConfiguration.asDbEntity(widgetId: Int) =
GridWidgetWithItemsEntity(
gridWidget = GridWidgetEntity(
id = widgetId,
serverId = serverId ?: 0,
label = label,
requireAuthentication = requireAuthentication
),
items = items.map { it.asDbEntity(widgetId) }
)

fun GridItem.asDbEntity(widgetId: Int) =
GridWidgetItemEntity(
id = id,
gridId = widgetId,
domain = domain,
service = service,
serviceData = serviceData,
label = label,
iconName = icon
)

fun GridWidgetWithItemsEntity.asGridConfiguration() =
GridConfiguration(
serverId = gridWidget.serverId,
label = gridWidget.label,
requireAuthentication = gridWidget.requireAuthentication,
items = items.map(GridWidgetItemEntity::asGridItem)
)

fun GridWidgetItemEntity.asGridItem() =
GridItem(
id = id,
label = label.orEmpty(),
icon = iconName,
domain = domain,
service = service,
serviceData = serviceData
)
Loading

0 comments on commit c3eb2e3

Please sign in to comment.