neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Put the board on the home screen, a card at a time
5b1fbaa · Gabriel Morell · 2026-09-03 15:30
Message
{commit_body(@commit)}
Files changed
modified
clients/android/app/src/main/AndroidManifest.xml
+19
−0
@@ -51,6 +51,25 @@
| 51 | 51 | android:exported="false" |
| 52 | 52 | android:foregroundServiceType="dataSync" /> |
| 53 | 53 | |
| 54 | + <!-- The board on the home screen, a card at a time. --> | |
| 55 | + <receiver | |
| 56 | + android:name=".widget.BoardWidget" | |
| 57 | + android:exported="false"> | |
| 58 | + <intent-filter> | |
| 59 | + <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> | |
| 60 | + </intent-filter> | |
| 61 | + <meta-data | |
| 62 | + android:name="android.appwidget.provider" | |
| 63 | + android:resource="@xml/board_widget_info" /> | |
| 64 | + </receiver> | |
| 65 | + | |
| 66 | + <!-- The cards themselves. Bound by the launcher's widget host, so it | |
| 67 | + needs the permission the host holds. --> | |
| 68 | + <service | |
| 69 | + android:name=".widget.BoardWidgetService" | |
| 70 | + android:permission="android.permission.BIND_REMOTEVIEWS" | |
| 71 | + android:exported="false" /> | |
| 72 | + | |
| 54 | 73 | <!-- The board as a launcher's page to the left of the home screen. |
| 55 | 74 | Launchers implementing Google's "minus one" gesture look for this |
| 56 | 75 | action and this scheme; the api version tells the client which |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/AnkyraService.kt
+2
−0
@@ -15,6 +15,7 @@ import io.neiam.apolloscrib.R
| 15 | 15 | import io.neiam.apolloscrib.data.Settings |
| 16 | 16 | import io.neiam.apolloscrib.data.VisionStore |
| 17 | 17 | import io.neiam.apolloscrib.targets.Targets |
| 18 | +import io.neiam.apolloscrib.widget.BoardWidget | |
| 18 | 19 | import io.neiam.apolloscrib.ui.MainActivity |
| 19 | 20 | import kotlinx.coroutines.flow.MutableStateFlow |
| 20 | 21 | import kotlinx.coroutines.flow.StateFlow |
@@ -67,6 +68,7 @@ class AnkyraService : Service() {
| 67 | 68 | // about a departure that has since gone. |
| 68 | 69 | settings.clearDismissed() |
| 69 | 70 | Targets.notifyAll(this) |
| 71 | + BoardWidget.refresh(this) | |
| 70 | 72 | } |
| 71 | 73 | |
| 72 | 74 | private fun onState(newState: AnkyraClient.State) { |
added
clients/android/app/src/main/java/io/neiam/apolloscrib/widget/BoardWidget.kt
+105
−0
@@ -0,0 +1,105 @@
| 1 | +package io.neiam.apolloscrib.widget | |
| 2 | + | |
| 3 | +import android.app.PendingIntent | |
| 4 | +import android.appwidget.AppWidgetManager | |
| 5 | +import android.appwidget.AppWidgetProvider | |
| 6 | +import android.content.ComponentName | |
| 7 | +import android.content.Context | |
| 8 | +import android.content.Intent | |
| 9 | +import android.net.Uri | |
| 10 | +import android.widget.RemoteViews | |
| 11 | +import io.neiam.apolloscrib.R | |
| 12 | +import io.neiam.apolloscrib.mqtt.AnkyraService | |
| 13 | +import io.neiam.apolloscrib.ui.MainActivity | |
| 14 | +import io.neiam.apolloscrib.ui.theme.appThemeByKey | |
| 15 | +import io.neiam.apolloscrib.data.Settings | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * The board on the home screen, a card at a time. | |
| 19 | + * | |
| 20 | + * A third way to read the same thing: the Smartspace targets show one query | |
| 21 | + * each, the launcher feed page shows all of them, and this cycles. The cards | |
| 22 | + * are built from the same [io.neiam.apolloscrib.targets.Preview]s as both, so | |
| 23 | + * a route reads the same wherever it is drawn. | |
| 24 | + * | |
| 25 | + * The cycling is the host's: `autoAdvanceViewId` in the widget's metadata | |
| 26 | + * points at the flipper, and launchers that honour it advance the card. On the | |
| 27 | + * ones that do not, the flipper can still be swiped. | |
| 28 | + */ | |
| 29 | +class BoardWidget : AppWidgetProvider() { | |
| 30 | + | |
| 31 | + override fun onUpdate( | |
| 32 | + context: Context, | |
| 33 | + manager: AppWidgetManager, | |
| 34 | + widgetIds: IntArray | |
| 35 | + ) { | |
| 36 | + widgetIds.forEach { id -> manager.updateAppWidget(id, build(context, id)) } | |
| 37 | + } | |
| 38 | + | |
| 39 | + private fun build(context: Context, widgetId: Int): RemoteViews { | |
| 40 | + val palette = appThemeByKey(Settings(context).themeKey) | |
| 41 | + | |
| 42 | + return RemoteViews(context.packageName, R.layout.widget_board).apply { | |
| 43 | + setInt(R.id.board_root, "setBackgroundColor", palette.bg.toArgb()) | |
| 44 | + setTextColor(R.id.board_empty, palette.dim.toArgb()) | |
| 45 | + | |
| 46 | + // The factory needs the widget id, and an Intent's extras are not | |
| 47 | + // part of its identity -- two widgets would share one factory | |
| 48 | + // unless the data URI differs. | |
| 49 | + val data = Intent(context, BoardWidgetService::class.java).apply { | |
| 50 | + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) | |
| 51 | + setData(Uri.parse("apolloscrib://widget/$widgetId")) | |
| 52 | + } | |
| 53 | + setRemoteAdapter(R.id.board_flipper, data) | |
| 54 | + setEmptyView(R.id.board_flipper, R.id.board_empty) | |
| 55 | + | |
| 56 | + // Tapping any card opens the app. Read-only, like every other | |
| 57 | + // surface: there is nothing on a card to act on. | |
| 58 | + val open = PendingIntent.getActivity( | |
| 59 | + context, | |
| 60 | + widgetId, | |
| 61 | + Intent(context, MainActivity::class.java), | |
| 62 | + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT | |
| 63 | + ) | |
| 64 | + setPendingIntentTemplate(R.id.board_flipper, open) | |
| 65 | + } | |
| 66 | + } | |
| 67 | + | |
| 68 | + override fun onEnabled(context: Context) { | |
| 69 | + // A widget on the home screen is a reason to be connected, the same | |
| 70 | + // way the feed page is. | |
| 71 | + AnkyraService.resume(context) | |
| 72 | + } | |
| 73 | + | |
| 74 | + companion object { | |
| 75 | + | |
| 76 | + /** | |
| 77 | + * Redraw every widget from what just arrived. | |
| 78 | + * | |
| 79 | + * Two calls: the data change reloads the factory, and the update | |
| 80 | + * rebuilds the flipper around it. Without the second, a widget added | |
| 81 | + * before the first board keeps showing its empty view. | |
| 82 | + */ | |
| 83 | + fun refresh(context: Context) { | |
| 84 | + val manager = AppWidgetManager.getInstance(context) ?: return | |
| 85 | + val ids = manager.getAppWidgetIds(ComponentName(context, BoardWidget::class.java)) | |
| 86 | + if (ids.isEmpty()) return | |
| 87 | + manager.notifyAppWidgetViewDataChanged(ids, R.id.board_flipper) | |
| 88 | + context.sendBroadcast( | |
| 89 | + Intent(context, BoardWidget::class.java).apply { | |
| 90 | + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE | |
| 91 | + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) | |
| 92 | + } | |
| 93 | + ) | |
| 94 | + } | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +/** Compose colours carry an alpha as a float; RemoteViews wants an int. */ | |
| 99 | +internal fun androidx.compose.ui.graphics.Color.toArgb(): Int = | |
| 100 | + android.graphics.Color.argb( | |
| 101 | + (alpha * 255f + 0.5f).toInt(), | |
| 102 | + (red * 255f + 0.5f).toInt(), | |
| 103 | + (green * 255f + 0.5f).toInt(), | |
| 104 | + (blue * 255f + 0.5f).toInt() | |
| 105 | + ) |
added
clients/android/app/src/main/java/io/neiam/apolloscrib/widget/BoardWidgetService.kt
+126
−0
@@ -0,0 +1,126 @@
| 1 | +package io.neiam.apolloscrib.widget | |
| 2 | + | |
| 3 | +import android.content.Context | |
| 4 | +import android.content.Intent | |
| 5 | +import android.widget.RemoteViews | |
| 6 | +import android.widget.RemoteViewsService | |
| 7 | +import io.neiam.apolloscrib.R | |
| 8 | +import io.neiam.apolloscrib.data.Settings | |
| 9 | +import io.neiam.apolloscrib.data.VisionStore | |
| 10 | +import io.neiam.apolloscrib.targets.Preview | |
| 11 | +import io.neiam.apolloscrib.targets.Targets | |
| 12 | +import io.neiam.apolloscrib.ui.theme.AppTheme | |
| 13 | +import io.neiam.apolloscrib.ui.theme.appThemeByKey | |
| 14 | + | |
| 15 | +/** The cards the widget flips through. */ | |
| 16 | +class BoardWidgetService : RemoteViewsService() { | |
| 17 | + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = | |
| 18 | + BoardFactory(applicationContext) | |
| 19 | +} | |
| 20 | + | |
| 21 | +private class BoardFactory(private val context: Context) : RemoteViewsService.RemoteViewsFactory { | |
| 22 | + | |
| 23 | + private val store = VisionStore(context) | |
| 24 | + private var cards: List<Preview> = emptyList() | |
| 25 | + private var palette: AppTheme = appThemeByKey(Settings(context).themeKey) | |
| 26 | + | |
| 27 | + override fun onCreate() = Unit | |
| 28 | + | |
| 29 | + /** | |
| 30 | + * Called on create and on every notified change, on a background thread. | |
| 31 | + * Reading the board here rather than per card keeps one payload being | |
| 32 | + * parsed once for the whole cycle. | |
| 33 | + */ | |
| 34 | + override fun onDataSetChanged() { | |
| 35 | + palette = appThemeByKey(Settings(context).themeKey) | |
| 36 | + cards = store.entries().flatMap { Targets.preview(it) } | |
| 37 | + } | |
| 38 | + | |
| 39 | + override fun onDestroy() { | |
| 40 | + cards = emptyList() | |
| 41 | + } | |
| 42 | + | |
| 43 | + override fun getCount(): Int = cards.size | |
| 44 | + | |
| 45 | + override fun getViewAt(position: Int): RemoteViews { | |
| 46 | + val card = cards.getOrNull(position) ?: return loading() | |
| 47 | + | |
| 48 | + return RemoteViews(context.packageName, R.layout.widget_card).apply { | |
| 49 | + setInt(R.id.card_root, "setBackgroundColor", palette.cardBg.toArgb()) | |
| 50 | + | |
| 51 | + setImageViewResource(R.id.card_icon, card.iconRes) | |
| 52 | + setInt(R.id.card_icon, "setColorFilter", palette.primary.toArgb()) | |
| 53 | + | |
| 54 | + setTextViewText(R.id.card_title, card.title) | |
| 55 | + setTextColor(R.id.card_title, palette.content.toArgb()) | |
| 56 | + | |
| 57 | + setTextViewText(R.id.card_subtitle, card.subtitle.orEmpty()) | |
| 58 | + setTextColor(R.id.card_subtitle, palette.dim.toArgb()) | |
| 59 | + setViewVisibility( | |
| 60 | + R.id.card_subtitle, | |
| 61 | + if (card.subtitle.isNullOrEmpty()) android.view.View.GONE | |
| 62 | + else android.view.View.VISIBLE | |
| 63 | + ) | |
| 64 | + | |
| 65 | + removeAllViews(R.id.card_rows) | |
| 66 | + if (card.style == Preview.Style.List) { | |
| 67 | + if (card.rows.isEmpty()) { | |
| 68 | + addView(R.id.card_rows, emptyRow(card.empty)) | |
| 69 | + } else { | |
| 70 | + // A widget's height is whatever the user dragged it to and | |
| 71 | + // cannot be measured from here, so the count is a guess | |
| 72 | + // that errs towards fitting. The app's board is where all | |
| 73 | + // of them are. | |
| 74 | + card.rows.take(MAX_ROWS).forEach { addView(R.id.card_rows, row(it)) } | |
| 75 | + val extra = card.rows.size - MAX_ROWS | |
| 76 | + if (extra > 0) addView(R.id.card_rows, emptyRow("+$extra more")) | |
| 77 | + } | |
| 78 | + } | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + private fun row(source: io.neiam.apolloscrib.targets.Row) = | |
| 83 | + RemoteViews(context.packageName, R.layout.widget_row).apply { | |
| 84 | + setImageViewResource(R.id.row_icon, source.iconRes) | |
| 85 | + setInt(R.id.row_icon, "setColorFilter", palette.dim.toArgb()) | |
| 86 | + setTextViewText(R.id.row_label, source.text) | |
| 87 | + setTextColor(R.id.row_label, palette.content.toArgb()) | |
| 88 | + | |
| 89 | + removeAllViews(R.id.row_values) | |
| 90 | + source.stamps.forEach { stamp -> | |
| 91 | + addView( | |
| 92 | + R.id.row_values, | |
| 93 | + RemoteViews(context.packageName, R.layout.widget_stamp).apply { | |
| 94 | + setImageViewResource(R.id.stamp_icon, stamp.iconRes) | |
| 95 | + setInt(R.id.stamp_icon, "setColorFilter", palette.accent.toArgb()) | |
| 96 | + setTextViewText(R.id.stamp_text, stamp.text) | |
| 97 | + setTextColor(R.id.stamp_text, palette.accent.toArgb()) | |
| 98 | + } | |
| 99 | + ) | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + /** A line of prose where a list would go: "Nothing due", "+2 more". */ | |
| 104 | + private fun emptyRow(text: String) = | |
| 105 | + RemoteViews(context.packageName, R.layout.widget_row).apply { | |
| 106 | + setViewVisibility(R.id.row_icon, android.view.View.GONE) | |
| 107 | + setTextViewText(R.id.row_label, text) | |
| 108 | + setTextColor(R.id.row_label, palette.dim.toArgb()) | |
| 109 | + } | |
| 110 | + | |
| 111 | + private fun loading() = RemoteViews(context.packageName, R.layout.widget_card) | |
| 112 | + | |
| 113 | + override fun getLoadingView(): RemoteViews? = null | |
| 114 | + | |
| 115 | + /** Positions are stable: the same query keeps the same card. */ | |
| 116 | + override fun getViewTypeCount(): Int = 1 | |
| 117 | + | |
| 118 | + override fun getItemId(position: Int): Long = | |
| 119 | + cards.getOrNull(position)?.id?.hashCode()?.toLong() ?: position.toLong() | |
| 120 | + | |
| 121 | + override fun hasStableIds(): Boolean = true | |
| 122 | + | |
| 123 | + companion object { | |
| 124 | + private const val MAX_ROWS = 4 | |
| 125 | + } | |
| 126 | +} |
added
clients/android/app/src/main/res/layout/widget_board.xml
+27
−0
@@ -0,0 +1,27 @@
| 1 | +<?xml version="1.0" encoding="utf-8"?> | |
| 2 | +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:id="@+id/board_root" | |
| 4 | + android:layout_width="match_parent" | |
| 5 | + android:layout_height="match_parent" | |
| 6 | + android:padding="2dp"> | |
| 7 | + | |
| 8 | + <AdapterViewFlipper | |
| 9 | + android:id="@+id/board_flipper" | |
| 10 | + android:layout_width="match_parent" | |
| 11 | + android:layout_height="match_parent" | |
| 12 | + android:loopViews="true" | |
| 13 | + android:autoStart="true" | |
| 14 | + android:flipInterval="8000" | |
| 15 | + android:animateFirstView="false" /> | |
| 16 | + | |
| 17 | + <!-- Shown by the flipper itself when the board has nothing in it, so the | |
| 18 | + widget says why rather than sitting blank. --> | |
| 19 | + <TextView | |
| 20 | + android:id="@+id/board_empty" | |
| 21 | + android:layout_width="match_parent" | |
| 22 | + android:layout_height="match_parent" | |
| 23 | + android:gravity="center" | |
| 24 | + android:padding="12dp" | |
| 25 | + android:textSize="13sp" | |
| 26 | + android:text="@string/widget_empty" /> | |
| 27 | +</FrameLayout> |
added
clients/android/app/src/main/res/layout/widget_card.xml
+48
−0
@@ -0,0 +1,48 @@
| 1 | +<?xml version="1.0" encoding="utf-8"?> | |
| 2 | +<!-- One card, the same shape the app and the Smartspace targets draw: a glyph, | |
| 3 | + a heading, a supporting line, and rows whose values sit against the right | |
| 4 | + edge. --> | |
| 5 | +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
| 6 | + android:id="@+id/card_root" | |
| 7 | + android:layout_width="match_parent" | |
| 8 | + android:layout_height="match_parent" | |
| 9 | + android:orientation="horizontal" | |
| 10 | + android:padding="12dp"> | |
| 11 | + | |
| 12 | + <ImageView | |
| 13 | + android:id="@+id/card_icon" | |
| 14 | + android:layout_width="24dp" | |
| 15 | + android:layout_height="24dp" | |
| 16 | + android:layout_marginEnd="10dp" | |
| 17 | + android:contentDescription="@null" /> | |
| 18 | + | |
| 19 | + <LinearLayout | |
| 20 | + android:layout_width="match_parent" | |
| 21 | + android:layout_height="wrap_content" | |
| 22 | + android:orientation="vertical"> | |
| 23 | + | |
| 24 | + <TextView | |
| 25 | + android:id="@+id/card_title" | |
| 26 | + android:layout_width="match_parent" | |
| 27 | + android:layout_height="wrap_content" | |
| 28 | + android:maxLines="1" | |
| 29 | + android:ellipsize="end" | |
| 30 | + android:textSize="15sp" | |
| 31 | + android:textStyle="bold" /> | |
| 32 | + | |
| 33 | + <TextView | |
| 34 | + android:id="@+id/card_subtitle" | |
| 35 | + android:layout_width="match_parent" | |
| 36 | + android:layout_height="wrap_content" | |
| 37 | + android:maxLines="1" | |
| 38 | + android:ellipsize="end" | |
| 39 | + android:textSize="12sp" /> | |
| 40 | + | |
| 41 | + <LinearLayout | |
| 42 | + android:id="@+id/card_rows" | |
| 43 | + android:layout_width="match_parent" | |
| 44 | + android:layout_height="wrap_content" | |
| 45 | + android:layout_marginTop="2dp" | |
| 46 | + android:orientation="vertical" /> | |
| 47 | + </LinearLayout> | |
| 48 | +</LinearLayout> |
added
clients/android/app/src/main/res/layout/widget_row.xml
+30
−0
@@ -0,0 +1,30 @@
| 1 | +<?xml version="1.0" encoding="utf-8"?> | |
| 2 | +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:layout_width="match_parent" | |
| 4 | + android:layout_height="wrap_content" | |
| 5 | + android:orientation="horizontal" | |
| 6 | + android:gravity="center_vertical"> | |
| 7 | + | |
| 8 | + <ImageView | |
| 9 | + android:id="@+id/row_icon" | |
| 10 | + android:layout_width="14dp" | |
| 11 | + android:layout_height="14dp" | |
| 12 | + android:layout_marginEnd="5dp" | |
| 13 | + android:contentDescription="@null" /> | |
| 14 | + | |
| 15 | + <TextView | |
| 16 | + android:id="@+id/row_label" | |
| 17 | + android:layout_width="0dp" | |
| 18 | + android:layout_height="wrap_content" | |
| 19 | + android:layout_weight="1" | |
| 20 | + android:maxLines="1" | |
| 21 | + android:ellipsize="end" | |
| 22 | + android:textSize="13sp" /> | |
| 23 | + | |
| 24 | + <LinearLayout | |
| 25 | + android:id="@+id/row_values" | |
| 26 | + android:layout_width="wrap_content" | |
| 27 | + android:layout_height="wrap_content" | |
| 28 | + android:orientation="horizontal" | |
| 29 | + android:gravity="center_vertical" /> | |
| 30 | +</LinearLayout> |
added
clients/android/app/src/main/res/layout/widget_stamp.xml
+21
−0
@@ -0,0 +1,21 @@
| 1 | +<?xml version="1.0" encoding="utf-8"?> | |
| 2 | +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:layout_width="wrap_content" | |
| 4 | + android:layout_height="wrap_content" | |
| 5 | + android:orientation="horizontal" | |
| 6 | + android:layout_marginStart="6dp" | |
| 7 | + android:gravity="center_vertical"> | |
| 8 | + | |
| 9 | + <ImageView | |
| 10 | + android:id="@+id/stamp_icon" | |
| 11 | + android:layout_width="14dp" | |
| 12 | + android:layout_height="14dp" | |
| 13 | + android:layout_marginEnd="3dp" | |
| 14 | + android:contentDescription="@null" /> | |
| 15 | + | |
| 16 | + <TextView | |
| 17 | + android:id="@+id/stamp_text" | |
| 18 | + android:layout_width="wrap_content" | |
| 19 | + android:layout_height="wrap_content" | |
| 20 | + android:textSize="13sp" /> | |
| 21 | +</LinearLayout> |
modified
clients/android/app/src/main/res/values/strings.xml
+2
−0
@@ -2,4 +2,6 @@
| 2 | 2 | <resources> |
| 3 | 3 | <string name="app_name">Apollo\'s Crib</string> |
| 4 | 4 | <string name="configure">Choose a query</string> |
| 5 | + <string name="widget_description">A vision\'s board, one card at a time</string> | |
| 6 | + <string name="widget_empty">No board yet — open Apollo\'s Crib to connect</string> | |
| 5 | 7 | </resources> |
added
clients/android/app/src/main/res/xml/board_widget_info.xml
+25
−0
@@ -0,0 +1,25 @@
| 1 | +<?xml version="1.0" encoding="utf-8"?> | |
| 2 | +<!-- The board as a home screen widget: one card at a time, cycled by the | |
| 3 | + host. `autoAdvanceViewId` is what asks for the cycling — the flipper | |
| 4 | + advances itself on launchers that honour it, and can be swiped by hand on | |
| 5 | + the ones that do not. | |
| 6 | + | |
| 7 | + No `updatePeriodMillis`: a board arrives when a Pythiae publishes one, and | |
| 8 | + the service notifies the widget then. Polling on a timer would redraw what | |
| 9 | + it already has. --> | |
| 10 | +<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" | |
| 11 | + android:minWidth="180dp" | |
| 12 | + android:minHeight="110dp" | |
| 13 | + android:minResizeWidth="110dp" | |
| 14 | + android:minResizeHeight="70dp" | |
| 15 | + android:targetCellWidth="3" | |
| 16 | + android:targetCellHeight="2" | |
| 17 | + android:maxResizeWidth="500dp" | |
| 18 | + android:maxResizeHeight="400dp" | |
| 19 | + android:resizeMode="horizontal|vertical" | |
| 20 | + android:updatePeriodMillis="0" | |
| 21 | + android:initialLayout="@layout/widget_board" | |
| 22 | + android:previewLayout="@layout/widget_board" | |
| 23 | + android:autoAdvanceViewId="@id/board_flipper" | |
| 24 | + android:widgetCategory="home_screen" | |
| 25 | + android:description="@string/widget_description" /> |
Parents: 4fa4e81