From 065cf165e183ecf2a5f0ed8be397df425e60df6c Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 16 Jul 2025 18:45:51 -0400 Subject: [PATCH 01/11] Implement a basic site selector in Android demo app --- android/app/build.gradle.kts | 1 + android/app/src/main/AndroidManifest.xml | 3 + .../example/gutenbergkit/EditorActivity.kt | 38 ++++ .../com/example/gutenbergkit/MainActivity.kt | 198 ++++++++++++++++-- .../res/layout/activity_configuration.xml | 24 +++ .../src/main/res/layout/activity_editor.xml | 19 ++ .../main/res/layout/dialog_configuration.xml | 43 ++++ .../main/res/layout/item_configuration.xml | 24 +++ android/app/src/main/res/values/strings.xml | 20 ++ 9 files changed, 349 insertions(+), 21 deletions(-) create mode 100644 android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt create mode 100644 android/app/src/main/res/layout/activity_configuration.xml create mode 100644 android/app/src/main/res/layout/activity_editor.xml create mode 100644 android/app/src/main/res/layout/dialog_configuration.xml create mode 100644 android/app/src/main/res/layout/item_configuration.xml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 85f71a07e..fcc8f7d1e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.webkit) + implementation("androidx.recyclerview:recyclerview:1.3.2") implementation(project(":Gutenberg")) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5f1e529af..584f985e0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -22,6 +22,9 @@ + \ No newline at end of file diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt new file mode 100644 index 000000000..b99336d83 --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -0,0 +1,38 @@ +package com.example.gutenbergkit + +import android.os.Bundle +import android.webkit.WebView +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import org.wordpress.gutenberg.EditorConfiguration +import org.wordpress.gutenberg.GutenbergView + +class EditorActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContentView(R.layout.activity_editor) + + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.editor)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + WebView.setWebContentsDebuggingEnabled(true) + + // Get the configuration from the intent + val configuration = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra("configuration", EditorConfiguration::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra("configuration") + } ?: EditorConfiguration.builder().build() + + val gbView = findViewById(R.id.gutenbergView) + gbView.start(configuration) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt index 421f5807d..867b2e217 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -1,37 +1,71 @@ package com.example.gutenbergkit +import android.content.Intent import android.os.Bundle -import android.webkit.WebView -import androidx.activity.enableEdgeToEdge +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import org.wordpress.gutenberg.GutenbergView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.floatingactionbutton.FloatingActionButton import org.wordpress.gutenberg.EditorConfiguration class MainActivity : AppCompatActivity() { + private lateinit var recyclerView: RecyclerView + private lateinit var adapter: ConfigurationAdapter + private val configurations = mutableListOf() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContentView(R.layout.activity_main) - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } + setContentView(R.layout.activity_configuration) - WebView.setWebContentsDebuggingEnabled(true) + title = getString(R.string.demo_title) - val gbView = findViewById(R.id.gutenbergView) + recyclerView = findViewById(R.id.configurationsRecyclerView) + recyclerView.layoutManager = LinearLayoutManager(this) - val config = EditorConfiguration.builder() - .setTitle("") - .setContent("") - .setPostType("post") - .setThemeStyles(false) + // Add default bundled editor configuration + configurations.add(ConfigurationItem.BundledEditor) + + // Add a sample remote configuration (similar to iOS template) + configurations.add( + ConfigurationItem.RemoteEditor( + name = getString(R.string.sample_site), + siteUrl = "", + authHeader = "" + ) + ) + + adapter = ConfigurationAdapter(configurations) { config -> + when (config) { + is ConfigurationItem.BundledEditor -> launchEditor(createBundledConfiguration()) + is ConfigurationItem.RemoteEditor -> { + if (config.siteUrl.isEmpty()) { + // Show dialog to configure the site + showEditConfigurationDialog(config) + } else { + launchEditor(createRemoteConfiguration(config)) + } + } + } + } + recyclerView.adapter = adapter + + // Add FAB for adding new remote configurations + findViewById(R.id.addConfigurationFab).setOnClickListener { + showAddConfigurationDialog() + } + } + + private fun createBundledConfiguration(): EditorConfiguration = + createCommonConfigurationBuilder() .setPlugins(false) - .setHideTitle(false) .setSiteURL("") .setSiteApiRoot("") .setSiteApiNamespace(arrayOf()) @@ -41,6 +75,128 @@ class MainActivity : AppCompatActivity() { .setCookies(emptyMap()) .build() - gbView.start(config) + private fun createRemoteConfiguration(config: ConfigurationItem.RemoteEditor): EditorConfiguration = + createCommonConfigurationBuilder() + .setPlugins(true) // Enable plugins for remote editor + .setSiteURL(config.siteUrl) + .setSiteApiRoot("${config.siteUrl}/wp-json/") + .setSiteApiNamespace(arrayOf("wp/v2")) + .setAuthHeader(config.authHeader) + .build() + + private fun createCommonConfigurationBuilder(): EditorConfiguration.Builder = + EditorConfiguration.builder() + .setTitle("") + .setContent("") + .setPostType("post") + .setThemeStyles(false) + .setHideTitle(false) + + private fun launchEditor(configuration: EditorConfiguration) { + val intent = Intent(this, EditorActivity::class.java) + intent.putExtra("configuration", configuration) + startActivity(intent) } + + private fun showEditConfigurationDialog(config: ConfigurationItem.RemoteEditor) { + val dialogView = layoutInflater.inflate(R.layout.dialog_configuration, null) + val siteUrlInput = dialogView.findViewById(R.id.siteUrlInput) + val authHeaderInput = dialogView.findViewById(R.id.authHeaderInput) + + siteUrlInput.setText(config.siteUrl) + authHeaderInput.setText(config.authHeader) + + AlertDialog.Builder(this) + .setTitle(getString(R.string.edit_configuration)) + .setView(dialogView) + .setPositiveButton(getString(R.string.save)) { _, _ -> + val newConfig = ConfigurationItem.RemoteEditor( + name = config.name, + siteUrl = siteUrlInput.text.toString(), + authHeader = authHeaderInput.text.toString() + ) + val index = configurations.indexOf(config) + configurations[index] = newConfig + adapter.notifyItemChanged(index) + } + .setNegativeButton(getString(R.string.cancel), null) + .show() + } + + private fun showAddConfigurationDialog() { + val dialogView = layoutInflater.inflate(R.layout.dialog_configuration, null) + val siteUrlInput = dialogView.findViewById(R.id.siteUrlInput) + val authHeaderInput = dialogView.findViewById(R.id.authHeaderInput) + + AlertDialog.Builder(this) + .setTitle(getString(R.string.add_remote_configuration)) + .setView(dialogView) + .setPositiveButton(getString(R.string.add)) { _, _ -> + val siteUrl = siteUrlInput.text.toString() + if (siteUrl.isNotEmpty()) { + val newConfig = ConfigurationItem.RemoteEditor( + name = siteUrl.removePrefix("https://").removePrefix("http://") + .substringBefore("/"), + siteUrl = siteUrl, + authHeader = authHeaderInput.text.toString() + ) + configurations.add(newConfig) + adapter.notifyItemInserted(configurations.size - 1) + } + } + .setNegativeButton(getString(R.string.cancel), null) + .show() + } +} + +sealed class ConfigurationItem { + object BundledEditor : ConfigurationItem() + data class RemoteEditor( + val name: String, + val siteUrl: String, + val authHeader: String + ) : ConfigurationItem() } + +class ConfigurationAdapter( + private val items: List, + private val onItemClick: (ConfigurationItem) -> Unit +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_configuration, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = items[position] + when (item) { + is ConfigurationItem.BundledEditor -> { + holder.titleText.text = holder.itemView.context.getString(R.string.bundled_editor) + holder.subtitleText.visibility = View.GONE + } + + is ConfigurationItem.RemoteEditor -> { + holder.titleText.text = item.name + holder.subtitleText.text = if (item.siteUrl.isEmpty()) { + holder.itemView.context.getString(R.string.tap_to_configure) + } else { + item.siteUrl + } + holder.subtitleText.visibility = View.VISIBLE + } + } + + holder.itemView.setOnClickListener { + onItemClick(item) + } + } + + override fun getItemCount() = items.size + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val titleText: TextView = view.findViewById(R.id.titleText) + val subtitleText: TextView = view.findViewById(R.id.subtitleText) + } +} \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_configuration.xml b/android/app/src/main/res/layout/activity_configuration.xml new file mode 100644 index 000000000..635ce122a --- /dev/null +++ b/android/app/src/main/res/layout/activity_configuration.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_editor.xml b/android/app/src/main/res/layout/activity_editor.xml new file mode 100644 index 000000000..c39ed4db4 --- /dev/null +++ b/android/app/src/main/res/layout/activity_editor.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/dialog_configuration.xml b/android/app/src/main/res/layout/dialog_configuration.xml new file mode 100644 index 000000000..5d0cbc978 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_configuration.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/item_configuration.xml b/android/app/src/main/res/layout/item_configuration.xml new file mode 100644 index 000000000..1662e8932 --- /dev/null +++ b/android/app/src/main/res/layout/item_configuration.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9ed8d8ddd..2fdd68e9f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,23 @@ GutenbergKit + + + GutenbergKit Demo + Bundled Editor + Sample Site + Tap to configure + Add remote configuration + + + Edit Configuration + Add Remote Configuration + Site URL + Authorization Header + Note: The Jetpack plugin must be installed on the site + Save + Add + Cancel + + + Editor \ No newline at end of file From 1c1806763ce3105160ed93a21cb523a1555add89 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 16 Jul 2025 19:04:58 -0400 Subject: [PATCH 02/11] Add storage and tap & hold to delete for site configurations in Android demo app --- .../example/gutenbergkit/EditorActivity.kt | 4 +- .../com/example/gutenbergkit/MainActivity.kt | 122 ++++++++++++++---- .../res/layout/activity_configuration.xml | 1 + android/app/src/main/res/values/strings.xml | 6 +- 4 files changed, 103 insertions(+), 30 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index b99336d83..6ca16f223 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -26,10 +26,10 @@ class EditorActivity : AppCompatActivity() { // Get the configuration from the intent val configuration = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra("configuration", EditorConfiguration::class.java) + intent.getParcelableExtra(MainActivity.EXTRA_CONFIGURATION, EditorConfiguration::class.java) } else { @Suppress("DEPRECATION") - intent.getParcelableExtra("configuration") + intent.getParcelableExtra(MainActivity.EXTRA_CONFIGURATION) } ?: EditorConfiguration.builder().build() val gbView = findViewById(R.id.gutenbergView) diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt index 867b2e217..ecebdeda6 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -1,11 +1,12 @@ package com.example.gutenbergkit +import android.content.Context import android.content.Intent +import android.content.SharedPreferences import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Button import android.widget.EditText import android.widget.TextView import androidx.appcompat.app.AlertDialog @@ -14,18 +15,29 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.floatingactionbutton.FloatingActionButton import org.wordpress.gutenberg.EditorConfiguration +import org.json.JSONArray +import org.json.JSONObject +import androidx.core.content.edit class MainActivity : AppCompatActivity() { private lateinit var recyclerView: RecyclerView private lateinit var adapter: ConfigurationAdapter private val configurations = mutableListOf() + private lateinit var sharedPrefs: SharedPreferences + + companion object { + private const val PREFS_NAME = "gutenberg_configs" + private const val KEY_REMOTE_CONFIGS = "remote_configurations" + const val EXTRA_CONFIGURATION = "configuration" + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_configuration) title = getString(R.string.demo_title) + sharedPrefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) recyclerView = findViewById(R.id.configurationsRecyclerView) recyclerView.layoutManager = LinearLayoutManager(this) @@ -33,28 +45,29 @@ class MainActivity : AppCompatActivity() { // Add default bundled editor configuration configurations.add(ConfigurationItem.BundledEditor) - // Add a sample remote configuration (similar to iOS template) - configurations.add( - ConfigurationItem.RemoteEditor( - name = getString(R.string.sample_site), - siteUrl = "", - authHeader = "" - ) - ) + // Load saved configurations + loadSavedConfigurations() - adapter = ConfigurationAdapter(configurations) { config -> - when (config) { - is ConfigurationItem.BundledEditor -> launchEditor(createBundledConfiguration()) - is ConfigurationItem.RemoteEditor -> { - if (config.siteUrl.isEmpty()) { - // Show dialog to configure the site - showEditConfigurationDialog(config) - } else { + adapter = ConfigurationAdapter( + configurations, + onItemClick = { config -> + when (config) { + is ConfigurationItem.BundledEditor -> launchEditor(createBundledConfiguration()) + is ConfigurationItem.RemoteEditor -> { launchEditor(createRemoteConfiguration(config)) } } + }, + onItemLongClick = { config -> + when (config) { + is ConfigurationItem.BundledEditor -> false // Can't delete bundled editor + is ConfigurationItem.RemoteEditor -> { + showDeleteDialog(config) + true + } + } } - } + ) recyclerView.adapter = adapter // Add FAB for adding new remote configurations @@ -81,6 +94,7 @@ class MainActivity : AppCompatActivity() { .setSiteURL(config.siteUrl) .setSiteApiRoot("${config.siteUrl}/wp-json/") .setSiteApiNamespace(arrayOf("wp/v2")) + .setNamespaceExcludedPaths(arrayOf()) .setAuthHeader(config.authHeader) .build() @@ -91,10 +105,12 @@ class MainActivity : AppCompatActivity() { .setPostType("post") .setThemeStyles(false) .setHideTitle(false) + .setWebViewGlobals(emptyList()) + .setCookies(emptyMap()) private fun launchEditor(configuration: EditorConfiguration) { val intent = Intent(this, EditorActivity::class.java) - intent.putExtra("configuration", configuration) + intent.putExtra(EXTRA_CONFIGURATION, configuration) startActivity(intent) } @@ -118,6 +134,7 @@ class MainActivity : AppCompatActivity() { val index = configurations.indexOf(config) configurations[index] = newConfig adapter.notifyItemChanged(index) + saveConfigurations() } .setNegativeButton(getString(R.string.cancel), null) .show() @@ -142,11 +159,61 @@ class MainActivity : AppCompatActivity() { ) configurations.add(newConfig) adapter.notifyItemInserted(configurations.size - 1) + saveConfigurations() } } .setNegativeButton(getString(R.string.cancel), null) .show() } + + private fun showDeleteDialog(config: ConfigurationItem.RemoteEditor) { + AlertDialog.Builder(this) + .setTitle(getString(R.string.delete_site_title)) + .setMessage(getString(R.string.delete_site_message)) + .setPositiveButton(getString(R.string.delete)) { _, _ -> + val index = configurations.indexOf(config) + configurations.removeAt(index) + adapter.notifyItemRemoved(index) + saveConfigurations() + } + .setNegativeButton(getString(R.string.cancel), null) + .show() + } + + private fun saveConfigurations() { + val jsonArray = JSONArray() + configurations.forEach { config -> + if (config is ConfigurationItem.RemoteEditor) { + val jsonObject = JSONObject().apply { + put("name", config.name) + put("siteUrl", config.siteUrl) + put("authHeader", config.authHeader) + } + jsonArray.put(jsonObject) + } + } + sharedPrefs.edit { + putString(KEY_REMOTE_CONFIGS, jsonArray.toString()) + } + } + + private fun loadSavedConfigurations() { + val savedData = sharedPrefs.getString(KEY_REMOTE_CONFIGS, null) ?: return + try { + val jsonArray = JSONArray(savedData) + for (i in 0 until jsonArray.length()) { + val jsonObject = jsonArray.getJSONObject(i) + val config = ConfigurationItem.RemoteEditor( + name = jsonObject.getString("name"), + siteUrl = jsonObject.getString("siteUrl"), + authHeader = jsonObject.getString("authHeader") + ) + configurations.add(config) + } + } catch (e: Exception) { + // Ignore parsing errors + } + } } sealed class ConfigurationItem { @@ -160,7 +227,8 @@ sealed class ConfigurationItem { class ConfigurationAdapter( private val items: List, - private val onItemClick: (ConfigurationItem) -> Unit + private val onItemClick: (ConfigurationItem) -> Unit, + private val onItemLongClick: (ConfigurationItem) -> Boolean ) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { @@ -174,16 +242,14 @@ class ConfigurationAdapter( when (item) { is ConfigurationItem.BundledEditor -> { holder.titleText.text = holder.itemView.context.getString(R.string.bundled_editor) - holder.subtitleText.visibility = View.GONE + holder.subtitleText.text = + holder.itemView.context.getString(R.string.bundled_editor_subtitle) + holder.subtitleText.visibility = View.VISIBLE } is ConfigurationItem.RemoteEditor -> { holder.titleText.text = item.name - holder.subtitleText.text = if (item.siteUrl.isEmpty()) { - holder.itemView.context.getString(R.string.tap_to_configure) - } else { - item.siteUrl - } + holder.subtitleText.text = item.siteUrl holder.subtitleText.visibility = View.VISIBLE } } @@ -191,6 +257,10 @@ class ConfigurationAdapter( holder.itemView.setOnClickListener { onItemClick(item) } + + holder.itemView.setOnLongClickListener { + onItemLongClick(item) + } } override fun getItemCount() = items.size diff --git a/android/app/src/main/res/layout/activity_configuration.xml b/android/app/src/main/res/layout/activity_configuration.xml index 635ce122a..5e2da2270 100644 --- a/android/app/src/main/res/layout/activity_configuration.xml +++ b/android/app/src/main/res/layout/activity_configuration.xml @@ -19,6 +19,7 @@ android:layout_margin="16dp" android:contentDescription="@string/add_remote_config_description" android:src="@android:drawable/ic_input_add" + app:backgroundTint="@color/design_default_color_primary" app:tint="@android:color/white" /> \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2fdd68e9f..ea6b3f9bd 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -4,8 +4,7 @@ GutenbergKit Demo Bundled Editor - Sample Site - Tap to configure + Offline editor with bundled assets Add remote configuration @@ -17,6 +16,9 @@ Save Add Cancel + Delete + Delete Site + Are you sure you want to delete this site configuration? Editor From 394bb30ef6261e97c34fb0758c9cf46ad6d27551 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 16 Jul 2025 19:07:06 -0400 Subject: [PATCH 03/11] Remove `showEditConfigurationDialog` --- .../com/example/gutenbergkit/MainActivity.kt | 25 ------------------- android/app/src/main/res/values/strings.xml | 2 -- 2 files changed, 27 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt index ecebdeda6..f9d4d4bf8 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -114,31 +114,6 @@ class MainActivity : AppCompatActivity() { startActivity(intent) } - private fun showEditConfigurationDialog(config: ConfigurationItem.RemoteEditor) { - val dialogView = layoutInflater.inflate(R.layout.dialog_configuration, null) - val siteUrlInput = dialogView.findViewById(R.id.siteUrlInput) - val authHeaderInput = dialogView.findViewById(R.id.authHeaderInput) - - siteUrlInput.setText(config.siteUrl) - authHeaderInput.setText(config.authHeader) - - AlertDialog.Builder(this) - .setTitle(getString(R.string.edit_configuration)) - .setView(dialogView) - .setPositiveButton(getString(R.string.save)) { _, _ -> - val newConfig = ConfigurationItem.RemoteEditor( - name = config.name, - siteUrl = siteUrlInput.text.toString(), - authHeader = authHeaderInput.text.toString() - ) - val index = configurations.indexOf(config) - configurations[index] = newConfig - adapter.notifyItemChanged(index) - saveConfigurations() - } - .setNegativeButton(getString(R.string.cancel), null) - .show() - } private fun showAddConfigurationDialog() { val dialogView = layoutInflater.inflate(R.layout.dialog_configuration, null) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ea6b3f9bd..eeb0a4ff7 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -8,12 +8,10 @@ Add remote configuration - Edit Configuration Add Remote Configuration Site URL Authorization Header Note: The Jetpack plugin must be installed on the site - Save Add Cancel Delete From 80d4ecb8b73c6fcc3df916dfc739d0be062e5e9b Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 16 Jul 2025 19:17:24 -0400 Subject: [PATCH 04/11] Move androidx.recyclerview dependency to libs.versions.toml --- android/app/build.gradle.kts | 4 ++-- android/gradle/libs.versions.toml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fcc8f7d1e..493118718 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -43,9 +43,9 @@ dependencies { implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.webkit) - implementation("androidx.recyclerview:recyclerview:1.3.2") + implementation(libs.androidx.recyclerview) implementation(project(":Gutenberg")) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 7be119596..f1743494b 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -13,6 +13,7 @@ webkit = "1.11.0" gson = "2.8.9" mockito = "4.1.0" robolectric = "4.14.1" +androidx-recyclerview = '1.3.2' [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -28,6 +29,7 @@ gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockito" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "androidx-recyclerview" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From d1557aaf502331c5c92c66732580f9ea9f1d4e9e Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 16 Jul 2025 19:24:04 -0400 Subject: [PATCH 05/11] Update Kotlin to 2.0.21 --- android/gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index f1743494b..11bb335ce 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.7.3" -kotlin = "1.9.0" +kotlin = "2.0.21" coreKtx = "1.13.1" junit = "4.13.2" junitVersion = "1.2.1" From 949627426d40492cb9ffe3bc83b23e8dab4de262 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 16 Jul 2025 19:24:19 -0400 Subject: [PATCH 06/11] Add wordpress-rs as a dependency --- android/Gutenberg/build.gradle.kts | 2 +- android/app/build.gradle.kts | 3 ++- android/gradle/libs.versions.toml | 2 ++ android/settings.gradle.kts | 6 ++++++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/android/Gutenberg/build.gradle.kts b/android/Gutenberg/build.gradle.kts index 45df50310..88a47b276 100644 --- a/android/Gutenberg/build.gradle.kts +++ b/android/Gutenberg/build.gradle.kts @@ -14,7 +14,7 @@ android { } defaultConfig { - minSdk = 22 + minSdk = 24 buildConfigField( "String", diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 493118718..ec47304f5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,7 +9,7 @@ android { defaultConfig { applicationId = "com.example.gutenbergkit" - minSdk = 22 + minSdk = 24 targetSdk = 34 versionCode = 1 versionName = "1.0" @@ -44,6 +44,7 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.androidx.webkit) implementation(libs.androidx.recyclerview) + implementation(libs.wordpress.rs.android) implementation(project(":Gutenberg")) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 11bb335ce..74bf53d72 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -14,6 +14,7 @@ gson = "2.8.9" mockito = "4.1.0" robolectric = "4.14.1" androidx-recyclerview = '1.3.2' +wordpress-rs = 'trunk-503f1da9e067677d1517d09f926b1d038dfa58a1' [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -30,6 +31,7 @@ mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mo mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockito" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "androidx-recyclerview" } +wordpress-rs-android = { group = "rs.wordpress.api", name = "android", version.ref = "wordpress-rs" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 167584105..db039cbee 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -28,6 +28,12 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { + url = uri("https://a8c-libs.s3.amazonaws.com/android") + content { + includeGroup("rs.wordpress.api") + } + } } } From de6a6e1281eed05f30cde06d730c3b472330f33e Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 16 Jul 2025 19:32:03 -0400 Subject: [PATCH 07/11] Mock authentication flow implementation for Android demo app --- .../com/example/gutenbergkit/MainActivity.kt | 44 ++++++++++++++----- .../main/res/layout/dialog_configuration.xml | 22 ---------- android/app/src/main/res/values/strings.xml | 6 ++- 3 files changed, 36 insertions(+), 36 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt index f9d4d4bf8..61eaad441 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -92,7 +92,7 @@ class MainActivity : AppCompatActivity() { createCommonConfigurationBuilder() .setPlugins(true) // Enable plugins for remote editor .setSiteURL(config.siteUrl) - .setSiteApiRoot("${config.siteUrl}/wp-json/") + .setSiteApiRoot(config.siteApiRoot) .setSiteApiNamespace(arrayOf("wp/v2")) .setNamespaceExcludedPaths(arrayOf()) .setAuthHeader(config.authHeader) @@ -118,28 +118,45 @@ class MainActivity : AppCompatActivity() { private fun showAddConfigurationDialog() { val dialogView = layoutInflater.inflate(R.layout.dialog_configuration, null) val siteUrlInput = dialogView.findViewById(R.id.siteUrlInput) - val authHeaderInput = dialogView.findViewById(R.id.authHeaderInput) AlertDialog.Builder(this) .setTitle(getString(R.string.add_remote_configuration)) .setView(dialogView) .setPositiveButton(getString(R.string.add)) { _, _ -> - val siteUrl = siteUrlInput.text.toString() + val siteUrl = siteUrlInput.text.toString().trim() if (siteUrl.isNotEmpty()) { - val newConfig = ConfigurationItem.RemoteEditor( - name = siteUrl.removePrefix("https://").removePrefix("http://") - .substringBefore("/"), - siteUrl = siteUrl, - authHeader = authHeaderInput.text.toString() - ) - configurations.add(newConfig) - adapter.notifyItemInserted(configurations.size - 1) - saveConfigurations() + authenticateWithSite(siteUrl) } } .setNegativeButton(getString(R.string.cancel), null) .show() } + + private fun authenticateWithSite(siteUrl: String) { + // TODO: Implement authentication logic + } + + fun onAuthenticationSuccess(siteUrl: String, siteApiRoot: String, authToken: String) { + val siteName = siteUrl.removePrefix("https://").removePrefix("http://").substringBefore("/") + val newConfig = ConfigurationItem.RemoteEditor( + name = siteName, + siteUrl = siteUrl, + siteApiRoot = siteApiRoot, + authHeader = authToken + ) + configurations.add(newConfig) + adapter.notifyItemInserted(configurations.size - 1) + saveConfigurations() + } + + fun onAuthenticationFailure(errorMessage: String) { + AlertDialog.Builder(this) + .setTitle(getString(R.string.authentication_failed)) + .setMessage(errorMessage) + .setPositiveButton(getString(R.string.ok), null) + .setCancelable(true) + .show() + } private fun showDeleteDialog(config: ConfigurationItem.RemoteEditor) { AlertDialog.Builder(this) @@ -162,6 +179,7 @@ class MainActivity : AppCompatActivity() { val jsonObject = JSONObject().apply { put("name", config.name) put("siteUrl", config.siteUrl) + put("siteApiRoot", config.siteApiRoot) put("authHeader", config.authHeader) } jsonArray.put(jsonObject) @@ -181,6 +199,7 @@ class MainActivity : AppCompatActivity() { val config = ConfigurationItem.RemoteEditor( name = jsonObject.getString("name"), siteUrl = jsonObject.getString("siteUrl"), + siteApiRoot = jsonObject.optString("siteApiRoot", jsonObject.getString("siteUrl") + "/wp-json/"), authHeader = jsonObject.getString("authHeader") ) configurations.add(config) @@ -196,6 +215,7 @@ sealed class ConfigurationItem { data class RemoteEditor( val name: String, val siteUrl: String, + val siteApiRoot: String, val authHeader: String ) : ConfigurationItem() } diff --git a/android/app/src/main/res/layout/dialog_configuration.xml b/android/app/src/main/res/layout/dialog_configuration.xml index 5d0cbc978..e4eb918f8 100644 --- a/android/app/src/main/res/layout/dialog_configuration.xml +++ b/android/app/src/main/res/layout/dialog_configuration.xml @@ -18,26 +18,4 @@ - - - - - - - - \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index eeb0a4ff7..df0932ee9 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -10,13 +10,15 @@ Add Remote Configuration Site URL - Authorization Header - Note: The Jetpack plugin must be installed on the site Add Cancel Delete Delete Site Are you sure you want to delete this site configuration? + + + Authentication Failed + OK Editor From 4833412d373e7a5e0bc1fd27c5e57b5c9cfc5bd4 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 16 Jul 2025 20:07:25 -0400 Subject: [PATCH 08/11] Implement authentication flow in Android demo app --- android/app/src/main/AndroidManifest.xml | 13 ++- .../com/example/gutenbergkit/MainActivity.kt | 79 +++++++++++++++++-- android/gradle/libs.versions.toml | 2 + 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 584f985e0..810556ebd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,12 +15,23 @@ tools:targetApi="31"> + android:exported="true" + android:launchMode="singleTop"> + + + + + + + + () private lateinit var sharedPrefs: SharedPreferences + private var currentApiRootUrl: String? = null companion object { private const val PREFS_NAME = "gutenberg_configs" @@ -125,17 +131,73 @@ class MainActivity : AppCompatActivity() { .setPositiveButton(getString(R.string.add)) { _, _ -> val siteUrl = siteUrlInput.text.toString().trim() if (siteUrl.isNotEmpty()) { - authenticateWithSite(siteUrl) + autoDiscovery(siteUrl) } } .setNegativeButton(getString(R.string.cancel), null) .show() } - - private fun authenticateWithSite(siteUrl: String) { - // TODO: Implement authentication logic + + private fun autoDiscovery(siteUrl: String) = runBlocking { + when (val apiDiscoveryResult = WpLoginClient().apiDiscovery(siteUrl)) { + is ApiDiscoveryResult.Success -> { + val success = apiDiscoveryResult.success + val apiRootUrl = success.apiRootUrl.url() + val applicationPasswordAuthenticationUrl = + success.applicationPasswordsAuthenticationUrl.url() + authenticateWithSite(apiRootUrl, applicationPasswordAuthenticationUrl) + } + else -> onAuthenticationFailure("Failed to find api root: $apiDiscoveryResult") + } + } + + private fun authenticateWithSite( + apiRootUrl: String, + applicationPasswordAuthenticationUrl: String + ) { + // Store the API root URL for use in onNewIntent + currentApiRootUrl = apiRootUrl + + val uriBuilder = applicationPasswordAuthenticationUrl.toUri().buildUpon() + + uriBuilder + .appendQueryParameter("app_name", "GutenbergKitAndroidDemoApp") + .appendQueryParameter("app_id", "00000000-0000-4000-9000-000000000000") + .appendQueryParameter("success_url", "gutenbergkit://authorized") + + uriBuilder.build().let { uri -> + val i = Intent(Intent.ACTION_VIEW, uri) + startActivity(i) + } } - + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + intent.data?.let { + val siteUrl = it.getQueryParameter("site_url") + ?: throw IllegalStateException("site_url is missing from authentication") + val username = it.getQueryParameter("user_login") + ?: throw IllegalStateException("username is missing from authentication") + val password = it.getQueryParameter("password") + ?: throw IllegalStateException("password is missing from authentication") + + val siteApiRoot = currentApiRootUrl + ?: throw IllegalStateException("API root URL is not available") + currentApiRootUrl = null + val authToken = "Basic " + Base64.encodeToString( + "$username:$password".toByteArray(), + Base64.NO_WRAP + ) + + onAuthenticationSuccess( + siteUrl, + siteApiRoot, + authToken + ) + } + } + fun onAuthenticationSuccess(siteUrl: String, siteApiRoot: String, authToken: String) { val siteName = siteUrl.removePrefix("https://").removePrefix("http://").substringBefore("/") val newConfig = ConfigurationItem.RemoteEditor( @@ -148,7 +210,7 @@ class MainActivity : AppCompatActivity() { adapter.notifyItemInserted(configurations.size - 1) saveConfigurations() } - + fun onAuthenticationFailure(errorMessage: String) { AlertDialog.Builder(this) .setTitle(getString(R.string.authentication_failed)) @@ -199,7 +261,10 @@ class MainActivity : AppCompatActivity() { val config = ConfigurationItem.RemoteEditor( name = jsonObject.getString("name"), siteUrl = jsonObject.getString("siteUrl"), - siteApiRoot = jsonObject.optString("siteApiRoot", jsonObject.getString("siteUrl") + "/wp-json/"), + siteApiRoot = jsonObject.optString( + "siteApiRoot", + jsonObject.getString("siteUrl") + "/wp-json/" + ), authHeader = jsonObject.getString("authHeader") ) configurations.add(config) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 74bf53d72..3165d57f5 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -13,6 +13,7 @@ webkit = "1.11.0" gson = "2.8.9" mockito = "4.1.0" robolectric = "4.14.1" +kotlinx-coroutines = '1.10.2' androidx-recyclerview = '1.3.2' wordpress-rs = 'trunk-503f1da9e067677d1517d09f926b1d038dfa58a1' @@ -30,6 +31,7 @@ gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockito" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "androidx-recyclerview" } wordpress-rs-android = { group = "rs.wordpress.api", name = "android", version.ref = "wordpress-rs" } From e335af1a92f64f3da3d36688b85d9dc9864c06ed Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 16 Jul 2025 20:23:37 -0400 Subject: [PATCH 09/11] Show a progress dialog during auto discovery in Android demo --- .../com/example/gutenbergkit/MainActivity.kt | 54 +++++++++++++++---- android/app/src/main/res/values/strings.xml | 2 + 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt index a9d32fb6c..87ce1f84d 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -19,10 +19,14 @@ import org.json.JSONArray import org.json.JSONObject import androidx.core.content.edit import androidx.core.net.toUri -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import rs.wordpress.api.kotlin.ApiDiscoveryResult import rs.wordpress.api.kotlin.WpLoginClient import android.util.Base64 +import uniffi.wp_api.localizeAutoDiscoveryAttemptFailure class MainActivity : AppCompatActivity() { @@ -128,9 +132,10 @@ class MainActivity : AppCompatActivity() { AlertDialog.Builder(this) .setTitle(getString(R.string.add_remote_configuration)) .setView(dialogView) - .setPositiveButton(getString(R.string.add)) { _, _ -> + .setPositiveButton(getString(R.string.add)) { dialog, _ -> val siteUrl = siteUrlInput.text.toString().trim() if (siteUrl.isNotEmpty()) { + dialog.dismiss() autoDiscovery(siteUrl) } } @@ -138,16 +143,43 @@ class MainActivity : AppCompatActivity() { .show() } - private fun autoDiscovery(siteUrl: String) = runBlocking { - when (val apiDiscoveryResult = WpLoginClient().apiDiscovery(siteUrl)) { - is ApiDiscoveryResult.Success -> { - val success = apiDiscoveryResult.success - val apiRootUrl = success.apiRootUrl.url() - val applicationPasswordAuthenticationUrl = - success.applicationPasswordsAuthenticationUrl.url() - authenticateWithSite(apiRootUrl, applicationPasswordAuthenticationUrl) + private fun autoDiscovery(siteUrl: String) { + val progressView = layoutInflater.inflate(android.R.layout.simple_list_item_1, null).apply { + findViewById(android.R.id.text1).apply { + text = getString(R.string.finding_api_root) + gravity = android.view.Gravity.CENTER + setPadding(32, 32, 32, 32) + } + } + + val progressDialog = AlertDialog.Builder(this) + .setTitle(getString(R.string.discovering_site)) + .setView(progressView) + .setCancelable(false) + .create() + .also { it.show() } + + CoroutineScope(Dispatchers.IO).launch { + when (val apiDiscoveryResult = WpLoginClient().apiDiscovery(siteUrl)) { + is ApiDiscoveryResult.Success -> { + val success = apiDiscoveryResult.success + val apiRootUrl = success.apiRootUrl.url() + val applicationPasswordAuthenticationUrl = + success.applicationPasswordsAuthenticationUrl.url() + withContext(Dispatchers.Main) { + progressDialog.dismiss() + authenticateWithSite(apiRootUrl, applicationPasswordAuthenticationUrl) + } + } + + else -> { + withContext(Dispatchers.Main) { + progressDialog.dismiss() + // TODO: We should have a helper in "wordpress-rs" to get the localized error without individually matching everything + onAuthenticationFailure("Failed to find api root: $apiDiscoveryResult") + } + } } - else -> onAuthenticationFailure("Failed to find api root: $apiDiscoveryResult") } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index df0932ee9..a29b2d1b2 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -19,6 +19,8 @@ Authentication Failed OK + Discovering Site + Finding API root and authentication URL... Editor From dbe11c84290f50f1d2526e21bb20b26bbc651db2 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 16 Jul 2025 20:33:56 -0400 Subject: [PATCH 10/11] Split MainActivity in Android demo app --- .../gutenbergkit/AuthenticationManager.kt | 118 +++++++++ .../gutenbergkit/ConfigurationAdapter.kt | 52 ++++ .../example/gutenbergkit/ConfigurationItem.kt | 11 + .../gutenbergkit/ConfigurationStorage.kt | 61 +++++ .../example/gutenbergkit/EditorActivity.kt | 5 +- .../com/example/gutenbergkit/MainActivity.kt | 223 +----------------- 6 files changed, 258 insertions(+), 212 deletions(-) create mode 100644 android/app/src/main/java/com/example/gutenbergkit/AuthenticationManager.kt create mode 100644 android/app/src/main/java/com/example/gutenbergkit/ConfigurationAdapter.kt create mode 100644 android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt create mode 100644 android/app/src/main/java/com/example/gutenbergkit/ConfigurationStorage.kt diff --git a/android/app/src/main/java/com/example/gutenbergkit/AuthenticationManager.kt b/android/app/src/main/java/com/example/gutenbergkit/AuthenticationManager.kt new file mode 100644 index 000000000..53d28df5e --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/AuthenticationManager.kt @@ -0,0 +1,118 @@ +package com.example.gutenbergkit + +import android.content.Context +import android.content.Intent +import android.util.Base64 +import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import rs.wordpress.api.kotlin.ApiDiscoveryResult +import rs.wordpress.api.kotlin.WpLoginClient + +class AuthenticationManager(private val context: Context) { + interface AuthenticationCallback { + fun onAuthenticationSuccess(siteUrl: String, siteApiRoot: String, authToken: String) + fun onAuthenticationFailure(errorMessage: String) + } + + private var currentApiRootUrl: String? = null + + fun startAuthentication(siteUrl: String, callback: AuthenticationCallback) { + showProgressDialog { progressDialog -> + CoroutineScope(Dispatchers.IO).launch { + when (val apiDiscoveryResult = WpLoginClient().apiDiscovery(siteUrl)) { + is ApiDiscoveryResult.Success -> { + val success = apiDiscoveryResult.success + val apiRootUrl = success.apiRootUrl.url() + val applicationPasswordAuthenticationUrl = + success.applicationPasswordsAuthenticationUrl.url() + withContext(Dispatchers.Main) { + progressDialog.dismiss() + launchAuthenticationFlow( + apiRootUrl, + applicationPasswordAuthenticationUrl + ) + } + } + + else -> { + withContext(Dispatchers.Main) { + progressDialog.dismiss() + callback.onAuthenticationFailure("Failed to find api root: $apiDiscoveryResult") + } + } + } + } + } + } + + private fun showProgressDialog(onCreated: (AlertDialog) -> Unit) { + val progressView = android.view.LayoutInflater.from(context) + .inflate(android.R.layout.simple_list_item_1, null).apply { + findViewById(android.R.id.text1).apply { + text = context.getString(R.string.finding_api_root) + gravity = android.view.Gravity.CENTER + setPadding(32, 32, 32, 32) + } + } + + val progressDialog = AlertDialog.Builder(context) + .setTitle(context.getString(R.string.discovering_site)) + .setView(progressView) + .setCancelable(false) + .create() + .also { it.show() } + + onCreated(progressDialog) + } + + private fun launchAuthenticationFlow( + apiRootUrl: String, + applicationPasswordAuthenticationUrl: String + ) { + // Store the API root URL for use when processing authentication result + currentApiRootUrl = apiRootUrl + + val uriBuilder = applicationPasswordAuthenticationUrl.toUri().buildUpon() + + uriBuilder + .appendQueryParameter("app_name", "GutenbergKitAndroidDemoApp") + .appendQueryParameter("app_id", "00000000-0000-4000-9000-000000000000") + // Url scheme is defined in AndroidManifest file + .appendQueryParameter("success_url", "gutenbergkit://authorized") + + uriBuilder.build().let { uri -> + val intent = Intent(Intent.ACTION_VIEW, uri) + context.startActivity(intent) + } + } + + fun processAuthenticationResult(intent: Intent, callback: AuthenticationCallback) { + intent.data?.let { data -> + try { + val siteUrl = data.getQueryParameter("site_url") + ?: throw IllegalStateException("site_url is missing from authentication") + val username = data.getQueryParameter("user_login") + ?: throw IllegalStateException("username is missing from authentication") + val password = data.getQueryParameter("password") + ?: throw IllegalStateException("password is missing from authentication") + + val siteApiRoot = currentApiRootUrl + ?: throw IllegalStateException("API root URL is not available") + currentApiRootUrl = null + + val authToken = "Basic " + Base64.encodeToString( + "$username:$password".toByteArray(), + Base64.NO_WRAP + ) + + callback.onAuthenticationSuccess(siteUrl, siteApiRoot, authToken) + } catch (e: Exception) { + callback.onAuthenticationFailure("Authentication error: ${e.message}") + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationAdapter.kt b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationAdapter.kt new file mode 100644 index 000000000..1b053b505 --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationAdapter.kt @@ -0,0 +1,52 @@ +package com.example.gutenbergkit + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView + +class ConfigurationAdapter( + private val items: List, + private val onItemClick: (ConfigurationItem) -> Unit, + private val onItemLongClick: (ConfigurationItem) -> Boolean +) : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_configuration, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = items[position] + when (item) { + is ConfigurationItem.BundledEditor -> { + holder.titleText.text = holder.itemView.context.getString(R.string.bundled_editor) + holder.subtitleText.text = + holder.itemView.context.getString(R.string.bundled_editor_subtitle) + holder.subtitleText.visibility = View.VISIBLE + } + + is ConfigurationItem.RemoteEditor -> { + holder.titleText.text = item.name + holder.subtitleText.text = item.siteUrl + holder.subtitleText.visibility = View.VISIBLE + } + } + + holder.itemView.setOnClickListener { + onItemClick(item) + } + + holder.itemView.setOnLongClickListener { + onItemLongClick(item) + } + } + + override fun getItemCount() = items.size + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val titleText: TextView = view.findViewById(R.id.titleText) + val subtitleText: TextView = view.findViewById(R.id.subtitleText) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt new file mode 100644 index 000000000..66c993bf0 --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt @@ -0,0 +1,11 @@ +package com.example.gutenbergkit + +sealed class ConfigurationItem { + object BundledEditor : ConfigurationItem() + data class RemoteEditor( + val name: String, + val siteUrl: String, + val siteApiRoot: String, + val authHeader: String + ) : ConfigurationItem() +} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationStorage.kt b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationStorage.kt new file mode 100644 index 000000000..02bca946e --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationStorage.kt @@ -0,0 +1,61 @@ +package com.example.gutenbergkit + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import org.json.JSONArray +import org.json.JSONObject + +class ConfigurationStorage(context: Context) { + private val sharedPrefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + companion object { + private const val PREFS_NAME = "gutenberg_configs" + private const val KEY_REMOTE_CONFIGS = "remote_configurations" + } + + fun saveConfigurations(configurations: List) { + val jsonArray = JSONArray() + configurations.forEach { config -> + if (config is ConfigurationItem.RemoteEditor) { + val jsonObject = JSONObject().apply { + put("name", config.name) + put("siteUrl", config.siteUrl) + put("siteApiRoot", config.siteApiRoot) + put("authHeader", config.authHeader) + } + jsonArray.put(jsonObject) + } + } + sharedPrefs.edit { + putString(KEY_REMOTE_CONFIGS, jsonArray.toString()) + } + } + + fun loadConfigurations(): List { + val savedData = sharedPrefs.getString(KEY_REMOTE_CONFIGS, null) ?: return emptyList() + val configurations = mutableListOf() + + try { + val jsonArray = JSONArray(savedData) + for (i in 0 until jsonArray.length()) { + val jsonObject = jsonArray.getJSONObject(i) + val config = ConfigurationItem.RemoteEditor( + name = jsonObject.getString("name"), + siteUrl = jsonObject.getString("siteUrl"), + siteApiRoot = jsonObject.optString( + "siteApiRoot", + jsonObject.getString("siteUrl") + "/wp-json/" + ), + authHeader = jsonObject.getString("authHeader") + ) + configurations.add(config) + } + } catch (e: Exception) { + // Ignore parsing errors + } + + return configurations + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 6ca16f223..9868c9d73 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -26,7 +26,10 @@ class EditorActivity : AppCompatActivity() { // Get the configuration from the intent val configuration = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(MainActivity.EXTRA_CONFIGURATION, EditorConfiguration::class.java) + intent.getParcelableExtra( + MainActivity.EXTRA_CONFIGURATION, + EditorConfiguration::class.java + ) } else { @Suppress("DEPRECATION") intent.getParcelableExtra(MainActivity.EXTRA_CONFIGURATION) diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt index 87ce1f84d..2ba0884a3 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -1,44 +1,23 @@ package com.example.gutenbergkit -import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import android.widget.EditText -import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.floatingactionbutton.FloatingActionButton import org.wordpress.gutenberg.EditorConfiguration -import org.json.JSONArray -import org.json.JSONObject -import androidx.core.content.edit -import androidx.core.net.toUri -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import rs.wordpress.api.kotlin.ApiDiscoveryResult -import rs.wordpress.api.kotlin.WpLoginClient -import android.util.Base64 -import uniffi.wp_api.localizeAutoDiscoveryAttemptFailure - -class MainActivity : AppCompatActivity() { +class MainActivity : AppCompatActivity(), AuthenticationManager.AuthenticationCallback { private lateinit var recyclerView: RecyclerView private lateinit var adapter: ConfigurationAdapter private val configurations = mutableListOf() - private lateinit var sharedPrefs: SharedPreferences - private var currentApiRootUrl: String? = null + private lateinit var configurationStorage: ConfigurationStorage + private lateinit var authenticationManager: AuthenticationManager companion object { - private const val PREFS_NAME = "gutenberg_configs" - private const val KEY_REMOTE_CONFIGS = "remote_configurations" const val EXTRA_CONFIGURATION = "configuration" } @@ -47,7 +26,8 @@ class MainActivity : AppCompatActivity() { setContentView(R.layout.activity_configuration) title = getString(R.string.demo_title) - sharedPrefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + configurationStorage = ConfigurationStorage(this) + authenticationManager = AuthenticationManager(this) recyclerView = findViewById(R.id.configurationsRecyclerView) recyclerView.layoutManager = LinearLayoutManager(this) @@ -56,7 +36,7 @@ class MainActivity : AppCompatActivity() { configurations.add(ConfigurationItem.BundledEditor) // Load saved configurations - loadSavedConfigurations() + configurations.addAll(configurationStorage.loadConfigurations()) adapter = ConfigurationAdapter( configurations, @@ -124,7 +104,6 @@ class MainActivity : AppCompatActivity() { startActivity(intent) } - private fun showAddConfigurationDialog() { val dialogView = layoutInflater.inflate(R.layout.dialog_configuration, null) val siteUrlInput = dialogView.findViewById(R.id.siteUrlInput) @@ -136,101 +115,19 @@ class MainActivity : AppCompatActivity() { val siteUrl = siteUrlInput.text.toString().trim() if (siteUrl.isNotEmpty()) { dialog.dismiss() - autoDiscovery(siteUrl) + authenticationManager.startAuthentication(siteUrl, this) } } .setNegativeButton(getString(R.string.cancel), null) .show() } - private fun autoDiscovery(siteUrl: String) { - val progressView = layoutInflater.inflate(android.R.layout.simple_list_item_1, null).apply { - findViewById(android.R.id.text1).apply { - text = getString(R.string.finding_api_root) - gravity = android.view.Gravity.CENTER - setPadding(32, 32, 32, 32) - } - } - - val progressDialog = AlertDialog.Builder(this) - .setTitle(getString(R.string.discovering_site)) - .setView(progressView) - .setCancelable(false) - .create() - .also { it.show() } - - CoroutineScope(Dispatchers.IO).launch { - when (val apiDiscoveryResult = WpLoginClient().apiDiscovery(siteUrl)) { - is ApiDiscoveryResult.Success -> { - val success = apiDiscoveryResult.success - val apiRootUrl = success.apiRootUrl.url() - val applicationPasswordAuthenticationUrl = - success.applicationPasswordsAuthenticationUrl.url() - withContext(Dispatchers.Main) { - progressDialog.dismiss() - authenticateWithSite(apiRootUrl, applicationPasswordAuthenticationUrl) - } - } - - else -> { - withContext(Dispatchers.Main) { - progressDialog.dismiss() - // TODO: We should have a helper in "wordpress-rs" to get the localized error without individually matching everything - onAuthenticationFailure("Failed to find api root: $apiDiscoveryResult") - } - } - } - } - } - - private fun authenticateWithSite( - apiRootUrl: String, - applicationPasswordAuthenticationUrl: String - ) { - // Store the API root URL for use in onNewIntent - currentApiRootUrl = apiRootUrl - - val uriBuilder = applicationPasswordAuthenticationUrl.toUri().buildUpon() - - uriBuilder - .appendQueryParameter("app_name", "GutenbergKitAndroidDemoApp") - .appendQueryParameter("app_id", "00000000-0000-4000-9000-000000000000") - .appendQueryParameter("success_url", "gutenbergkit://authorized") - - uriBuilder.build().let { uri -> - val i = Intent(Intent.ACTION_VIEW, uri) - startActivity(i) - } - } - override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - - intent.data?.let { - val siteUrl = it.getQueryParameter("site_url") - ?: throw IllegalStateException("site_url is missing from authentication") - val username = it.getQueryParameter("user_login") - ?: throw IllegalStateException("username is missing from authentication") - val password = it.getQueryParameter("password") - ?: throw IllegalStateException("password is missing from authentication") - - val siteApiRoot = currentApiRootUrl - ?: throw IllegalStateException("API root URL is not available") - currentApiRootUrl = null - val authToken = "Basic " + Base64.encodeToString( - "$username:$password".toByteArray(), - Base64.NO_WRAP - ) - - onAuthenticationSuccess( - siteUrl, - siteApiRoot, - authToken - ) - } + authenticationManager.processAuthenticationResult(intent, this) } - fun onAuthenticationSuccess(siteUrl: String, siteApiRoot: String, authToken: String) { + override fun onAuthenticationSuccess(siteUrl: String, siteApiRoot: String, authToken: String) { val siteName = siteUrl.removePrefix("https://").removePrefix("http://").substringBefore("/") val newConfig = ConfigurationItem.RemoteEditor( name = siteName, @@ -240,10 +137,10 @@ class MainActivity : AppCompatActivity() { ) configurations.add(newConfig) adapter.notifyItemInserted(configurations.size - 1) - saveConfigurations() + configurationStorage.saveConfigurations(configurations) } - fun onAuthenticationFailure(errorMessage: String) { + override fun onAuthenticationFailure(errorMessage: String) { AlertDialog.Builder(this) .setTitle(getString(R.string.authentication_failed)) .setMessage(errorMessage) @@ -260,105 +157,9 @@ class MainActivity : AppCompatActivity() { val index = configurations.indexOf(config) configurations.removeAt(index) adapter.notifyItemRemoved(index) - saveConfigurations() + configurationStorage.saveConfigurations(configurations) } .setNegativeButton(getString(R.string.cancel), null) .show() } - - private fun saveConfigurations() { - val jsonArray = JSONArray() - configurations.forEach { config -> - if (config is ConfigurationItem.RemoteEditor) { - val jsonObject = JSONObject().apply { - put("name", config.name) - put("siteUrl", config.siteUrl) - put("siteApiRoot", config.siteApiRoot) - put("authHeader", config.authHeader) - } - jsonArray.put(jsonObject) - } - } - sharedPrefs.edit { - putString(KEY_REMOTE_CONFIGS, jsonArray.toString()) - } - } - - private fun loadSavedConfigurations() { - val savedData = sharedPrefs.getString(KEY_REMOTE_CONFIGS, null) ?: return - try { - val jsonArray = JSONArray(savedData) - for (i in 0 until jsonArray.length()) { - val jsonObject = jsonArray.getJSONObject(i) - val config = ConfigurationItem.RemoteEditor( - name = jsonObject.getString("name"), - siteUrl = jsonObject.getString("siteUrl"), - siteApiRoot = jsonObject.optString( - "siteApiRoot", - jsonObject.getString("siteUrl") + "/wp-json/" - ), - authHeader = jsonObject.getString("authHeader") - ) - configurations.add(config) - } - } catch (e: Exception) { - // Ignore parsing errors - } - } -} - -sealed class ConfigurationItem { - object BundledEditor : ConfigurationItem() - data class RemoteEditor( - val name: String, - val siteUrl: String, - val siteApiRoot: String, - val authHeader: String - ) : ConfigurationItem() -} - -class ConfigurationAdapter( - private val items: List, - private val onItemClick: (ConfigurationItem) -> Unit, - private val onItemLongClick: (ConfigurationItem) -> Boolean -) : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_configuration, parent, false) - return ViewHolder(view) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val item = items[position] - when (item) { - is ConfigurationItem.BundledEditor -> { - holder.titleText.text = holder.itemView.context.getString(R.string.bundled_editor) - holder.subtitleText.text = - holder.itemView.context.getString(R.string.bundled_editor_subtitle) - holder.subtitleText.visibility = View.VISIBLE - } - - is ConfigurationItem.RemoteEditor -> { - holder.titleText.text = item.name - holder.subtitleText.text = item.siteUrl - holder.subtitleText.visibility = View.VISIBLE - } - } - - holder.itemView.setOnClickListener { - onItemClick(item) - } - - holder.itemView.setOnLongClickListener { - onItemLongClick(item) - } - } - - override fun getItemCount() = items.size - - class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { - val titleText: TextView = view.findViewById(R.id.titleText) - val subtitleText: TextView = view.findViewById(R.id.subtitleText) - } } \ No newline at end of file From 6ae54c92a05445a93c370d569debc581c9ec455f Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 16 Jul 2025 20:52:10 -0400 Subject: [PATCH 11/11] Potential fix for code scanning alert no. 415: Android Webview debugging enabled Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../src/main/java/com/example/gutenbergkit/EditorActivity.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 9868c9d73..bd44e3a00 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -2,6 +2,7 @@ package com.example.gutenbergkit import android.os.Bundle import android.webkit.WebView +import android.content.pm.ApplicationInfo import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat @@ -21,7 +22,9 @@ class EditorActivity : AppCompatActivity() { insets } - WebView.setWebContentsDebuggingEnabled(true) + if (0 != (applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE)) { + WebView.setWebContentsDebuggingEnabled(true) + } // Get the configuration from the intent val configuration =