1 Commits
Author SHA1 Message Date
ayushyaandClaude Sonnet 5 eee2730fbe 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>
2026-09-17 00:01:11 -04:00
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
`{server}/index.php/login/v2`, gets back a browser login URL + a poll
endpoint/token.
2. The app opens the login URL for the user to authenticate/authorize.
Which browser depends on whether this is the first login or an
add-account flow (`addAccount`, see below):
- First/only login: a Chrome Custom Tab (`url_launcher`,
`LaunchMode.inAppBrowserView`) — real Chrome, so saved
passwords/autofill work, unlike Flutter's own embedded web view.
There's no way to close the tab automatically on success (Login Flow
v2 never redirects back into the app, and a Custom Tab belongs to
Chrome's own task) — the user switches back manually.
- Adding another account: [`LoginWebViewView`](../../lib/views/login_webview_view.dart),
a normal screen this app owns, backed by `package:webview_flutter`
rather than a Custom Tab - pushed by `LoginView` the moment
`loginFlowStatus` flips to `awaitingBrowser`. Two reasons this isn't
a Custom Tab: (1) a Custom Tab shares Chrome's actual browser
profile/cookie jar - 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`
(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).
2. The app opens the login URL for the user to authenticate/authorize in
[`LoginWebViewView`](../../lib/views/login_webview_view.dart), a normal
screen this app owns (`package:webview_flutter`) - pushed by `LoginView`
the moment `loginFlowStatus` flips to `awaitingBrowser`, for every login
(first account or an additional one), not just add-account. This used
to be split: first login went through a Chrome Custom Tab
(`url_launcher`, `LaunchMode.inAppBrowserView`) for Chrome's own
autofill, and only add-account used the embedded WebView, 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 - so both paths now use the same owned screen.
`url_launcher` is no longer a dependency. The cost is no
Chrome-autofill (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
2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in
`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"/>
<data android:mimeType="text/plain"/>
</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. -->
<intent>
<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: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',
-32
View File
@@ -925,30 +925,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -957,14 +933,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
-1
View File
@@ -42,7 +42,6 @@ dependencies:
intl: ^0.19.0
path: ^1.9.0
flutter_secure_storage: ^11.1.1
url_launcher: ^6.3.1
dio: ^5.11.1
path_provider: ^2.1.5
open_file: ^3.5.10