Skip to content

Handle and store application password #21859

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 36 commits into from
May 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
52479ae
Adding logic to the LoginViewModel
adalpari May 5, 2025
af180ed
detekt and style
adalpari May 5, 2025
dfb4deb
Extrractinv the URKl construction into a helper
adalpari May 5, 2025
0cab150
Adding tests
adalpari May 5, 2025
9e44279
clean imports
adalpari May 5, 2025
1003e3d
Removing previous iteration code
adalpari May 5, 2025
1250aa9
Some refactoring
adalpari May 5, 2025
8ed1d19
Fixing null annotation warning
adalpari May 5, 2025
cf59f99
Adding new check function
adalpari May 6, 2025
ae77d90
Saving credentials into database
adalpari May 6, 2025
1a9ea1a
Removing the launch on login
adalpari May 6, 2025
135a172
Running the discovery process inside the site fragment
adalpari May 6, 2025
d9f62e2
Showing login flow inside the site fragment
adalpari May 6, 2025
e43434d
Fixing the flow
adalpari May 6, 2025
a4d1fb2
Showing dialog
adalpari May 7, 2025
c834ad5
Capturing the callback in a different activity
adalpari May 7, 2025
e9f770f
Capturing the correct flow
adalpari May 7, 2025
8141bff
Do not show the dialog the first time the site is opened
adalpari May 7, 2025
e976259
Detakt and style
adalpari May 7, 2025
afbb4cf
Cleaning some code
adalpari May 7, 2025
d87dd25
Injecting SharedPreferences
adalpari May 8, 2025
2671679
Adding some tests
adalpari May 8, 2025
0c46638
Adding tests
adalpari May 8, 2025
76a10cc
detekt
adalpari May 8, 2025
8b510b2
Adding tests to the ApplicationPasswordLoginHelper
adalpari May 8, 2025
8098256
Making helper async with coroutines
adalpari May 8, 2025
3f90a7d
Some refactor
adalpari May 8, 2025
69d41ea
Some cleaning
adalpari May 8, 2025
30a529b
Merge branch 'trunk' into feat/CMM-328-Show-Password-Login-webview-fo…
adalpari May 9, 2025
83aa1bd
Adding authorization checks and tests
adalpari May 9, 2025
60373e9
General cleaning
adalpari May 9, 2025
bfd6e10
Merge branch 'feat/CMM-328-Show-Password-Login-webview-for-background…
adalpari May 9, 2025
a33c41a
Using proper string for saving confirmation
adalpari May 9, 2025
fa0b9f4
Update WordPress/src/main/java/org/wordpress/android/ui/mysite/MySite…
adalpari May 9, 2025
dfcb1b7
Merge remote-tracking branch 'origin/trunk' into feat/CMM-330-Handle-…
adalpari May 9, 2025
3f8a9b5
Merge branch 'trunk' into feat/CMM-330-Handle-and-store-application-p…
adalpari May 12, 2025
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
16 changes: 16 additions & 0 deletions WordPress/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@
</intent-filter>
</activity>

<activity
android:name=".ui.accounts.ApplicationPasswordLoginActivity"
android:theme="@style/LoginTheme.TransparentSystemBars"
android:windowSoftInputMode="adjustResize"
android:exported="true">

<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="app-pass-authorize"
android:scheme="${magicLinkScheme}" />
Comment on lines +135 to +142

Check warning

Code scanning / Android Lint

Application has custom scheme intent filters with missing autoVerify attributes Warning

Custom scheme intent filters should explicitly set the autoVerify attribute to true
</intent-filter>
</activity>

<activity
android:name=".ui.accounts.LoginMagicLinkInterceptActivity"
android:theme="@style/NoDisplay"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package org.wordpress.android.ui.accounts

import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.wordpress.android.R
import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper
import org.wordpress.android.ui.main.BaseAppCompatActivity
import org.wordpress.android.ui.main.WPMainActivity
import org.wordpress.android.util.ToastUtils
import javax.inject.Inject

@AndroidEntryPoint
class ApplicationPasswordLoginActivity: BaseAppCompatActivity() {
@Inject
lateinit var applicationPasswordLoginHelper: ApplicationPasswordLoginHelper

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
tryToSaveCredentialsAndRunMain()
}

private fun tryToSaveCredentialsAndRunMain() {
lifecycleScope.launch {
val dataString = intent.dataString.orEmpty()
val credentialsStored =
applicationPasswordLoginHelper.storeApplicationPasswordCredentialsFrom(dataString)

if (credentialsStored) {
ToastUtils.showToast(
this@ApplicationPasswordLoginActivity,
getString(
R.string.application_password_credentials_stored,
applicationPasswordLoginHelper.getSiteUrlFromUrl(dataString)
)
)
intent.setData(null)
}

val mainActivityIntent =
Intent(this@ApplicationPasswordLoginActivity, WPMainActivity::class.java)
mainActivityIntent.setFlags(
(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_CLEAR_TASK)
)
startActivity(mainActivityIntent)
finish()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
// Attempt Login if this activity was created in response to a user confirming login, and if
// successful clear the intent so we don't reuse the OAuth code if the activity is recreated
boolean loginProcessed = mLoginHelper.tryLoginWithDataString(getIntent().getDataString());

if (loginProcessed) {
getIntent().setData(null);
}
Expand Down Expand Up @@ -699,22 +700,6 @@ public void gotXmlRpcEndpoint(String inputSiteAddress, String endpointAddress) {
LoginUsernamePasswordFragment loginUsernamePasswordFragment =
LoginUsernamePasswordFragment.newInstance(inputSiteAddress, endpointAddress, null, null, false);
slideInFragment(loginUsernamePasswordFragment, true, LoginUsernamePasswordFragment.TAG);

// In the background, run the API discovery test to see if we can add this site for the REST API
String authorizationUrl = mViewModel.runApiDiscovery(inputSiteAddress);
// launchApplicationPasswordFlow(authorizationUrl);
}

public void launchApplicationPasswordFlow(@NonNull String endpointAddress) {
CustomTabsIntent intent = getCustomTabsIntent();

Uri loginUri = Uri.parse(endpointAddress);
try {
intent.launchUrl(this, loginUri);
} catch (SecurityException | ActivityNotFoundException e) {
AppLog.e(AppLog.T.UTILS, "Error opening login uri in CustomTabsIntent, attempting external browser", e);
ActivityLauncher.openUrlExternal(this, loginUri.toString());
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
package org.wordpress.android.ui.accounts

import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.runBlocking
import org.wordpress.android.analytics.AnalyticsTracker
import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayloadScheme
import org.wordpress.android.fluxc.store.SiteStore.ConnectSiteInfoPayload
import org.wordpress.android.ui.accounts.LoginNavigationEvents.ShowNoJetpackSites
import org.wordpress.android.ui.accounts.LoginNavigationEvents.ShowSiteAddressError
import org.wordpress.android.util.BuildConfigWrapper
import org.wordpress.android.viewmodel.Event
import rs.wordpress.api.kotlin.WpLoginClient
import javax.inject.Inject
import kotlin.text.RegexOption.IGNORE_CASE
import org.wordpress.android.ui.accounts.login.WPcomLoginHelper

class LoginViewModel @Inject constructor(
private val buildConfigWrapper: BuildConfigWrapper,
private val wpLoginClient: WpLoginClient,
private val wpComLoginHelper: WPcomLoginHelper
) : ViewModel() {
private val _navigationEvents = MediatorLiveData<Event<LoginNavigationEvents>>()
val navigationEvents: LiveData<Event<LoginNavigationEvents>> = _navigationEvents
Expand All @@ -40,20 +33,4 @@ class LoginViewModel @Inject constructor(
} else {
AuthEmailPayloadScheme.WORDPRESS
}

@Suppress("TooGenericExceptionCaught")
fun runApiDiscovery(url: String): String = runBlocking {
try {
val urlDiscovery = wpLoginClient.apiDiscovery(url)
val authorizationUrl = urlDiscovery.apiDetails.findApplicationPasswordsAuthenticationUrl()
val authorizationUrlComplete = wpComLoginHelper.appendParamsToRestAuthorizationUrl(authorizationUrl)
Log.d("WP_RS", "Found authorization for $url URL: $authorizationUrlComplete")
AnalyticsTracker.track(AnalyticsTracker.Stat.BACKGROUND_REST_AUTODISCOVERY_SUCCESSFUL)
authorizationUrlComplete
} catch (throwable: Throwable) {
Log.e("WP_RS", "VM: Error during API discovery for $url", throwable)
AnalyticsTracker.track(AnalyticsTracker.Stat.BACKGROUND_REST_AUTODISCOVERY_FAILED)
""
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.wordpress.android.ui.accounts.login

import android.util.Log
import androidx.core.net.toUri
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.wordpress.android.fluxc.persistence.SiteSqlUtils
import org.wordpress.android.modules.BG_THREAD
import javax.inject.Inject
import javax.inject.Named

class ApplicationPasswordLoginHelper @Inject constructor(
@param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
private val siteSqlUtils: SiteSqlUtils,
private val uriLoginWrapper: UriLoginWrapper
) {
private var processedAppPasswordData: String? = null

@Suppress("ReturnCount")
suspend fun storeApplicationPasswordCredentialsFrom(url: String): Boolean {
if (url.isEmpty() || url == processedAppPasswordData) {
return false
}

return withContext(bgDispatcher) {
val uriLogin = uriLoginWrapper.parseUriLogin(url)

if (uriLogin.user.isNullOrEmpty() || uriLogin.password.isNullOrEmpty() ) {
false
} else {
val site = siteSqlUtils.getSites().firstOrNull { it.url == uriLogin.siteUrl }
if (site != null) {
site.apiRestUsername = uriLogin.user
site.apiRestPassword = uriLogin.password
siteSqlUtils.insertOrUpdateSite(site)
Log.d("WP_RS", "Saved application password credentials for: ${uriLogin.siteUrl}")
processedAppPasswordData = url
true
} else {
Log.e("WP_RS", "Cannot save application password credentials for: ${uriLogin.siteUrl}")
false
}
}
}
}

fun getSiteUrlFromUrl(url: String): String {
return uriLoginWrapper.parseUriLogin(url).siteUrl.orEmpty()
}

fun appendParamsToRestAuthorizationUrl(authorizationUrl: String?): String {
return if (authorizationUrl.isNullOrEmpty()) {
authorizationUrl.orEmpty()
} else {
authorizationUrl.toUri().buildUpon().apply {
appendQueryParameter("app_name", "android-jetpack-client")
appendQueryParameter("success_url", "jetpack://app-pass-authorize")
}.build().toString()
}
}

/**
* This class is created to wrap the Uri calls and let us unit test the login helper
*/
class UriLoginWrapper @Inject constructor() {
fun parseUriLogin(url: String): UriLogin {
val uri = url.toUri()
return UriLogin(
uri.getQueryParameter("site_url"),
uri.getQueryParameter("user_login"),
uri.getQueryParameter("password")
)
}
}

data class UriLogin(
val siteUrl: String?,
val user: String?,
val password: String?
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import kotlin.coroutines.CoroutineContext
class WPcomLoginHelper @Inject constructor(
private val loginClient: WPcomLoginClient,
private val accountStore: AccountStore,
private val appSecrets: AppSecrets
appSecrets: AppSecrets,
) {
private val context: CoroutineContext = Dispatchers.IO

Expand All @@ -35,7 +35,7 @@ class WPcomLoginHelper @Inject constructor(
return false
}

val code = this.codeFromAuthorizationUri(data) ?: return false
val code = data.toUri().getQueryParameter("code") ?: return false

runBlocking {
val tokenResult = loginClient.exchangeAuthCodeForToken(code)
Expand All @@ -58,21 +58,6 @@ class WPcomLoginHelper @Inject constructor(
fun bindCustomTabsService(context: Context) {
customTabsServiceConnection.bind(context)
}

private fun codeFromAuthorizationUri(string: String): String? {
return Uri.parse(string).getQueryParameter("code")
}

fun appendParamsToRestAuthorizationUrl(authorizationUrl: String?): String {
return if (authorizationUrl.isNullOrEmpty()) {
authorizationUrl.orEmpty()
} else {
authorizationUrl.toUri().buildUpon().apply {
appendQueryParameter("app_name", "android-jetpack-client")
appendQueryParameter("success_url", appSecrets.redirectUri)
}.build().toString()
}
}
}

class ServiceConnection(
Expand All @@ -87,7 +72,7 @@ class ServiceConnection(

val session = client.newSession(CustomTabsCallback())
session?.mayLaunchUrl(uri, null, null)
session?.mayLaunchUrl(Uri.parse("https://wordpress.com/log-in/"), null, null)
session?.mayLaunchUrl("https://wordpress.com/log-in/".toUri(), null, null)

this.session = session
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.wordpress.android.ui.mysite

import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Build
Expand All @@ -9,6 +10,7 @@ import android.os.Parcelable
import android.view.View
import android.view.WindowManager
import androidx.annotation.StringRes
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.text.HtmlCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
Expand Down Expand Up @@ -87,6 +89,7 @@ import org.wordpress.android.viewmodel.observeEvent
import org.wordpress.android.viewmodel.pages.PageListViewModel
import java.io.File
import javax.inject.Inject
import androidx.core.net.toUri

@Suppress("LargeClass")
class MySiteFragment : Fragment(R.layout.my_site_fragment),
Expand Down Expand Up @@ -161,6 +164,9 @@ class MySiteFragment : Fragment(R.layout.my_site_fragment),
setupContentViews(savedInstanceState)
setupObservers()
}

// This is work in progress, we are not running the flow for regular users yet
// viewModel.runApplicationPasswordDiscovery()
}

override fun onSaveInstanceState(outState: Bundle) {
Expand Down Expand Up @@ -444,6 +450,10 @@ class MySiteFragment : Fragment(R.layout.my_site_fragment),
WPJetpackIndividualPluginFragment.show(requireActivity().supportFragmentManager)
}

viewModel.onShowApplicationPasswordLoginDialog.observeEvent(viewLifecycleOwner) {
showApplicationPasswordDialog(it)
}

viewModel.onScrollTo.observeEvent(viewLifecycleOwner) {
var quickStartScrollPosition = it
if (quickStartScrollPosition == -1) {
Expand All @@ -461,6 +471,67 @@ class MySiteFragment : Fragment(R.layout.my_site_fragment),
}
}

private fun showApplicationPasswordDialog(url: String) {
// This is in progress, so texts are not finals and we are not translating them yet.
val builder = android.app.AlertDialog.Builder(requireContext())
builder.setTitle("Application Password")
.setMessage("Would you like to authenticate this site using Applictaion Password?")
.setPositiveButton("Yes") { dialog, which ->
val intent = getCustomTabsIntent()
val loginUri = url.toUri()
val activity = requireActivity()
try {
intent.launchUrl(activity, loginUri)
} catch (e: SecurityException) {
AppLog.e(
AppLog.T.UTILS,
"Error opening login uri in CustomTabsIntent, attempting external browser",
e
)
ActivityLauncher.openUrlExternal(activity, loginUri.toString())
} catch (e: ActivityNotFoundException) {
AppLog.e(
AppLog.T.UTILS,
"Error opening login uri in CustomTabsIntent, attempting external browser",
e
)
ActivityLauncher.openUrlExternal(activity, loginUri.toString())
}
dialog.dismiss()
}
.setNeutralButton("Later") { dialog, which ->
dialog.dismiss()
}
.setNegativeButton("No") { dialog, which ->
viewModel.onApplicationPasswordLoginDialogDismissed(url)
dialog.dismiss()
}
.setCancelable(false)

val dialog = builder.create()
dialog.show()
}

private fun getCustomTabsIntent(): CustomTabsIntent {
val activity = requireActivity()
return CustomTabsIntent.Builder()
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
.setStartAnimations(
requireActivity(),
R.anim.activity_slide_in_from_right,
R.anim.activity_slide_out_to_left
)
.setExitAnimations(
activity,
R.anim.activity_slide_in_from_left,
R.anim.activity_slide_out_to_right
)
.setUrlBarHidingEnabled(true)
.setInstantAppsEnabled(false)
.setShowTitle(false)
.build()
}

private fun showSnackbar(holder: SnackbarMessageHolder) {
activity?.let { parent ->
snackbarSequencer.enqueue(
Expand Down
Loading