diff --git a/.claude/context/server.md b/.claude/context/server.md
index d9b74b2..3674214 100644
--- a/.claude/context/server.md
+++ b/.claude/context/server.md
@@ -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`/
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index ce80bef..eefa75a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -70,12 +70,6 @@
-
-
-
-
-
-
diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart
index c565dcc..932b051 100644
--- a/lib/providers/server_provider.dart
+++ b/lib/providers/server_provider.dart
@@ -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 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 reopenLoginBrowser() async {
- if (_pendingLoginUrl != null) {
- await launchUrl(_pendingLoginUrl!, mode: LaunchMode.inAppBrowserView);
- }
- }
-
void cancelLoginFlow({String? errorMessage}) {
_pollTimer?.cancel();
_pollTimeoutTimer?.cancel();
diff --git a/lib/views/login_view.dart b/lib/views/login_view.dart
index ef65895..df69ddc 100644
--- a/lib/views/login_view.dart
+++ b/lib/views/login_view.dart
@@ -69,14 +69,12 @@ class _LoginViewState extends State {
});
}
- // 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 {
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 {
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')),
- ],
- );
- }
-}
diff --git a/lib/views/login_webview_view.dart b/lib/views/login_webview_view.dart
index c7d40f3..ce544d6 100644
--- a/lib/views/login_webview_view.dart
+++ b/lib/views/login_webview_view.dart
@@ -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 {
},
child: Scaffold(
appBar: AppBar(
- title: const Text('Add Account'),
+ title: const Text('Sign in'),
leading: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Cancel',
diff --git a/pubspec.lock b/pubspec.lock
index 382f611..8627857 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -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:
diff --git a/pubspec.yaml b/pubspec.yaml
index 90ec112..1f72cdb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -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