Skip to content

Commit 68f3ce1

Browse files
authored
feat(android): add native block inserter (#461)
* feat(android): wire up showBlockInserter bridge Adds the `@JavascriptInterface showBlockInserter(String)` entry point on `GutenbergView` plus the `presentBlockInserter` / `insertBlock` / `dismissBlockInserter` round-trip back to JS via `window.blockInserter.insertBlock` / `onClose`. Bad payloads surface a Toast (from `R.string.gbk_block_inserter_failure`) so a broken tap isn't silent. Sets `resourcePrefix = "gbk_"` so AGP flags any unprefixed library resource at lint time. The dialog itself lands in #480; this commit only wires the bridge so that PR can build atop it. * feat(android): add native block inserter (#480) Compose-based bottom sheet that replaces the legacy WebView block picker. Variation B handoff: drag handle + header, tonal Material 3 palette (dynamic on API 31+, brand-seeded fallback below), 5-column tile grid with auto-shrinking labels, scrollable category-tab chips, and a rounded search field. Block tiles render plain tonal rounded-rect placeholders for now — SVG icon rendering lands in #468 (which adds `SvgIconCache` and pipes `iconForeground` through the JS payload + iOS/Android models). This PR deliberately stops at the shell so #468 can be reviewed independently. Tab filter, search filter, photo/camera tiles, and recent-photo strip ship in #478 / #479 — the chips and search bar are intentionally non-functional in this PR so the visual shell can be reviewed in isolation. * feat(android): render branded block icons with contrast-aware tinting (#468) * feat(android): render SVG icons in block inserter Adopts AndroidSVG (com.caverock:androidsvg-aar:1.4) — the same rendering engine Coil-SVG wraps, used directly to avoid pulling in Coil — and wires it into the block inserter dialog introduced in #461. Mirrors `BlockIconCache` on iOS: parse each block's inline SVG once, keyed by `BlockType.id`, and cache the rendered bitmap so RecyclerView rebinds don't re-render on scroll. Three @wordpress/icons patterns the browser handles via CSS that AndroidSVG does not: 1. **Missing `viewBox`** (e.g. `core/site-tagline`) — intrinsic width/height are declared but paths render at native coordinate size inside our larger viewport, so the icon appears tiny. Synthesise a viewBox from the intrinsic dimensions and set document width/height to 100%. 2. **`fill="none"` at root** (e.g. `core/icon`) — paths without an explicit fill inherit `none` and render invisibly. The web editor's `@wordpress/components` CSS injects `fill: currentColor`; we do the same via `RenderOptions.css` at render time. 3. **Multi-fill branded icons** (e.g. Pocket Casts, Animoto) — these rely on colour contrast between inner/outer paths. A monochrome PorterDuff SRC_IN tint flattens them into a silhouette. Detect hex fills in the raw SVG string and skip the tint when present, letting branded icons render as-is. Pocket Casts still renders black-and-white rather than brand red — its foreground colour lives in JS metadata (`icon.foreground`) that `getBlockIcon` at `src/utils/blocks.js:44` drops. Fixing this properly requires piping `foreground` through the bridge and is deferred to a follow-up. - [x] `./gradlew :Gutenberg:test detekt :Gutenberg:lintDebug :app:compileDebugKotlin` — BUILD SUCCESSFUL - [x] Manually verified on Pixel 9 Pro XL with `enableNativeBlockInserter` on: open inserter, scroll through all sections, confirmed Site Tagline (no viewBox), Icon block (root `fill="none"`), and Pocket Casts/Animoto/Bluesky (branded colours) all render correctly. - [ ] Reviewer: verify iOS behaviour unchanged. * feat(android): contrast-aware tinting for branded block icons Follow-up to #461 / 444c640 that addresses the "known limitation" called out in that commit: branded icons (Pocket Casts, Animoto, Bluesky) and single-colour brand foregrounds (X, Dailymotion, WordPress Embed) now render correctly against either light or dark dialog surfaces. `getBlockIcon` at `src/utils/blocks.js:44` previously dropped `icon.foreground` — the JS metadata that the web editor applies as CSS `color`, which paths inside the SVG pick up via `currentColor`. Adds `getBlockIconForeground` and includes it in the serialised inserter payload, with matching `iconForeground: String?` fields on the Android `BlockType` data class and the iOS `BlockType` struct (iOS gets the field for parity; no rendering change). Wraps each icon in a 44dp `RoundedRectangle` chip filled with ~12% of the theme's primary text colour, mirroring the iOS `BlockIconView` treatment (`Color(.label).mask` over a `secondarySystemFill` chip). Without this, low-contrast brand icons (X's `#000000`, Dailymotion's `#333436`) rendered as near-invisible smudges on the dark bottom sheet. `SvgIconCache.shouldTint` decides per-icon whether to apply the theme tint: 1. **No declared colours** — pure monochrome; tint to text colour. 2. **Multiple declared colours** (Pocket Casts red+white, Animoto, Bluesky) — render as-is. A PorterDuff SRC_IN tint would flatten internal contrast into a silhouette. 3. **Single declared colour** — keep it if it has at least 3:1 contrast (WCAG 2.x SC 1.4.11 — minimum for UI graphics) against the surface the icon actually sits on; otherwise strip the brand colour and tint. The contrast reference is the chip fill **composited over the dialog surface**, not the bare surface. Measuring against bare black makes marginal colours like WordPress blue (#0073AA) appear to pass at 3.16:1 while reading as dim against the actual ~`#3B3B3D` chip surrogate (2.1:1). `resolveDialogSurface()` looks up `?attr/colorSurface` then `?android:attr/colorBackground` so the surrogate matches what the user sees. - [x] `./gradlew :Gutenberg:test detekt :Gutenberg:lintDebug :app:compileDebugKotlin` — BUILD SUCCESSFUL - [x] Manually verified on Pixel 9 Pro XL with `enableNativeBlockInserter` on, dark theme: Pocket Casts renders red+white, Animoto/Bluesky render branded, X / Dailymotion / WordPress Embed render white (tint fallback), Site Tagline / Icon block still render correctly. - [ ] Reviewer: verify in light theme; verify iOS behaviour unchanged. * refactor(android): address review feedback in BlockInserterDialog Three fixes from #468 review: - **resolveTextColorPrimary fallback** — `resolveAttribute` can return false (attribute absent) or leave `typed.resourceId == 0`, in which case `getColorStateList(0, …)` throws. Mirror the pattern already used by `resolveDialogSurface` and fall back to `Color.BLACK`. - **BlockViewHolder positional access** — `(container.getChildAt(0) as FrameLayout).getChildAt(0) as ImageView` silently breaks if the view hierarchy is reordered. `buildBlockView` now returns a `BlockRowViews` struct holding direct references; the ViewHolder takes that struct rather than the root `View`. - **rightMargin → marginEnd** — `rightMargin` is physical, `marginEnd` is layout-direction-aware so the chip leads correctly under RTL. Verified manually on Pixel 9 Pro XL: most-used and embed sections still render correctly with chip-backed icons. * refactor(android): drop over-defensive fallback in resolveTextColorPrimary The Color.BLACK fallback only triggered when theme.resolveAttribute returned false — i.e. the host app's theme genuinely doesn't define android.R.attr.textColorPrimary, which has been part of the framework since API 1. Standard theme parents (AppCompat, Material*, even bare @android:style/Theme) all define it. Bailing out a host that's already broken in many other ways isn't our job. Keep the typed.resourceId != 0 branch — that handles legitimate inline literal themes (`<item name="...">#DD000000</item>`), which is real real-world theme construction, not misconfiguration. * feat(android): render icons and wrap labels in the block inserter (#481) * feat(android): render block icons in the native inserter Replaces the tonal placeholder Box in `BlockPickerDialog.BlockTile` with the actual SVG icon for each block, sourced from `SvgIconCache` (added in #461 alongside this dialog but never wired up). Without this change the inserter renders rows of identical solid chips — every block is visually indistinguishable apart from its label. The cache lives at the `BlockGridContent` level and is keyed on `(renderSizePx, chipColor)` so a font-scale or theme change recomputes both the bitmap resolution and the contrast surrogate the tinting decision in `SvgIconCache.shouldTint` is measured against. The render size is the inner glyph (`BLOCK_TILE_ICON_GLYPH_SIZE_DP`, 32dp), not the 56dp chip — the bitmap pixels need to be sharp at the displayed size, not at the chip dimensions. Branded icons (Pocket Casts, Pinterest, Reddit) flow through with no tint; monochrome and low-contrast brand icons get `ColorFilter.tint(onSurface)` so they read against the chip. Disabled blocks dim via `Image.alpha` rather than baking the alpha into the tint, so branded icons fade uniformly instead of going monochrome on disable. * feat(android): wrap two-line labels with tight line height in the inserter `AutoShrinkTileLabel` previously shrank labels down to 9sp until they fit on a single line. Three issues with the result: 1. **Single-word and multi-word were treated identically.** "Featured Image" was shrunk to fit on one line when it could have wrapped at the space at full size and stayed legible. 2. **Mid-word breaks on long single words.** Naively switching to `maxLines = 2` made Compose character-break "Preformatted" into "Preform" / "atted" rather than shrink it. The default soft-wrap will break inside a word to satisfy the line budget, so we now measure single-word labels with `maxLines = 1` and render with `softWrap = false` to force the shrink path. 3. **Loose vertical rhythm on wrapped labels.** With no explicit `lineHeight`, the gap between the two wrapped lines tracked the font's default leading and read as too tall. `Material 3`'s typography presets fix this with `LineHeightStyle.Trim.Both`, but custom `sp + lineHeight` doesn't pick that up — set it explicitly so labels sit centred against the reserved two-line height. `minLines = 2` reserves two lines worth of vertical space at the resolved size so tile heights stay consistent across the grid, even when neighbouring labels need different line counts. * fix(android): re-render block icons when the SVG cache is replaced `rememberSvgIconCache` correctly returns a new `SvgIconCache` instance when `sizePx` or `surfaceArgb` changes — i.e. on a theme or font-scale switch. `BlockTile` was keying its `remember` only on `block.id`, so it returned the previously-rendered `Icon` from the now-stale cache. Visible result: icons keep using the old contrast surrogate (and the old bitmap pixels) until the tile leaves and re-enters composition. Adding `iconCache` to the key forces the lambda to re-run whenever the cache reference changes, which re-renders each SVG against the new surface and rebuilds the tinting decision. The new bitmap is the correct one to display going forward. Caught by @adalpari in #481 review. * feat(android): wire up block inserter search and tab filtering (#478) Activates the chips and search field shipped as a non-functional shell in the previous PR. Tab selection narrows the grid to a fixed set of block ids per category (Text/Media/Design/Widgets/Theme/Embeds), and the Recent tab surfaces blocks from the payload's `gbk-most-used` section when present, falling back to all blocks otherwise. Search runs against the active tab's results with a 150ms debounce and a fuzzy scorer that weights name/title/keyword/category matches. Empty result sets render the No results / "No blocks match X" copy. * fix(js): skip redux toggle when opening native block inserter (#487) Tapping the `+` button currently calls `setIsInserterOpened(true)` in the editor store, which momentarily renders Gutenberg's web inserter before the native dialog covers it — visible as a flash on slower devices. Call `prepareAndShowInserter()` directly instead; the redux flag isn't useful when the inserter is rendered natively.
1 parent 2f5818f commit 68f3ce1

21 files changed

Lines changed: 1784 additions & 25 deletions

File tree

android/Gutenberg/build.gradle.kts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import groovy.json.JsonSlurper
33
plugins {
44
alias(libs.plugins.android.library)
55
alias(libs.plugins.jetbrains.kotlin.android)
6+
alias(libs.plugins.jetbrains.kotlin.compose)
67
alias(libs.plugins.jetbrains.kotlin.serialization)
78
id("com.automattic.android.publish-to-s3")
89
id("kotlin-parcelize")
@@ -87,9 +88,11 @@ val generateSupportedLocales = tasks.register("generateSupportedLocales") {
8788
android {
8889
namespace = "org.wordpress.gutenberg"
8990
compileSdk = 34
91+
resourcePrefix = "gbk_"
9092

9193
buildFeatures {
9294
buildConfig = true
95+
compose = true
9396
}
9497

9598
defaultConfig {
@@ -162,6 +165,12 @@ dependencies {
162165
implementation(libs.kotlinx.serialization.json)
163166
implementation(libs.jsoup)
164167
implementation(libs.okhttp)
168+
implementation(libs.androidsvg)
169+
170+
implementation(platform(libs.androidx.compose.bom))
171+
implementation(libs.androidx.compose.ui)
172+
implementation(libs.androidx.compose.material3)
173+
implementation(libs.androidx.compose.material.icons.extended)
165174

166175
testImplementation(libs.junit)
167176
testImplementation(kotlin("test"))

android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,16 @@ import android.webkit.WebView
2828
import android.webkit.WebViewClient
2929
import android.widget.FrameLayout
3030
import android.widget.ProgressBar
31+
import android.widget.Toast
3132
import androidx.webkit.WebViewAssetLoader
3233
import androidx.webkit.WebViewAssetLoader.AssetsPathHandler
3334
import kotlinx.coroutines.CoroutineScope
3435
import kotlinx.coroutines.Dispatchers
3536
import kotlinx.coroutines.launch
3637
import org.json.JSONException
3738
import org.json.JSONObject
39+
import org.wordpress.gutenberg.inserter.BlockPickerDialog
40+
import org.wordpress.gutenberg.model.BlockInserterPayload
3841
import org.wordpress.gutenberg.model.EditorConfiguration
3942
import org.wordpress.gutenberg.model.EditorDependencies
4043
import org.wordpress.gutenberg.model.GBKitGlobal
@@ -119,6 +122,7 @@ class GutenbergView : FrameLayout {
119122
private var modalDialogStateListener: ModalDialogStateListener? = null
120123
private var networkRequestListener: NetworkRequestListener? = null
121124
private var latestContentProvider: LatestContentProvider? = null
125+
private var blockInserterDialog: BlockPickerDialog? = null
122126

123127
/**
124128
* Stores the contextId from the most recent openMediaLibrary call
@@ -865,15 +869,63 @@ class GutenbergView : FrameLayout {
865869
currentMediaContextId = null
866870
}
867871

872+
private fun insertBlock(blockId: String) {
873+
if (!isEditorLoaded) return
874+
handler.post {
875+
webView.evaluateJavascript(
876+
"window.blockInserter?.insertBlock(${JSONObject.quote(blockId)});",
877+
null,
878+
)
879+
}
880+
}
881+
882+
private fun dismissBlockInserter() {
883+
if (!isEditorLoaded) return
884+
handler.post {
885+
webView.evaluateJavascript("window.blockInserter?.onClose?.();", null)
886+
}
887+
}
888+
868889
@JavascriptInterface
869890
fun onEditorExceptionLogged(exception: String) {
870891
val parsedException = GutenbergJsException.fromString(exception)
871892
logJsExceptionListener?.onLogJsException(parsedException)
872893
}
873894

874895
@JavascriptInterface
875-
fun showBlockPicker() {
876-
Log.i("GutenbergView", "BlockPickerShouldShow")
896+
fun showBlockInserter(payload: String) {
897+
val parsed = try {
898+
BlockInserterPayload.fromJson(payload)
899+
} catch (e: Exception) {
900+
Log.e("GutenbergView", "Failed to parse showBlockInserter payload", e)
901+
handler.post {
902+
Toast.makeText(
903+
context,
904+
R.string.gbk_block_inserter_failure,
905+
Toast.LENGTH_LONG,
906+
).show()
907+
}
908+
return
909+
}
910+
911+
handler.post { presentBlockInserter(parsed) }
912+
}
913+
914+
private fun presentBlockInserter(payload: BlockInserterPayload) {
915+
blockInserterDialog?.dismiss()
916+
val dialog = BlockPickerDialog(
917+
context = context,
918+
payload = payload,
919+
onBlockSelected = { block -> insertBlock(block.id) },
920+
)
921+
dialog.setOnDismissListener {
922+
if (blockInserterDialog === dialog) {
923+
blockInserterDialog = null
924+
}
925+
dismissBlockInserter()
926+
}
927+
blockInserterDialog = dialog
928+
dialog.show()
877929
}
878930

879931
@JavascriptInterface
@@ -986,6 +1038,8 @@ class GutenbergView : FrameLayout {
9861038
networkRequestListener = null
9871039
requestInterceptor = DefaultGutenbergRequestInterceptor()
9881040
latestContentProvider = null
1041+
blockInserterDialog?.dismiss()
1042+
blockInserterDialog = null
9891043
handler.removeCallbacksAndMessages(null)
9901044
webView.destroy()
9911045
}

0 commit comments

Comments
 (0)