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