Skip to content
Merged
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
2 changes: 1 addition & 1 deletion android/Gutenberg/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ android {
}

defaultConfig {
minSdk = 22
minSdk = 24

buildConfigField(
"String",
Expand Down
6 changes: 4 additions & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ android {

defaultConfig {
applicationId = "com.example.gutenbergkit"
minSdk = 22
minSdk = 24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that bumping to 24 broke loading the development server from an IP address on the development machine, that it may include tighter default security.

It seems that one now has to add an explicit entry for the IP in the network security configuration. Is there a better way to allow the development server to load?

<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">10.0.0.2</domain>
</domain-config>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not something I am familiar with :( I've made this bump because wordpress-rs requires it, although I don't remember why.

Unless this is a major issue for you, I think it's fine to go with a temporary solution like the one you mentioned above. If it's major, I can look into it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While annoying, it's not a blocker for my workflow; I can work around it.

The bigger concern is someone less familiar with the project encountering failed development server loads without knowledge of this workaround. Also, we don't want to commit one-off IP addresses to this configuration file. I'll try to find a solution soon.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, please let me know before spending any significant time on this. I'll double check that we need minSdk = 24 for wordpress-rs.

If you want, we can remove wordpress-rs, but I find having a proper authentication flow easier to work with than always having to figure out the authentication token, updating a config document etc. Having said that, I don't plan to work in this repo too much, so whatever works best for you is fine with me.

targetSdk = 34
versionCode = 1
versionName = "1.0"
Expand Down Expand Up @@ -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)
}
}
16 changes: 15 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,27 @@
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="authorized"
android:scheme="gutenbergkit" >
</data>
</intent-filter>
</activity>
<activity
android:name=".EditorActivity"
android:exported="false" />
</application>
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
Original file line number Diff line number Diff line change
@@ -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.widget.TextView>(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}")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ConfigurationItem>,
private val onItemClick: (ConfigurationItem) -> Unit,
private val onItemLongClick: (ConfigurationItem) -> Boolean
) : RecyclerView.Adapter<ConfigurationAdapter.ViewHolder>() {
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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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<ConfigurationItem>) {
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<ConfigurationItem.RemoteEditor> {
val savedData = sharedPrefs.getString(KEY_REMOTE_CONFIGS, null) ?: return emptyList()
val configurations = mutableListOf<ConfigurationItem.RemoteEditor>()

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
}
}
Original file line number Diff line number Diff line change
@@ -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<EditorConfiguration>(MainActivity.EXTRA_CONFIGURATION)
} ?: EditorConfiguration.builder().build()

val gbView = findViewById<GutenbergView>(R.id.gutenbergView)
gbView.start(configuration)
}
}
Loading