Skip to content

Commit 706f52b

Browse files
eintreintr
authored andcommitted
feat(auth): add direct login with app-specific password
Allow users to log in directly with username and app password without requiring an external browser. After server URL validation, two options are presented: browser login or app password login. This benefits devices without a full browser available. Fixes #15739 Assisted-by: Kiro:claude-sonnet-4-20250514 Signed-off-by: eintr <mascot-rise-squint@duck.com>
1 parent 7728669 commit 706f52b

7 files changed

Lines changed: 448 additions & 12 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.owncloud.android.authentication
8+
9+
import android.accounts.AccountManager
10+
import android.view.KeyEvent
11+
import androidx.test.core.app.ActivityScenario
12+
import androidx.test.espresso.Espresso.onView
13+
import androidx.test.espresso.action.ViewActions.click
14+
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
15+
import androidx.test.espresso.action.ViewActions.pressKey
16+
import androidx.test.espresso.action.ViewActions.typeText
17+
import androidx.test.espresso.assertion.ViewAssertions.matches
18+
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
19+
import androidx.test.espresso.matcher.ViewMatchers.withId
20+
import androidx.test.espresso.matcher.ViewMatchers.withText
21+
import androidx.test.filters.LargeTest
22+
import androidx.test.platform.app.InstrumentationRegistry
23+
import com.nextcloud.client.account.UserAccountManagerImpl
24+
import com.nextcloud.test.RetryTestRule
25+
import com.owncloud.android.AbstractOnServerIT
26+
import com.owncloud.android.R
27+
import org.hamcrest.Matchers.not
28+
import org.junit.After
29+
import org.junit.Assert.assertTrue
30+
import org.junit.Before
31+
import org.junit.Rule
32+
import org.junit.Test
33+
34+
@LargeTest
35+
class DirectLoginIT : AbstractOnServerIT() {
36+
37+
@get:Rule
38+
var retryTestRule = RetryTestRule()
39+
40+
private lateinit var scenario: ActivityScenario<AuthenticatorActivity>
41+
42+
@Before
43+
fun setUp() {
44+
AccountManager.get(targetContext).removeAccountExplicitly(account)
45+
scenario = ActivityScenario.launch(AuthenticatorActivity::class.java)
46+
}
47+
48+
@After
49+
override fun after() {
50+
scenario.close()
51+
AccountManager.get(targetContext).removeAccountExplicitly(account)
52+
super.after()
53+
}
54+
55+
@Test
56+
fun directLoginSectionHiddenInitially() {
57+
onView(withId(R.id.direct_login_section)).check(matches(not(isDisplayed())))
58+
onView(withId(R.id.direct_login_toggle)).check(matches(not(isDisplayed())))
59+
onView(withId(R.id.browser_login_button)).check(matches(not(isDisplayed())))
60+
}
61+
62+
@Test
63+
fun directLoginToggleAppearsAfterServerValidation() {
64+
submitServerUrl()
65+
66+
onView(withId(R.id.direct_login_toggle)).check(matches(isDisplayed()))
67+
onView(withId(R.id.browser_login_button)).check(matches(isDisplayed()))
68+
}
69+
70+
@Test
71+
fun directLoginShowsFormOnToggleClick() {
72+
showDirectLoginForm()
73+
74+
onView(withId(R.id.direct_login_section)).check(matches(isDisplayed()))
75+
onView(withId(R.id.direct_login_username)).check(matches(isDisplayed()))
76+
onView(withId(R.id.direct_login_password)).check(matches(isDisplayed()))
77+
onView(withId(R.id.direct_login_button)).check(matches(isDisplayed()))
78+
onView(withId(R.id.browser_login_button)).check(matches(not(isDisplayed())))
79+
}
80+
81+
@Test
82+
fun directLoginEmptyFieldsShowsErrors() {
83+
showDirectLoginForm()
84+
onView(withId(R.id.direct_login_button)).perform(click())
85+
86+
onView(withText(R.string.direct_login_username_required)).check(matches(isDisplayed()))
87+
}
88+
89+
@Test
90+
fun directLoginEmptyPasswordShowsError() {
91+
showDirectLoginForm()
92+
onView(withId(R.id.direct_login_username)).perform(typeText("testuser"), closeSoftKeyboard())
93+
onView(withId(R.id.direct_login_button)).perform(click())
94+
95+
onView(withText(R.string.direct_login_password_required)).check(matches(isDisplayed()))
96+
}
97+
98+
@Test
99+
fun directLoginWrongCredentialsShowsError() {
100+
showDirectLoginForm()
101+
onView(withId(R.id.direct_login_username)).perform(typeText("wronguser"), closeSoftKeyboard())
102+
onView(withId(R.id.direct_login_password)).perform(typeText("wrongpass"), closeSoftKeyboard())
103+
onView(withId(R.id.direct_login_button)).perform(click())
104+
Thread.sleep(AUTHENTICATION_DELAY_MILLIS)
105+
106+
onView(withText(R.string.auth_unauthorized)).check(matches(isDisplayed()))
107+
}
108+
109+
@Test
110+
fun directLoginWithValidAppPasswordCreatesAccount() {
111+
showDirectLoginForm()
112+
onView(withId(R.id.direct_login_username)).perform(typeText(testServerUsername()), closeSoftKeyboard())
113+
onView(withId(R.id.direct_login_password)).perform(typeText(testServerAppPassword()), closeSoftKeyboard())
114+
onView(withId(R.id.direct_login_button)).perform(click())
115+
Thread.sleep(AUTHENTICATION_DELAY_MILLIS)
116+
117+
assertTrue(UserAccountManagerImpl.fromContext(targetContext).accounts.isNotEmpty())
118+
}
119+
120+
private fun showDirectLoginForm() {
121+
submitServerUrl()
122+
onView(withId(R.id.direct_login_toggle)).perform(click())
123+
}
124+
125+
private fun submitServerUrl() {
126+
onView(withId(R.id.host_url_input)).perform(
127+
typeText(testServerUrl()),
128+
pressKey(KeyEvent.KEYCODE_ENTER),
129+
closeSoftKeyboard()
130+
)
131+
Thread.sleep(SERVER_VALIDATION_DELAY_MILLIS)
132+
}
133+
134+
private fun testServerUrl(): String = testServerArgument("TEST_SERVER_URL", "server URL")
135+
136+
private fun testServerUsername(): String = testServerArgument("TEST_SERVER_USERNAME", "username")
137+
138+
private fun testServerAppPassword(): String =
139+
testServerArgument("TEST_SERVER_PASSWORD", "dedicated test user's app-specific password")
140+
141+
private fun testServerArgument(argumentName: String, description: String): String {
142+
val value = InstrumentationRegistry.getArguments().getString(argumentName)
143+
require(!value.isNullOrBlank() && value != "null") {
144+
"$argumentName must be configured with the $description"
145+
}
146+
return value
147+
}
148+
149+
companion object {
150+
private const val SERVER_VALIDATION_DELAY_MILLIS = 5_000L
151+
private const val AUTHENTICATION_DELAY_MILLIS = 10_000L
152+
}
153+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.owncloud.android.authentication
8+
9+
import android.view.View
10+
import androidx.test.core.app.ActivityScenario
11+
import androidx.test.espresso.Espresso.onView
12+
import androidx.test.espresso.assertion.ViewAssertions.matches
13+
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
14+
import androidx.test.espresso.matcher.ViewMatchers.isRoot
15+
import com.owncloud.android.AbstractIT
16+
import com.owncloud.android.utils.ScreenshotTest
17+
import org.junit.Test
18+
19+
class DirectLoginScreenshotIT : AbstractIT() {
20+
private val testClassName = "com.owncloud.android.authentication.DirectLoginScreenshotIT"
21+
22+
@Test
23+
@ScreenshotTest
24+
fun directLoginForm() {
25+
ActivityScenario.launch(AuthenticatorActivity::class.java).use { scenario ->
26+
scenario.onActivity { activity ->
27+
activity.accountSetupBinding.apply {
28+
hostUrlInput.setText("https://cloud.example.com")
29+
requireNotNull(browserLoginButton).visibility = View.GONE
30+
requireNotNull(directLoginToggle).visibility = View.GONE
31+
requireNotNull(directLoginSection).visibility = View.VISIBLE
32+
}
33+
}
34+
35+
onView(isRoot()).check(matches(isDisplayed()))
36+
scenario.onActivity { activity ->
37+
screenshotViaName(activity, createName("${testClassName}_directLoginForm", ""))
38+
}
39+
}
40+
}
41+
}

app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity
178178
private static final String KEY_USERNAME = "USERNAME";
179179
private static final String KEY_PASSWORD = "PASSWORD";
180180
private static final String KEY_ASYNC_TASK_IN_PROGRESS = "AUTH_IN_PROGRESS";
181+
private static final String KEY_DIRECT_LOGIN_ACTIVE = "DIRECT_LOGIN_ACTIVE";
181182

182183
public static final String WEB_LOGIN = "/index.php/login/v2";
183184

@@ -298,6 +299,7 @@ protected void onCreate(Bundle savedInstanceState) {
298299
if (savedInstanceState != null) {
299300
mWaitingForOpId = savedInstanceState.getLong(KEY_WAITING_FOR_OP_ID);
300301
mIsFirstAuthAttempt = savedInstanceState.getBoolean(KEY_AUTH_IS_FIRST_ATTEMPT_TAG);
302+
isDirectLoginActive = savedInstanceState.getBoolean(KEY_DIRECT_LOGIN_ACTIVE);
301303
}
302304

303305
boolean webViewLoginMethod = false;
@@ -404,6 +406,7 @@ private void deleteCookies() {
404406
private final ScheduledExecutorService loginFlowExecutorService = Executors.newSingleThreadScheduledExecutor();
405407
private boolean isLoginProcessCompleted = false;
406408
private boolean isRedirectedToTheDefaultBrowser = false;
409+
private boolean isDirectLoginActive = false;
407410
private String baseUrl;
408411

409412
private void poolLogin() {
@@ -805,6 +808,7 @@ protected void onSaveInstanceState(@NonNull Bundle outState) {
805808

806809
/// authentication
807810
outState.putBoolean(KEY_AUTH_IS_FIRST_ATTEMPT_TAG, mIsFirstAuthAttempt);
811+
outState.putBoolean(KEY_DIRECT_LOGIN_ACTIVE, isDirectLoginActive);
808812

809813
/// AsyncTask (User and password)
810814
if (mAsyncTask != null) {
@@ -1024,6 +1028,74 @@ private void accessRootFolder(OwnCloudCredentials credentials) {
10241028
mAsyncTask.execute(params);
10251029
}
10261030

1031+
private void showLoginChoice() {
1032+
if (accountSetupBinding == null) {
1033+
return;
1034+
}
1035+
accountSetupBinding.browserLoginButton.setVisibility(View.VISIBLE);
1036+
accountSetupBinding.directLoginToggle.setVisibility(View.VISIBLE);
1037+
accountSetupBinding.browserLoginButton.setOnClickListener(v -> launchBrowserLogin());
1038+
accountSetupBinding.directLoginToggle.setOnClickListener(v -> {
1039+
isDirectLoginActive = true;
1040+
accountSetupBinding.browserLoginButton.setVisibility(View.GONE);
1041+
showDirectLoginSection();
1042+
});
1043+
}
1044+
1045+
private void showDirectLoginSection() {
1046+
if (accountSetupBinding == null) {
1047+
return;
1048+
}
1049+
accountSetupBinding.directLoginToggle.setVisibility(View.GONE);
1050+
accountSetupBinding.directLoginSection.setVisibility(View.VISIBLE);
1051+
accountSetupBinding.directLoginButton.setOnClickListener(v -> performDirectLogin());
1052+
accountSetupBinding.directLoginUsername.requestFocus();
1053+
}
1054+
1055+
private void launchBrowserLogin() {
1056+
accountSetupWebviewBinding = AccountSetupWebviewBinding.inflate(getLayoutInflater());
1057+
setContentView(accountSetupWebviewBinding.getRoot());
1058+
1059+
if (!isLoginProcessCompleted) {
1060+
if (!isRedirectedToTheDefaultBrowser) {
1061+
anonymouslyPostLoginRequest(mServerInfo.mBaseUrl + WEB_LOGIN);
1062+
isRedirectedToTheDefaultBrowser = true;
1063+
} else {
1064+
initLoginInfoView();
1065+
}
1066+
}
1067+
}
1068+
1069+
private void performDirectLogin() {
1070+
if (accountSetupBinding == null) {
1071+
return;
1072+
}
1073+
1074+
CharSequence username = accountSetupBinding.directLoginUsername.getText();
1075+
CharSequence password = accountSetupBinding.directLoginPassword.getText();
1076+
DirectLoginCredentials credentials = new DirectLoginCredentials(
1077+
username == null ? null : username.toString(),
1078+
password == null ? null : password.toString()
1079+
);
1080+
1081+
if (credentials.isUsernameEmpty()) {
1082+
accountSetupBinding.directLoginUsernameContainer.setError(getString(R.string.direct_login_username_required));
1083+
return;
1084+
}
1085+
1086+
if (credentials.isPasswordEmpty()) {
1087+
accountSetupBinding.directLoginPasswordContainer.setError(getString(R.string.direct_login_password_required));
1088+
return;
1089+
}
1090+
1091+
accountSetupBinding.directLoginUsernameContainer.setError(null);
1092+
accountSetupBinding.directLoginPasswordContainer.setError(null);
1093+
1094+
webViewUser = credentials.getUsername();
1095+
webViewPassword = credentials.getPassword();
1096+
checkBasicAuthorization(webViewUser, webViewPassword);
1097+
}
1098+
10271099
/**
10281100
* Callback method invoked when a RemoteOperation executed by this Activity finishes.
10291101
* <p>
@@ -1093,18 +1165,10 @@ private void onGetServerInfoFinish(RemoteOperationResult result) {
10931165
if (webViewUser != null && !webViewUser.isEmpty() &&
10941166
webViewPassword != null && !webViewPassword.isEmpty()) {
10951167
checkBasicAuthorization(webViewUser, webViewPassword);
1168+
} else if (isDirectLoginActive) {
1169+
showDirectLoginSection();
10961170
} else {
1097-
accountSetupWebviewBinding = AccountSetupWebviewBinding.inflate(getLayoutInflater());
1098-
setContentView(accountSetupWebviewBinding.getRoot());
1099-
1100-
if (!isLoginProcessCompleted) {
1101-
if (!isRedirectedToTheDefaultBrowser) {
1102-
anonymouslyPostLoginRequest(mServerInfo.mBaseUrl + WEB_LOGIN);
1103-
isRedirectedToTheDefaultBrowser = true;
1104-
} else {
1105-
initLoginInfoView();
1106-
}
1107-
}
1171+
showLoginChoice();
11081172
}
11091173
} else {
11101174
updateServerStatusIconAndText(result);
@@ -1385,7 +1449,11 @@ public void onAuthenticatorTaskCallback(RemoteOperationResult<UserInfo> result)
13851449
}
13861450

13871451
} else { // authorization fail due to client side - probably wrong credentials
1388-
if (accountSetupWebviewBinding != null) {
1452+
if (isDirectLoginActive) {
1453+
mAuthStatusIcon = R.drawable.ic_alert;
1454+
mAuthStatusText = getString(R.string.auth_unauthorized);
1455+
showAuthStatus();
1456+
} else if (accountSetupWebviewBinding != null) {
13891457
anonymouslyPostLoginRequest(mServerInfo.mBaseUrl + WEB_LOGIN);
13901458
} else {
13911459
DisplayUtils.showSnackMessage(this, R.string.auth_access_failed, result.getLogMessage(this));
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.owncloud.android.authentication;
8+
9+
public final class DirectLoginCredentials {
10+
private final String username;
11+
private final String password;
12+
13+
public DirectLoginCredentials(String username, String password) {
14+
this.username = normalize(username);
15+
this.password = normalize(password);
16+
}
17+
18+
public String getUsername() {
19+
return username;
20+
}
21+
22+
public String getPassword() {
23+
return password;
24+
}
25+
26+
public boolean isUsernameEmpty() {
27+
return username.isEmpty();
28+
}
29+
30+
public boolean isPasswordEmpty() {
31+
return password.isEmpty();
32+
}
33+
34+
private static String normalize(String value) {
35+
return value == null ? "" : value.trim();
36+
}
37+
}

0 commit comments

Comments
 (0)