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
+20 -53
View File
@@ -3,7 +3,6 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_file_version.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
/// URL, opens it in the system browser, then polls until the user
/// authorizes and the server hands back a scoped app password. The app
/// never sees the user's real password. [addAccount] only marks the
/// flow as "add another account" for [isAddAccountFlow] - the pushed Add
/// Account screen reads that to decide when to pop itself; this method's
/// own persistence behavior on success ([_completeLoginFlow]) is the same
/// either way (create-or-refresh the resulting account, then activate it).
/// URL, then polls until the user authorizes (in LoginWebViewView, pushed
/// by LoginView once loginFlowStatus flips to awaitingBrowser below) and
/// the server hands back a scoped app password. The app never sees the
/// user's real password. [addAccount] only marks the flow as "add
/// another account" for [isAddAccountFlow] - the pushed Add Account
/// screen reads that to decide when to pop itself; this method's own
/// 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(
String serverUrl, {
bool addAccount = false,
@@ -724,42 +736,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
try {
final init = await LoginFlowService.initiate(serverUrl);
_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;
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}) {
_pollTimer?.cancel();
_pollTimeoutTimer?.cancel();
+49 -108
View File
@@ -69,14 +69,12 @@ class _LoginViewState extends State<LoginView> {
});
}
// The add-account flow shows its login page in LoginWebViewView (a
// real pushed screen, not a Custom Tab - see ServerProvider.
// startLoginFlow's doc comment for why) rather than this view's own
// _WaitingForBrowser, so push it the moment there's a URL to show.
// _webViewPushed resets once that route pops (cancelled or done) so a
// retry after cancelling pushes it again.
if (widget.isAddingAccount &&
!_webViewPushed &&
// Every login (first account or an additional one) shows its login
// page in LoginWebViewView, a real screen this app owns - see
// ServerProvider.startLoginFlow's doc comment for why. Push it the
// moment there's a URL to show; _webViewPushed resets once that route
// pops (cancelled or done) so a retry after cancelling pushes it again.
if (!_webViewPushed &&
isAwaitingBrowser &&
provider.pendingLoginUrl != null) {
_webViewPushed = true;
@@ -101,62 +99,52 @@ class _LoginViewState extends State<LoginView> {
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: isAwaitingBrowser && !widget.isAddingAccount
? _WaitingForBrowser(
onCancel: () => provider.cancelLoginFlow(),
onReopenBrowser: provider.reopenLoginBrowser,
)
: Column(
mainAxisSize: MainAxisSize.min,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// A logout keeps the account saved rather than
// deleting it, specifically so it can be resumed
// from here with one tap - no need to repeat
// Login Flow v2.
if (!widget.isAddingAccount &&
provider.accounts.isNotEmpty) ...[
_SavedAccountsSection(
accounts: provider.accounts,
onSelect: (account) => provider.switchAccount(account.id),
),
const SizedBox(height: 28),
Row(
children: [
// A logout keeps the account saved rather than
// deleting it, specifically so it can be resumed
// from here with one tap - no need to repeat
// Login Flow v2.
if (!widget.isAddingAccount &&
provider.accounts.isNotEmpty) ...[
_SavedAccountsSection(
accounts: provider.accounts,
onSelect: (account) =>
provider.switchAccount(account.id),
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
'or',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 28),
Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
),
child: Text(
'or',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: 28),
],
_ServerForm(
formKey: _formKey,
urlController: _urlController,
isLoading:
isInitiating ||
provider.isLoading ||
(widget.isAddingAccount && isAwaitingBrowser),
errorMessage:
provider.loginFlowStatus == LoginFlowStatus.error
? provider.errorMessage
: null,
onContinue: _handleContinue,
theme: theme,
colorScheme: colorScheme,
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: 28),
],
_ServerForm(
formKey: _formKey,
urlController: _urlController,
isLoading:
isInitiating || provider.isLoading || isAwaitingBrowser,
errorMessage:
provider.loginFlowStatus == LoginFlowStatus.error
? provider.errorMessage
: null,
onContinue: _handleContinue,
theme: theme,
colorScheme: colorScheme,
),
],
),
),
),
),
@@ -166,7 +154,7 @@ class _LoginViewState extends State<LoginView> {
if (!widget.isAddingAccount) return body;
// 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
// gone - this view could never be popped before "add account" existed,
// so that case wasn't reachable until now.
@@ -323,7 +311,7 @@ class _ServerForm extends StatelessWidget {
),
const SizedBox(height: 24),
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,
style: theme.textTheme.bodySmall?.copyWith(
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 '../providers/server_provider.dart';
/// The "add another account" Login Flow v2 page, rendered in Flutter's own
/// WebView rather than url_launcher's `LaunchMode.inAppWebView` - that mode
/// hosts 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 with no way back short of the OS back gesture. A normal
/// Scaffold/AppBar here gets the status bar/safe-area handling and a close
/// button for free, exactly like every other screen in the app.
/// Every Login Flow v2 page - first login or an additional account -
/// rendered in Flutter's own WebView rather than a Chrome Custom Tab or
/// url_launcher's `LaunchMode.inAppWebView`. The latter hosts 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 with
/// no way back short of the OS back gesture. A normal Scaffold/AppBar here
/// 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
/// flow uses an embedded WebView (isolated cookies) instead of a Chrome
/// Custom Tab at all.
/// See ServerProvider.startLoginFlow's doc comment for the full rationale.
class LoginWebViewView extends StatefulWidget {
final Uri url;
@@ -73,7 +73,7 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
},
child: Scaffold(
appBar: AppBar(
title: const Text('Add Account'),
title: const Text('Sign in'),
leading: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Cancel',