Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions android/Gutenberg/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<!-- Used by the block inserter media strip to preview recent device photos.
Inherited in the merged manifest, so it shows up in the host's Play
Store data-safety disclosure. Hosts that don't render the inserter or
already declare these permissions for their own media flows can opt
out via the manifest merger:

<uses-permission
android:name="android.permission.READ_MEDIA_IMAGES"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
tools:node="remove" />

See docs/integration.md (Android → Manifest Permissions) for details. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />

<application>
<!-- Exposes a writeable temp-file URI to the camera app so captures can
come back to us without the host app having to configure its own
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ import kotlinx.coroutines.launch
import org.json.JSONException
import org.json.JSONObject
import org.wordpress.gutenberg.inserter.BlockPickerDialog
import org.wordpress.gutenberg.inserter.clearPhotoPreferences
import org.wordpress.gutenberg.inserter.warmupPhotoPrefs
import kotlinx.serialization.encodeToString
import org.wordpress.gutenberg.media.MediaFileManager
import org.wordpress.gutenberg.media.MediaInfo
import org.wordpress.gutenberg.media.MediaInfoJson
import org.wordpress.gutenberg.media.MediaPathHandler
import org.wordpress.gutenberg.media.StoredMedia
import org.wordpress.gutenberg.model.BlockInserterPayload
import org.wordpress.gutenberg.model.EditorConfiguration
import org.wordpress.gutenberg.model.EditorDependencies
Expand Down Expand Up @@ -96,8 +104,9 @@ class GutenbergView : FrameLayout {
private val webView: WebView
private var isEditorLoaded = false
private var didFireEditorLoaded = false
private lateinit var assetLoader: WebViewAssetLoader
private lateinit var assetDomain: String
private var assetLoader: WebViewAssetLoader by writeOnce()
private var assetDomain: String by writeOnce()
private var assetScheme: String by writeOnce()
private val configuration: EditorConfiguration
private lateinit var dependencies: EditorDependencies

Expand Down Expand Up @@ -219,10 +228,24 @@ class GutenbergView : FrameLayout {
this.configuration = configuration
this.coroutineScope = coroutineScope

// Initialize the asset loader now that context is available
assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/assets/", AssetsPathHandler(context))
.build()
// Warm the block-inserter's photo-prefs cache off the main thread now,
// well before the user can navigate to the inserter. By the time they
// tap `+`, the prefs read in `MediaStrip` is synchronous from the
// process-wide cache — no async-load placeholder, no visible flash.
warmupPhotoPrefs(context)

// Sweep stale media imports off the UI thread before the inserter can
// trigger them — `listFiles()` + per-file `delete()` would otherwise
// jank the first Camera tap, where the cleanup latch would flip on
// the main thread. Idempotent via `MediaFileManager`'s process-wide
// latch, so re-firing across `GutenbergView` instances is harmless.
val appContext = context.applicationContext
coroutineScope.launch(Dispatchers.IO) {
MediaFileManager.primeCleanup(appContext)
}

// The asset loader is built in `loadEditor`, where the configured
// `assetDomain` and HTTP-allowance flag are known.

// Create the internal WebView as first child (behind overlays)
webView = WebView(context).apply {
Expand Down Expand Up @@ -542,19 +565,23 @@ class GutenbergView : FrameLayout {
// avoid accidentally downgrading asset traffic for production sites.
val siteUri = Uri.parse(configuration.siteURL)
val isLocalHttpSite = siteUri.scheme == "http" && siteUri.host in LOCAL_HOSTS
assetScheme = if (isLocalHttpSite) "http" else "https"
assetLoader = WebViewAssetLoader.Builder()
.setDomain(assetDomain)
.setHttpAllowed(isLocalHttpSite)
.addPathHandler("/assets/", AssetsPathHandler(this.context))
.addPathHandler(
MediaFileManager.MEDIA_PATH_PREFIX,
MediaPathHandler(MediaFileManager.uploadsDir(this.context)),
)
.build()

// Transition to spinner phase (WebView initialization)
showSpinnerPhase()

initializeWebView()

val scheme = if (isLocalHttpSite) "http" else "https"
val assetUrl = "$scheme://$assetDomain$ASSET_PATH_INDEX"
val assetUrl = "$assetScheme://$assetDomain$ASSET_PATH_INDEX"
val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty {
assetUrl
}
Expand Down Expand Up @@ -879,6 +906,28 @@ class GutenbergView : FrameLayout {
}
}

/**
* Hands a list of inserter-sourced media to the JS editor via
* `window.blockInserter.insertMedia(...)`. URL stamping happens here, not
* in `MediaFileManager`, so the URLs match the live `assetDomain` /
* `assetScheme` rather than the build-time defaults — keeping the media
* same-origin against the editor bundle however the asset loader is
* configured.
*/
internal fun insertMediaFromInserter(stored: List<StoredMedia>) {
if (!isEditorLoaded || stored.isEmpty()) return
val media = stored.map {
MediaInfo(url = mediaUrlFor(assetScheme, assetDomain, it.fileName), type = it.type)
}
val payload = MediaInfoJson.encodeToString(media)
handler.post {
webView.evaluateJavascript(
"window.blockInserter?.insertMedia($payload);",
null,
)
}
}

private fun dismissBlockInserter() {
if (!isEditorLoaded) return
handler.post {
Expand Down Expand Up @@ -917,6 +966,7 @@ class GutenbergView : FrameLayout {
context = context,
payload = payload,
onBlockSelected = { block -> insertBlock(block.id) },
onMediaSelected = { media -> insertMediaFromInserter(media) },
)
dialog.setOnDismissListener {
if (blockInserterDialog === dialog) {
Expand Down Expand Up @@ -1090,6 +1140,20 @@ class GutenbergView : FrameLayout {
}

companion object {
/**
* Clears the block inserter's photo-library preferences (rationale
* rejection + first-prompt tracking). Call from a host-app settings
* screen if you want users to re-see the rationale after dismissing it.
*
* The OS-level photo permission itself is not affected — only the
* in-app flags. The library's media permissions are declared in the
* library's manifest and inherited via manifest merging; see
* docs/integration.md (Android → Manifest Permissions) for the opt-out.
*/
fun resetBlockPickerPhotoPreferences(context: Context) {
clearPhotoPreferences(context)
}

/** Hosts that are safe to serve assets over HTTP (local development only). */
private val LOCAL_HOSTS = setOf("localhost", "127.0.0.1", "10.0.2.2")

Expand Down Expand Up @@ -1166,6 +1230,15 @@ data class Media(
}
}

private fun mediaUrlFor(scheme: String, domain: String, fileName: String): String =
Uri.Builder()
.scheme(scheme)
.authority(domain)
.appendEncodedPath(MediaFileManager.MEDIA_PATH_PREFIX.trim('/'))
.appendPath(fileName)
.build()
.toString()

enum class MediaType(var label: String) {
IMAGE("image"),
VIDEO("video"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.wordpress.gutenberg

import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

/**
* Property delegate that allows exactly one assignment per instance. A second
* `set` throws `IllegalStateException`; reading before any assignment throws
* `UninitializedPropertyAccessException`. Use for `lateinit`-shaped fields
* whose initialization is centralized in one method and whose accidental
* reassignment would silently overwrite state — e.g., the asset loader, whose
* registered path handlers were lost when an earlier refactor introduced a
* second builder.
*/
internal class WriteOnce<T : Any> : ReadWriteProperty<Any?, T> {
private var value: T? = null

override fun getValue(thisRef: Any?, property: KProperty<*>): T =
value ?: throw UninitializedPropertyAccessException(
"Property ${property.name} has not been initialized",
)

override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
check(this.value == null) {
"Property ${property.name} can only be assigned once"
}
this.value = value
}
}

internal fun <T : Any> writeOnce(): ReadWriteProperty<Any?, T> = WriteOnce()
Loading
Loading