Use the owned login WebView for first login too, not just add-account
Build APK / build (push) Successful in 5m5s

The Chrome Custom Tab path for the first/only login had real costs -
no way to close it automatically on success, and it's a separate task
outside the app's own navigation - that only bought Chrome's own
autofill in exchange. LoginWebViewView now handles every login, not
just add-account, which was already using it to avoid a Custom Tab
silently reusing Chrome's session for a different account.

url_launcher is no longer a dependency as a result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 00:01:11 -04:00
co-authored by Claude Sonnet 5
parent be38f24f32
commit eee2730fbe
7 changed files with 97 additions and 239 deletions
+17 -28
View File
@@ -9,34 +9,23 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart):
1. `LoginFlowService.initiate(serverUrl)` POSTs to 1. `LoginFlowService.initiate(serverUrl)` POSTs to
`{server}/index.php/login/v2`, gets back a browser login URL + a poll `{server}/index.php/login/v2`, gets back a browser login URL + a poll
endpoint/token. endpoint/token.
2. The app opens the login URL for the user to authenticate/authorize. 2. The app opens the login URL for the user to authenticate/authorize in
Which browser depends on whether this is the first login or an [`LoginWebViewView`](../../lib/views/login_webview_view.dart), a normal
add-account flow (`addAccount`, see below): screen this app owns (`package:webview_flutter`) - pushed by `LoginView`
- First/only login: a Chrome Custom Tab (`url_launcher`, the moment `loginFlowStatus` flips to `awaitingBrowser`, for every login
`LaunchMode.inAppBrowserView`) — real Chrome, so saved (first account or an additional one), not just add-account. This used
passwords/autofill work, unlike Flutter's own embedded web view. to be split: first login went through a Chrome Custom Tab
There's no way to close the tab automatically on success (Login Flow (`url_launcher`, `LaunchMode.inAppBrowserView`) for Chrome's own
v2 never redirects back into the app, and a Custom Tab belongs to autofill, and only add-account used the embedded WebView, specifically
Chrome's own task) — the user switches back manually. to avoid a Custom Tab silently reusing Chrome's existing session for a
- Adding another account: [`LoginWebViewView`](../../lib/views/login_webview_view.dart), different account. But a Custom Tab has real costs even for the first
a normal screen this app owns, backed by `package:webview_flutter` login - no way to close it automatically on success (the user has to
rather than a Custom Tab - pushed by `LoginView` the moment switch back manually), and it's a separate task outside this app's own
`loginFlowStatus` flips to `awaitingBrowser`. Two reasons this isn't navigation entirely - so both paths now use the same owned screen.
a Custom Tab: (1) a Custom Tab shares Chrome's actual browser `url_launcher` is no longer a dependency. The cost is no
profile/cookie jar - if the user is still logged into the first Chrome-autofill (Android's own system Autofill framework, e.g. a
account on the Nextcloud web UI in Chrome, it would silently reuse password manager, may still work in the WebView; Chrome's own
that session and authorize the wrong account instead of prompting saved-password autofill specifically cannot, since that's Chrome-only).
fresh credentials; (2) `url_launcher`'s own `LaunchMode.inAppWebView`
(tried first) is a bare native WebView Activity with no chrome of its
own - no close button, and on at least some devices it draws
edge-to-edge and hides the status bar. Owning the screen fixes both -
isolated cookies, a normal `AppBar`/close button/safe area - and lets
it close itself automatically on success (it watches
`loginFlowStatus` itself), unlike the Custom Tab case. The cost is no
Chrome-autofill for this one flow (Android's own system Autofill
framework, e.g. a password manager, may still work in the WebView;
Chrome's own saved-password autofill specifically cannot, since
that's Chrome-only).
3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every 3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every
2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in 2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in
`server_provider.dart`) until it gets a 200 with `server`/`loginName`/ `server_provider.dart`) until it gets a 200 with `server`/`loginName`/
-6
View File
@@ -70,12 +70,6 @@
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
<!-- Needed so url_launcher can find a browser for Nextcloud Login Flow v2. -->
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
<!-- Needed so open_file can find apps to open downloaded files. --> <!-- Needed so open_file can find apps to open downloaded files. -->
<intent> <intent>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
+20 -53
View File
@@ -3,7 +3,6 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../models/nextcloud_file_version.dart'; import '../models/nextcloud_file_version.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
@@ -705,13 +704,26 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
} }
/// Starts Nextcloud Login Flow v2: asks the server for a one-time login /// Starts Nextcloud Login Flow v2: asks the server for a one-time login
/// URL, opens it in the system browser, then polls until the user /// URL, then polls until the user authorizes (in LoginWebViewView, pushed
/// authorizes and the server hands back a scoped app password. The app /// by LoginView once loginFlowStatus flips to awaitingBrowser below) and
/// never sees the user's real password. [addAccount] only marks the /// the server hands back a scoped app password. The app never sees the
/// flow as "add another account" for [isAddAccountFlow] - the pushed Add /// user's real password. [addAccount] only marks the flow as "add
/// Account screen reads that to decide when to pop itself; this method's /// another account" for [isAddAccountFlow] - the pushed Add Account
/// own persistence behavior on success ([_completeLoginFlow]) is the same /// screen reads that to decide when to pop itself; this method's own
/// either way (create-or-refresh the resulting account, then activate it). /// persistence behavior on success ([_completeLoginFlow]) is the same
/// either way (create-or-refresh the resulting account, then activate
/// it).
///
/// Every login - first account or an additional one - goes through
/// LoginWebViewView (`package:webview_flutter`), a screen this app owns,
/// rather than a Chrome Custom Tab. That used to only be true for
/// add-account, specifically to avoid a Custom Tab silently reusing
/// Chrome's existing session for a different account; but a Custom Tab
/// has real costs even for the first login - no way to close it
/// automatically on success (the user has to switch back manually), and
/// it's a separate task outside this app's own navigation entirely. The
/// only thing it bought over a plain WebView was Chrome's own
/// autofill/saved-password support, which isn't worth those tradeoffs.
Future<void> startLoginFlow( Future<void> startLoginFlow(
String serverUrl, { String serverUrl, {
bool addAccount = false, bool addAccount = false,
@@ -724,42 +736,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
try { try {
final init = await LoginFlowService.initiate(serverUrl); final init = await LoginFlowService.initiate(serverUrl);
_pendingLoginUrl = init.loginUrl; _pendingLoginUrl = init.loginUrl;
// Chrome Custom Tabs (inAppBrowserView) for the first/only login:
// real Chrome, so the saved passwords/autofill service works
// normally, unlike Flutter's own embedded web view. The tradeoff is
// that - like Login Flow v2's grant page itself, which never
// redirects back into the app on its own (unlike the old nc://
// Flow v1) - a Custom Tab belongs to Chrome's own task, not ours, so
// we have no way to close it automatically once polling below
// detects success; the user has to switch back manually, same as
// with the external browser.
//
// Adding another account instead shows the login page in
// LoginWebViewView, a real screen this app owns (pushed by LoginView
// once loginFlowStatus flips to awaitingBrowser below), backed by
// Flutter's own WebView rather than a Custom Tab. Two reasons, not
// just one: (1) a Custom Tab shares Chrome's actual browser
// profile/cookie jar, so if the user is still logged into the first
// account on the Nextcloud web UI in Chrome, it would silently reuse
// that session and authorize the wrong account instead of prompting
// fresh credentials; (2) url_launcher's own LaunchMode.inAppWebView
// is a bare native WebView Activity with no chrome of its own - no
// close button, and on at least some devices it draws edge-to-edge
// and hides the status bar. Owning the screen ourselves fixes both:
// isolated cookies, plus a normal AppBar/close button/safe area, and
// as a bonus we CAN close it automatically on success (LoginWebView
// View watches loginFlowStatus itself), unlike the Custom Tab case.
if (!addAccount) {
final opened = await launchUrl(
init.loginUrl,
mode: LaunchMode.inAppBrowserView,
);
if (!opened) {
throw Exception('Could not open the browser for login.');
}
}
_loginFlowStatus = LoginFlowStatus.awaitingBrowser; _loginFlowStatus = LoginFlowStatus.awaitingBrowser;
notifyListeners(); notifyListeners();
@@ -809,15 +785,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
} }
} }
// Only relevant to the first/only-login Chrome Custom Tab path - the
// add-account LoginWebViewView is a normal pushed screen the user can't
// lose track of, so it has no equivalent "reopen" need.
Future<void> reopenLoginBrowser() async {
if (_pendingLoginUrl != null) {
await launchUrl(_pendingLoginUrl!, mode: LaunchMode.inAppBrowserView);
}
}
void cancelLoginFlow({String? errorMessage}) { void cancelLoginFlow({String? errorMessage}) {
_pollTimer?.cancel(); _pollTimer?.cancel();
_pollTimeoutTimer?.cancel(); _pollTimeoutTimer?.cancel();
+12 -71
View File
@@ -69,14 +69,12 @@ class _LoginViewState extends State<LoginView> {
}); });
} }
// The add-account flow shows its login page in LoginWebViewView (a // Every login (first account or an additional one) shows its login
// real pushed screen, not a Custom Tab - see ServerProvider. // page in LoginWebViewView, a real screen this app owns - see
// startLoginFlow's doc comment for why) rather than this view's own // ServerProvider.startLoginFlow's doc comment for why. Push it the
// _WaitingForBrowser, so push it the moment there's a URL to show. // moment there's a URL to show; _webViewPushed resets once that route
// _webViewPushed resets once that route pops (cancelled or done) so a // pops (cancelled or done) so a retry after cancelling pushes it again.
// retry after cancelling pushes it again. if (!_webViewPushed &&
if (widget.isAddingAccount &&
!_webViewPushed &&
isAwaitingBrowser && isAwaitingBrowser &&
provider.pendingLoginUrl != null) { provider.pendingLoginUrl != null) {
_webViewPushed = true; _webViewPushed = true;
@@ -101,12 +99,7 @@ class _LoginViewState extends State<LoginView> {
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24), padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440), constraints: const BoxConstraints(maxWidth: 440),
child: isAwaitingBrowser && !widget.isAddingAccount child: Column(
? _WaitingForBrowser(
onCancel: () => provider.cancelLoginFlow(),
onReopenBrowser: provider.reopenLoginBrowser,
)
: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// A logout keeps the account saved rather than // A logout keeps the account saved rather than
@@ -117,17 +110,14 @@ class _LoginViewState extends State<LoginView> {
provider.accounts.isNotEmpty) ...[ provider.accounts.isNotEmpty) ...[
_SavedAccountsSection( _SavedAccountsSection(
accounts: provider.accounts, accounts: provider.accounts,
onSelect: (account) => onSelect: (account) => provider.switchAccount(account.id),
provider.switchAccount(account.id),
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
Row( Row(
children: [ children: [
const Expanded(child: Divider()), const Expanded(child: Divider()),
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 12),
horizontal: 12,
),
child: Text( child: Text(
'or', 'or',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
@@ -144,9 +134,7 @@ class _LoginViewState extends State<LoginView> {
formKey: _formKey, formKey: _formKey,
urlController: _urlController, urlController: _urlController,
isLoading: isLoading:
isInitiating || isInitiating || provider.isLoading || isAwaitingBrowser,
provider.isLoading ||
(widget.isAddingAccount && isAwaitingBrowser),
errorMessage: errorMessage:
provider.loginFlowStatus == LoginFlowStatus.error provider.loginFlowStatus == LoginFlowStatus.error
? provider.errorMessage ? provider.errorMessage
@@ -166,7 +154,7 @@ class _LoginViewState extends State<LoginView> {
if (!widget.isAddingAccount) return body; if (!widget.isAddingAccount) return body;
// Backing out mid-flow (system back/swipe-back, not just the explicit // Backing out mid-flow (system back/swipe-back, not just the explicit
// Cancel button in _WaitingForBrowser) should cancel the pending login // Cancel button in LoginWebViewView) should cancel the pending login
// flow rather than leaving its poll timer running after this screen is // flow rather than leaving its poll timer running after this screen is
// gone - this view could never be popped before "add account" existed, // gone - this view could never be popped before "add account" existed,
// so that case wasn't reachable until now. // so that case wasn't reachable until now.
@@ -323,7 +311,7 @@ class _ServerForm extends StatelessWidget {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
"You'll finish signing in through your browser. This app never sees your password.", "You'll finish signing in on the page that opens. This app never sees your password.",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@@ -414,50 +402,3 @@ class _SavedAccountRow extends StatelessWidget {
); );
} }
} }
class _WaitingForBrowser extends StatelessWidget {
final VoidCallback onCancel;
final VoidCallback onReopenBrowser;
const _WaitingForBrowser({
required this.onCancel,
required this.onReopenBrowser,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: colorScheme.primary),
const SizedBox(height: 28),
Text(
'Waiting for you to sign in',
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Complete the login in the browser window that just opened, then come back here.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
OutlinedButton.icon(
onPressed: onReopenBrowser,
icon: const Icon(Icons.open_in_browser_rounded),
label: const Text('Reopen browser'),
),
const SizedBox(height: 12),
TextButton(onPressed: onCancel, child: const Text('Cancel')),
],
);
}
}
+11 -11
View File
@@ -3,17 +3,17 @@ import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import '../providers/server_provider.dart'; import '../providers/server_provider.dart';
/// The "add another account" Login Flow v2 page, rendered in Flutter's own /// Every Login Flow v2 page - first login or an additional account -
/// WebView rather than url_launcher's `LaunchMode.inAppWebView` - that mode /// rendered in Flutter's own WebView rather than a Chrome Custom Tab or
/// hosts a bare native WebView Activity with no chrome of its own: no close /// url_launcher's `LaunchMode.inAppWebView`. The latter hosts a bare native
/// button, and on at least some devices it draws edge-to-edge and hides the /// WebView Activity with no chrome of its own: no close button, and on at
/// status bar with no way back short of the OS back gesture. A normal /// least some devices it draws edge-to-edge and hides the status bar with
/// Scaffold/AppBar here gets the status bar/safe-area handling and a close /// no way back short of the OS back gesture. A normal Scaffold/AppBar here
/// button for free, exactly like every other screen in the app. /// gets the status bar/safe-area handling and a close button for free,
/// exactly like every other screen in the app - and unlike a Custom Tab,
/// this screen can close itself automatically once login succeeds.
/// ///
/// See ServerProvider.startLoginFlow's doc comment for why the add-account /// See ServerProvider.startLoginFlow's doc comment for the full rationale.
/// flow uses an embedded WebView (isolated cookies) instead of a Chrome
/// Custom Tab at all.
class LoginWebViewView extends StatefulWidget { class LoginWebViewView extends StatefulWidget {
final Uri url; final Uri url;
@@ -73,7 +73,7 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
}, },
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Add Account'), title: const Text('Sign in'),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded),
tooltip: 'Cancel', tooltip: 'Cancel',
-32
View File
@@ -925,30 +925,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
url: "https://pub.dev"
source: hosted
version: "6.3.33"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
url: "https://pub.dev"
source: hosted
version: "6.4.2"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
@@ -957,14 +933,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.3" version: "3.2.3"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
url: "https://pub.dev"
source: hosted
version: "3.2.6"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:
-1
View File
@@ -42,7 +42,6 @@ dependencies:
intl: ^0.19.0 intl: ^0.19.0
path: ^1.9.0 path: ^1.9.0
flutter_secure_storage: ^11.1.1 flutter_secure_storage: ^11.1.1
url_launcher: ^6.3.1
dio: ^5.11.1 dio: ^5.11.1
path_provider: ^2.1.5 path_provider: ^2.1.5
open_file: ^3.5.10 open_file: ^3.5.10