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 85f71a07e..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" @@ -43,8 +43,10 @@ dependencies { implementation(libs.androidx.activity) 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) androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5f1e529af..810556ebd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,13 +15,27 @@ tools:targetApi="31"> + android:exported="true" + android:launchMode="singleTop"> + + + + + + + + + \ No newline at end of file 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 new file mode 100644 index 000000000..bd44e3a00 --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -0,0 +1,44 @@ +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 +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 + } + + if (0 != (applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE)) { + 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( + MainActivity.EXTRA_CONFIGURATION, + EditorConfiguration::class.java + ) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(MainActivity.EXTRA_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..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,37 +1,74 @@ package com.example.gutenbergkit +import android.content.Intent import android.os.Bundle -import android.webkit.WebView -import androidx.activity.enableEdgeToEdge +import android.widget.EditText +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() { +class MainActivity : AppCompatActivity(), AuthenticationManager.AuthenticationCallback { + private lateinit var recyclerView: RecyclerView + private lateinit var adapter: ConfigurationAdapter + private val configurations = mutableListOf() + private lateinit var configurationStorage: ConfigurationStorage + private lateinit var authenticationManager: AuthenticationManager + + companion object { + const val EXTRA_CONFIGURATION = "configuration" + } 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) + configurationStorage = ConfigurationStorage(this) + authenticationManager = AuthenticationManager(this) - 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) + + // Load saved configurations + configurations.addAll(configurationStorage.loadConfigurations()) + + 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 + findViewById(R.id.addConfigurationFab).setOnClickListener { + showAddConfigurationDialog() + } + } + + private fun createBundledConfiguration(): EditorConfiguration = + createCommonConfigurationBuilder() .setPlugins(false) - .setHideTitle(false) .setSiteURL("") .setSiteApiRoot("") .setSiteApiNamespace(arrayOf()) @@ -41,6 +78,88 @@ 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.siteApiRoot) + .setSiteApiNamespace(arrayOf("wp/v2")) + .setNamespaceExcludedPaths(arrayOf()) + .setAuthHeader(config.authHeader) + .build() + + private fun createCommonConfigurationBuilder(): EditorConfiguration.Builder = + EditorConfiguration.builder() + .setTitle("") + .setContent("") + .setPostType("post") + .setThemeStyles(false) + .setHideTitle(false) + .setWebViewGlobals(emptyList()) + .setCookies(emptyMap()) + + private fun launchEditor(configuration: EditorConfiguration) { + val intent = Intent(this, EditorActivity::class.java) + intent.putExtra(EXTRA_CONFIGURATION, configuration) + startActivity(intent) + } + + private fun showAddConfigurationDialog() { + val dialogView = layoutInflater.inflate(R.layout.dialog_configuration, null) + val siteUrlInput = dialogView.findViewById(R.id.siteUrlInput) + + AlertDialog.Builder(this) + .setTitle(getString(R.string.add_remote_configuration)) + .setView(dialogView) + .setPositiveButton(getString(R.string.add)) { dialog, _ -> + val siteUrl = siteUrlInput.text.toString().trim() + if (siteUrl.isNotEmpty()) { + dialog.dismiss() + authenticationManager.startAuthentication(siteUrl, this) + } + } + .setNegativeButton(getString(R.string.cancel), null) + .show() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + authenticationManager.processAuthenticationResult(intent, this) + } + + override 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) + configurationStorage.saveConfigurations(configurations) + } + + override 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) + .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) + configurationStorage.saveConfigurations(configurations) + } + .setNegativeButton(getString(R.string.cancel), null) + .show() } -} +} \ 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..5e2da2270 --- /dev/null +++ b/android/app/src/main/res/layout/activity_configuration.xml @@ -0,0 +1,25 @@ + + + + + + + + \ 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..e4eb918f8 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_configuration.xml @@ -0,0 +1,21 @@ + + + + + + + + + + \ 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..a29b2d1b2 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,27 @@ GutenbergKit + + + GutenbergKit Demo + Bundled Editor + Offline editor with bundled assets + Add remote configuration + + + Add Remote Configuration + Site URL + Add + Cancel + Delete + Delete Site + Are you sure you want to delete this site configuration? + + + Authentication Failed + OK + Discovering Site + Finding API root and authentication URL... + + + Editor \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 7be119596..3165d57f5 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" @@ -13,6 +13,9 @@ 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' [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -28,6 +31,9 @@ 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" } [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") + } + } } }