feat(settings): add configurable base URL#180
Conversation
There was a problem hiding this comment.
Pull request overview
Adds runtime configurability for the Anna’s Archive base URL, allowing users to change the source domain from the Settings page and having that preference persist across app restarts.
Changes:
- Introduces a
baseUrlProviderand wires it into search + book-info providers. - Updates
AnnasArchieveto accept a configurablebaseUrlinstead of a static constant. - Persists the base URL in the preferences table, loads it on startup, and adds a Settings UI dialog to edit it.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/ui/settings_page.dart | Adds a “Base URL” settings entry and dialog for editing/saving the URL. |
| lib/state/state.dart | Adds baseUrlProvider and uses it to construct AnnasArchieve for search/detail providers. |
| lib/services/database.dart | Seeds a default annasArchiveBaseUrl preference on DB open. |
| lib/services/annas_archieve.dart | Converts base URL from static const to instance field with constructor injection. |
| lib/main.dart | Loads persisted base URL at startup and overrides baseUrlProvider accordingly. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| String currentBaseUrl = ref.read(baseUrlProvider); | ||
| controller.text = currentBaseUrl; | ||
|
|
||
| return showDialog( |
There was a problem hiding this comment.
_showBaseUrlDialog is declared async with return type Future<void>, but it returns showDialog(...) (a Future<T?>). This is a type mismatch and will fail to compile. await showDialog(...) and then return, or change the function signature to return the dialog's Future type (e.g. Future<T?>).
| return showDialog( | |
| await showDialog( |
| return showDialog( | ||
| context: context, | ||
| builder: (context) { | ||
| return AlertDialog( | ||
| title: const Text('Configure Base URL'), | ||
| content: SingleChildScrollView( | ||
| child: Column( | ||
| mainAxisSize: MainAxisSize.min, | ||
| children: [ | ||
| TextField( | ||
| controller: controller, | ||
| decoration: const InputDecoration( | ||
| hintText: 'https://annas-archive.li', | ||
| labelText: 'Base URL', | ||
| ), | ||
| ), | ||
| ], | ||
| ), | ||
| ), | ||
| actions: [ | ||
| TextButton( | ||
| onPressed: () { | ||
| Navigator.of(context).pop(); | ||
| }, | ||
| child: Text('Cancel', | ||
| style: TextStyle(color: Theme.of(context).colorScheme.error)), | ||
| ), | ||
| ElevatedButton( | ||
| style: ElevatedButton.styleFrom( | ||
| backgroundColor: Colors.red, | ||
| foregroundColor: Colors.white, | ||
| ), | ||
| onPressed: () async { | ||
| String newUrl = controller.text.trim(); | ||
| // Remove trailing slash if present | ||
| if (newUrl.endsWith('/')) { | ||
| newUrl = newUrl.substring(0, newUrl.length - 1); | ||
| } | ||
| // Validate URL format | ||
| if (newUrl.isNotEmpty && | ||
| (newUrl.startsWith('http://') || | ||
| newUrl.startsWith('https://'))) { | ||
| ref.read(baseUrlProvider.notifier).state = newUrl; | ||
| await dataBase.savePreference('annasArchiveBaseUrl', newUrl); | ||
| Navigator.of(context).pop(); | ||
| // Show confirmation | ||
| ScaffoldMessenger.of(context).showSnackBar( | ||
| SnackBar( | ||
| content: Text('Base URL updated to: $newUrl'), | ||
| duration: const Duration(seconds: 2), | ||
| ), | ||
| ); | ||
| } else { | ||
| // Show error | ||
| ScaffoldMessenger.of(context).showSnackBar( | ||
| const SnackBar( | ||
| content: Text( | ||
| 'Invalid URL. Must start with http:// or https://'), | ||
| duration: Duration(seconds: 2), | ||
| ), | ||
| ); | ||
| } | ||
| }, | ||
| child: const Text('Save'), | ||
| ), | ||
| ], | ||
| ); | ||
| }, | ||
| ); |
There was a problem hiding this comment.
The TextEditingController created for the dialog is never disposed. Since this function creates the controller, it should dispose it (e.g., try/finally around an await showDialog(...)) to avoid leaking resources.
| return showDialog( | |
| context: context, | |
| builder: (context) { | |
| return AlertDialog( | |
| title: const Text('Configure Base URL'), | |
| content: SingleChildScrollView( | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| TextField( | |
| controller: controller, | |
| decoration: const InputDecoration( | |
| hintText: 'https://annas-archive.li', | |
| labelText: 'Base URL', | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| actions: [ | |
| TextButton( | |
| onPressed: () { | |
| Navigator.of(context).pop(); | |
| }, | |
| child: Text('Cancel', | |
| style: TextStyle(color: Theme.of(context).colorScheme.error)), | |
| ), | |
| ElevatedButton( | |
| style: ElevatedButton.styleFrom( | |
| backgroundColor: Colors.red, | |
| foregroundColor: Colors.white, | |
| ), | |
| onPressed: () async { | |
| String newUrl = controller.text.trim(); | |
| // Remove trailing slash if present | |
| if (newUrl.endsWith('/')) { | |
| newUrl = newUrl.substring(0, newUrl.length - 1); | |
| } | |
| // Validate URL format | |
| if (newUrl.isNotEmpty && | |
| (newUrl.startsWith('http://') || | |
| newUrl.startsWith('https://'))) { | |
| ref.read(baseUrlProvider.notifier).state = newUrl; | |
| await dataBase.savePreference('annasArchiveBaseUrl', newUrl); | |
| Navigator.of(context).pop(); | |
| // Show confirmation | |
| ScaffoldMessenger.of(context).showSnackBar( | |
| SnackBar( | |
| content: Text('Base URL updated to: $newUrl'), | |
| duration: const Duration(seconds: 2), | |
| ), | |
| ); | |
| } else { | |
| // Show error | |
| ScaffoldMessenger.of(context).showSnackBar( | |
| const SnackBar( | |
| content: Text( | |
| 'Invalid URL. Must start with http:// or https://'), | |
| duration: Duration(seconds: 2), | |
| ), | |
| ); | |
| } | |
| }, | |
| child: const Text('Save'), | |
| ), | |
| ], | |
| ); | |
| }, | |
| ); | |
| try { | |
| await showDialog( | |
| context: context, | |
| builder: (context) { | |
| return AlertDialog( | |
| title: const Text('Configure Base URL'), | |
| content: SingleChildScrollView( | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| TextField( | |
| controller: controller, | |
| decoration: const InputDecoration( | |
| hintText: 'https://annas-archive.li', | |
| labelText: 'Base URL', | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| actions: [ | |
| TextButton( | |
| onPressed: () { | |
| Navigator.of(context).pop(); | |
| }, | |
| child: Text('Cancel', | |
| style: | |
| TextStyle(color: Theme.of(context).colorScheme.error)), | |
| ), | |
| ElevatedButton( | |
| style: ElevatedButton.styleFrom( | |
| backgroundColor: Colors.red, | |
| foregroundColor: Colors.white, | |
| ), | |
| onPressed: () async { | |
| String newUrl = controller.text.trim(); | |
| // Remove trailing slash if present | |
| if (newUrl.endsWith('/')) { | |
| newUrl = newUrl.substring(0, newUrl.length - 1); | |
| } | |
| // Validate URL format | |
| if (newUrl.isNotEmpty && | |
| (newUrl.startsWith('http://') || | |
| newUrl.startsWith('https://'))) { | |
| ref.read(baseUrlProvider.notifier).state = newUrl; | |
| await dataBase.savePreference( | |
| 'annasArchiveBaseUrl', newUrl); | |
| Navigator.of(context).pop(); | |
| // Show confirmation | |
| ScaffoldMessenger.of(context).showSnackBar( | |
| SnackBar( | |
| content: Text('Base URL updated to: $newUrl'), | |
| duration: const Duration(seconds: 2), | |
| ), | |
| ); | |
| } else { | |
| // Show error | |
| ScaffoldMessenger.of(context).showSnackBar( | |
| const SnackBar( | |
| content: Text( | |
| 'Invalid URL. Must start with http:// or https://'), | |
| duration: Duration(seconds: 2), | |
| ), | |
| ); | |
| } | |
| }, | |
| child: const Text('Save'), | |
| ), | |
| ], | |
| ); | |
| }, | |
| ); | |
| } finally { | |
| controller.dispose(); | |
| } |
| ref.read(baseUrlProvider.notifier).state = newUrl; | ||
| await dataBase.savePreference('annasArchiveBaseUrl', newUrl); | ||
| Navigator.of(context).pop(); | ||
| // Show confirmation | ||
| ScaffoldMessenger.of(context).showSnackBar( | ||
| SnackBar( | ||
| content: Text('Base URL updated to: $newUrl'), | ||
| duration: const Duration(seconds: 2), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
Navigator.of(context).pop() is called and then a SnackBar is shown using the dialog's context. After popping, that context may no longer be valid / may not have the intended ScaffoldMessenger. Consider capturing a parent BuildContext (from the page) for ScaffoldMessenger, or show the snackbar before popping, or use ScaffoldMessenger.of(rootContext).
| String newUrl = controller.text.trim(); | ||
| // Remove trailing slash if present | ||
| if (newUrl.endsWith('/')) { | ||
| newUrl = newUrl.substring(0, newUrl.length - 1); | ||
| } | ||
| // Validate URL format | ||
| if (newUrl.isNotEmpty && | ||
| (newUrl.startsWith('http://') || | ||
| newUrl.startsWith('https://'))) { | ||
| ref.read(baseUrlProvider.notifier).state = newUrl; |
There was a problem hiding this comment.
URL validation only checks the scheme prefix and strips a trailing slash; it still allows invalid/malformed URLs (e.g. https:// or https://host/path) which will break URL construction later ($baseUrl/search...). Consider validating via Uri.tryParse, ensuring a non-empty host, and rejecting non-empty path/query/fragment so the stored value is an origin/base host.
| final cookieProvider = StateProvider<String>((ref) => ""); | ||
| final userAgentProvider = StateProvider<String>((ref) => ""); | ||
| final baseUrlProvider = | ||
| StateProvider<String>((ref) => "https://annas-archive.li"); |
There was a problem hiding this comment.
The new configurable baseUrlProvider is only wired into search/book-info requests. Download mirror handling still hard-codes https://annas-archive.se in lib/services/download_file.dart (and even drops those URLs in _reorderMirrors), which will make downloads behave incorrectly if the user configures the base URL to that domain. Consider refactoring mirror ordering/filtering to not hard-code a specific domain and/or to use the configured base URL.
| if (newUrl.isNotEmpty && | ||
| (newUrl.startsWith('http://') || | ||
| newUrl.startsWith('https://'))) { |
There was a problem hiding this comment.
The base URL dialog explicitly allows values starting with http://, which will cause all subsequent requests via Dio to use unencrypted HTTP when such a URL is saved. An attacker on the same network can eavesdrop on or tamper with users’ searches and downloaded content whenever a non-TLS mirror is configured. Restrict this setting to https:// URLs (or gate http:// usage behind an explicit developer/debug flag) so that production traffic is always protected by TLS.
Due to recently issues with "the" site, this pr introduces support for configuring the base URL at runtime, allowing users to change the source URL from the settings page.