Skip to content

Commit c3eb2e3

Browse files
committed
Initial grid widget implementation
Adds a new widget that allows to configure multiple actions based on the existing ButtonWidget. Fixes home-assistant#1193 home-assistant#4549
1 parent 8de86eb commit c3eb2e3

File tree

30 files changed

+2406
-13
lines changed

30 files changed

+2406
-13
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,20 @@
181181
android:resource="@xml/template_widget_info" />
182182
</receiver>
183183

184+
<receiver android:name=".widgets.grid.GridWidget" android:label="@string/widget_grid_label"
185+
android:exported="false">
186+
<intent-filter>
187+
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
188+
<action android:name="io.homeassistant.companion.android.widgets.GridWidget.CALL_SERVICE" />
189+
<action android:name="io.homeassistant.companion.android.widgets.GridWidget.CALL_SERVICE_AUTH" />
190+
<action android:name="io.homeassistant.companion.android.widgets.GridWidget.RECEIVE_DATA" />
191+
</intent-filter>
192+
193+
<meta-data
194+
android:name="android.appwidget.provider"
195+
android:resource="@xml/grid_widget_info" />
196+
</receiver>
197+
184198
<activity android:name=".widgets.button.ButtonWidgetConfigureActivity"
185199
android:configChanges="orientation|screenSize"
186200
android:exported="true">
@@ -220,6 +234,12 @@
220234
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
221235
</intent-filter>
222236
</activity>
237+
<activity android:name=".widgets.grid.config.GridWidgetConfigureActivity"
238+
android:exported="true">
239+
<intent-filter>
240+
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
241+
</intent-filter>
242+
</activity>
223243

224244
<service android:name=".sensors.NotificationSensorManager"
225245
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"

app/src/main/java/io/homeassistant/companion/android/widgets/button/ButtonWidget.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,15 @@ class ButtonWidget : AppWidgetProvider() {
158158
private fun authThenCallConfiguredAction(context: Context, appWidgetId: Int) {
159159
Log.d(TAG, "Calling authentication, then configured action")
160160

161-
val intent = Intent(context, WidgetAuthenticationActivity::class.java)
162-
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
163-
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
161+
val extras = Bundle().apply {
162+
putInt(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
163+
}
164+
val intent = Intent(context, WidgetAuthenticationActivity::class.java).apply {
165+
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
166+
putExtra(WidgetAuthenticationActivity.EXTRA_TARGET, ButtonWidget::class.java)
167+
putExtra(WidgetAuthenticationActivity.EXTRA_ACTION, CALL_SERVICE)
168+
putExtra(WidgetAuthenticationActivity.EXTRA_EXTRAS, extras)
169+
}
164170
context.startActivity(intent)
165171
}
166172

app/src/main/java/io/homeassistant/companion/android/widgets/common/WidgetAuthenticationActivity.kt

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package io.homeassistant.companion.android.widgets.common
22

3-
import android.appwidget.AppWidgetManager
43
import android.content.Intent
54
import android.os.Bundle
65
import android.util.Log
@@ -13,6 +12,9 @@ import io.homeassistant.companion.android.widgets.button.ButtonWidget
1312
class WidgetAuthenticationActivity : AppCompatActivity() {
1413
companion object {
1514
private const val TAG = "WidgetAuthenticationA"
15+
const val EXTRA_TARGET = "io.homeassistant.companion.android.widgets.common.WidgetAuthenticationActivity.EXTRA_TARGET"
16+
const val EXTRA_ACTION = "io.homeassistant.companion.android.widgets.common.WidgetAuthenticationActivity.EXTRA_ACTION"
17+
const val EXTRA_EXTRAS = "io.homeassistant.companion.android.widgets.common.WidgetAuthenticationActivity.EXTRA_EXTRAS"
1618
}
1719

1820
private var authenticating = false
@@ -35,14 +37,14 @@ class WidgetAuthenticationActivity : AppCompatActivity() {
3537
when (result) {
3638
Authenticator.SUCCESS -> {
3739
Log.d(TAG, "Authentication successful, calling requested service")
38-
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1)
39-
if (appWidgetId > -1) {
40-
val intent = Intent(applicationContext, ButtonWidget::class.java).apply {
41-
action = ButtonWidget.CALL_SERVICE
42-
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
43-
}
44-
sendBroadcast(intent)
40+
val target = intent.getSerializableExtra(EXTRA_TARGET) ?: ButtonWidget::class.java
41+
val targetAction = intent.getStringExtra(EXTRA_ACTION) ?: ButtonWidget.CALL_SERVICE
42+
val extras = intent.getBundleExtra(EXTRA_EXTRAS) ?: Bundle()
43+
val intent = Intent(applicationContext, target as Class<*>).apply {
44+
action = targetAction
45+
putExtras(extras)
4546
}
47+
sendBroadcast(intent)
4648
finishAffinity()
4749
}
4850
Authenticator.CANCELED -> {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package io.homeassistant.companion.android.widgets.grid
2+
3+
import android.appwidget.AppWidgetManager
4+
import android.appwidget.AppWidgetProvider
5+
import android.content.Context
6+
import android.content.Intent
7+
import android.os.Bundle
8+
import android.util.Log
9+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
10+
import com.fasterxml.jackson.module.kotlin.readValue
11+
import dagger.hilt.android.AndroidEntryPoint
12+
import io.homeassistant.companion.android.common.data.servers.ServerManager
13+
import io.homeassistant.companion.android.database.widget.GridWidgetDao
14+
import io.homeassistant.companion.android.widgets.common.WidgetAuthenticationActivity
15+
import java.util.regex.Pattern
16+
import javax.inject.Inject
17+
import kotlin.text.split
18+
import kotlinx.coroutines.CoroutineScope
19+
import kotlinx.coroutines.Dispatchers
20+
import kotlinx.coroutines.Job
21+
import kotlinx.coroutines.launch
22+
23+
@AndroidEntryPoint
24+
class GridWidget : AppWidgetProvider() {
25+
companion object {
26+
private const val TAG = "GridWidget"
27+
const val CALL_SERVICE =
28+
"io.homeassistant.companion.android.widgets.grid.GridWidget.CALL_SERVICE"
29+
const val CALL_SERVICE_AUTH =
30+
"io.homeassistant.companion.android.widgets.grid.GridWidget.CALL_SERVICE_AUTH"
31+
const val EXTRA_ACTION_ID =
32+
"io.homeassistant.companion.android.widgets.grid.GridWidget.EXTRA_ACTION_ID"
33+
}
34+
35+
@Inject
36+
lateinit var serverManager: ServerManager
37+
38+
@Inject
39+
lateinit var gridWidgetDao: GridWidgetDao
40+
41+
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
42+
43+
override fun onReceive(context: Context, intent: Intent) {
44+
val action = intent.action
45+
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
46+
val actionId = intent.getIntExtra(EXTRA_ACTION_ID, -1)
47+
48+
super.onReceive(context, intent)
49+
when (action) {
50+
CALL_SERVICE_AUTH -> authThenCallConfiguredAction(context, appWidgetId, actionId)
51+
CALL_SERVICE -> callConfiguredAction(appWidgetId, actionId)
52+
}
53+
}
54+
55+
override fun onUpdate(
56+
context: Context,
57+
appWidgetManager: AppWidgetManager,
58+
appWidgetIds: IntArray
59+
) {
60+
appWidgetIds.forEach { appWidgetId ->
61+
val gridConfig = gridWidgetDao.get(appWidgetId)?.asGridConfiguration()
62+
appWidgetManager.updateAppWidget(appWidgetId, gridConfig.asRemoteViews(context, appWidgetId))
63+
}
64+
}
65+
66+
override fun onAppWidgetOptionsChanged(context: Context?, appWidgetManager: AppWidgetManager?, appWidgetId: Int, newOptions: Bundle?) {
67+
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
68+
}
69+
70+
override fun onDeleted(context: Context?, appWidgetIds: IntArray?) {
71+
super.onDeleted(context, appWidgetIds)
72+
}
73+
74+
override fun onEnabled(context: Context?) {
75+
super.onEnabled(context)
76+
}
77+
78+
override fun onDisabled(context: Context?) {
79+
super.onDisabled(context)
80+
}
81+
82+
override fun onRestored(context: Context?, oldWidgetIds: IntArray?, newWidgetIds: IntArray?) {
83+
super.onRestored(context, oldWidgetIds, newWidgetIds)
84+
}
85+
86+
private fun authThenCallConfiguredAction(context: Context, appWidgetId: Int, actionId: Int) {
87+
Log.d(TAG, "Calling authentication, then configured action")
88+
89+
val extras = Bundle().apply {
90+
putInt(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
91+
putInt(EXTRA_ACTION_ID, actionId)
92+
}
93+
val intent = Intent(context, WidgetAuthenticationActivity::class.java).apply {
94+
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
95+
putExtra(WidgetAuthenticationActivity.EXTRA_TARGET, GridWidget::class.java)
96+
putExtra(WidgetAuthenticationActivity.EXTRA_ACTION, CALL_SERVICE)
97+
putExtra(WidgetAuthenticationActivity.EXTRA_EXTRAS, extras)
98+
}
99+
context.startActivity(intent)
100+
}
101+
102+
private fun callConfiguredAction(appWidgetId: Int, actionId: Int) {
103+
Log.d(TAG, "Calling widget action")
104+
105+
val widget = gridWidgetDao.get(appWidgetId)
106+
val item = widget?.items?.find { it.id == actionId }
107+
108+
mainScope.launch {
109+
// Load the action call data from Shared Preferences
110+
val domain = item?.domain
111+
val action = item?.service
112+
val actionDataJson = item?.serviceData
113+
114+
Log.d(
115+
TAG,
116+
"Action Call Data loaded:" + System.lineSeparator() +
117+
"domain: " + domain + System.lineSeparator() +
118+
"action: " + action + System.lineSeparator() +
119+
"action_data: " + actionDataJson
120+
)
121+
122+
if (domain == null || action == null || actionDataJson == null) {
123+
Log.w(TAG, "Action Call Data incomplete. Aborting action call")
124+
} else {
125+
// If everything loaded correctly, package the action data and attempt the call
126+
try {
127+
// Convert JSON to HashMap
128+
val actionDataMap: HashMap<String, Any> =
129+
jacksonObjectMapper().readValue(actionDataJson)
130+
131+
if (actionDataMap["entity_id"] != null) {
132+
val entityIdWithoutBrackets = Pattern.compile("\\[(.*?)\\]")
133+
.matcher(actionDataMap["entity_id"].toString())
134+
if (entityIdWithoutBrackets.find()) {
135+
val value = entityIdWithoutBrackets.group(1)
136+
if (value != null) {
137+
if (value == "all" ||
138+
value.split(",").contains("all")
139+
) {
140+
actionDataMap["entity_id"] = "all"
141+
}
142+
}
143+
}
144+
}
145+
146+
Log.d(TAG, "Sending action call to Home Assistant")
147+
serverManager.integrationRepository(widget.gridWidget.serverId).callAction(domain, action, actionDataMap)
148+
Log.d(TAG, "Action call sent successfully")
149+
} catch (e: Exception) {
150+
Log.e(TAG, "Failed to call action", e)
151+
}
152+
}
153+
}
154+
}
155+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package io.homeassistant.companion.android.widgets.grid
2+
3+
import android.app.PendingIntent
4+
import android.appwidget.AppWidgetManager
5+
import android.content.Context
6+
import android.content.Intent
7+
import android.os.Build
8+
import android.os.Bundle
9+
import android.view.View
10+
import android.widget.RemoteViews
11+
import androidx.core.graphics.drawable.DrawableCompat
12+
import androidx.core.graphics.drawable.toBitmap
13+
import androidx.core.widget.RemoteViewsCompat
14+
import com.mikepenz.iconics.IconicsDrawable
15+
import com.mikepenz.iconics.IconicsSize
16+
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
17+
import com.mikepenz.iconics.utils.padding
18+
import com.mikepenz.iconics.utils.size
19+
import io.homeassistant.companion.android.R
20+
import io.homeassistant.companion.android.database.widget.GridWidgetEntity
21+
import io.homeassistant.companion.android.database.widget.GridWidgetItemEntity
22+
import io.homeassistant.companion.android.database.widget.GridWidgetWithItemsEntity
23+
import io.homeassistant.companion.android.util.icondialog.getIconByMdiName
24+
import io.homeassistant.companion.android.widgets.grid.GridWidget.Companion.CALL_SERVICE
25+
import io.homeassistant.companion.android.widgets.grid.GridWidget.Companion.CALL_SERVICE_AUTH
26+
import io.homeassistant.companion.android.widgets.grid.GridWidget.Companion.EXTRA_ACTION_ID
27+
import io.homeassistant.companion.android.widgets.grid.config.GridConfiguration
28+
import io.homeassistant.companion.android.widgets.grid.config.GridItem
29+
30+
fun GridConfiguration?.asRemoteViews(context: Context, widgetId: Int): RemoteViews {
31+
val layout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
32+
R.layout.widget_grid_wrapper_dynamiccolor
33+
} else {
34+
R.layout.widget_grid_wrapper_default
35+
}
36+
val remoteViews = RemoteViews(context.packageName, layout)
37+
38+
if (this != null) {
39+
remoteViews.apply {
40+
if (label.isNullOrEmpty()) {
41+
setViewVisibility(R.id.widgetLabel, View.GONE)
42+
} else {
43+
setViewVisibility(R.id.widgetLabel, View.VISIBLE)
44+
setTextViewText(R.id.widgetLabel, label)
45+
}
46+
47+
val intent = Intent(context, GridWidget::class.java).apply {
48+
action = if (requireAuthentication) CALL_SERVICE_AUTH else CALL_SERVICE
49+
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
50+
}
51+
setPendingIntentTemplate(
52+
R.id.widgetGrid,
53+
PendingIntent.getBroadcast(
54+
context,
55+
widgetId,
56+
intent,
57+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
58+
)
59+
)
60+
61+
RemoteViewsCompat.setRemoteAdapter(
62+
context = context,
63+
remoteViews = this,
64+
appWidgetId = widgetId,
65+
viewId = R.id.widgetGrid,
66+
items = items.asRemoteCollection(context)
67+
)
68+
}
69+
}
70+
return remoteViews
71+
}
72+
73+
fun List<GridItem>.asRemoteCollection(context: Context) =
74+
RemoteViewsCompat.RemoteCollectionItems.Builder().apply {
75+
setHasStableIds(true)
76+
forEach { addItem(context, it) }
77+
}.build()
78+
79+
private fun RemoteViewsCompat.RemoteCollectionItems.Builder.addItem(context: Context, item: GridItem) {
80+
addItem(item.id.toLong(), item.asRemoteViews(context))
81+
}
82+
83+
private fun GridItem.asRemoteViews(context: Context) =
84+
RemoteViews(context.packageName, R.layout.widget_grid_button).apply {
85+
val icon = CommunityMaterial.getIconByMdiName(icon)
86+
icon?.let {
87+
val iconDrawable = DrawableCompat.wrap(
88+
IconicsDrawable(context, icon).apply {
89+
padding = IconicsSize.dp(2)
90+
size = IconicsSize.dp(24)
91+
}
92+
)
93+
94+
setImageViewBitmap(R.id.widgetImageButton, iconDrawable.toBitmap())
95+
}
96+
setTextViewText(
97+
R.id.widgetLabel,
98+
label
99+
)
100+
101+
val fillInIntent = Intent().apply {
102+
Bundle().also { extras ->
103+
extras.putInt(EXTRA_ACTION_ID, id)
104+
putExtras(extras)
105+
}
106+
}
107+
setOnClickFillInIntent(R.id.gridButtonLayout, fillInIntent)
108+
}
109+
110+
fun GridConfiguration.asDbEntity(widgetId: Int) =
111+
GridWidgetWithItemsEntity(
112+
gridWidget = GridWidgetEntity(
113+
id = widgetId,
114+
serverId = serverId ?: 0,
115+
label = label,
116+
requireAuthentication = requireAuthentication
117+
),
118+
items = items.map { it.asDbEntity(widgetId) }
119+
)
120+
121+
fun GridItem.asDbEntity(widgetId: Int) =
122+
GridWidgetItemEntity(
123+
id = id,
124+
gridId = widgetId,
125+
domain = domain,
126+
service = service,
127+
serviceData = serviceData,
128+
label = label,
129+
iconName = icon
130+
)
131+
132+
fun GridWidgetWithItemsEntity.asGridConfiguration() =
133+
GridConfiguration(
134+
serverId = gridWidget.serverId,
135+
label = gridWidget.label,
136+
requireAuthentication = gridWidget.requireAuthentication,
137+
items = items.map(GridWidgetItemEntity::asGridItem)
138+
)
139+
140+
fun GridWidgetItemEntity.asGridItem() =
141+
GridItem(
142+
id = id,
143+
label = label.orEmpty(),
144+
icon = iconName,
145+
domain = domain,
146+
service = service,
147+
serviceData = serviceData
148+
)

0 commit comments

Comments
 (0)