Fix DB downgrades to match creating from scratch

Fixes #1427.

Prerequisite for #1421.

## Selected commit messages

#### f64450168 db test: Fix migration tests to use intended source versions

The `fromVersion` reference inside this test body, for each of the
test cases, was referring to the same one, mutable `fromVersion`
variable.  So by the time any of them ran, its value was 5, and they
tested migrating from 5 to 2, 5 to 3, 5 to 4, 5 to 5 respectively.
Meanwhile the test names reflected the intended migrations, because
the names were computed eagerly, reading the value of `fromVersion`
before moving on to the next iteration.

To fix the issue, make a fresh `fromVersion` binding inside each
iteration of the loop.

#### 8ed6c5b8e db [nfc]: Assert downgrades are always to current version

And adjust a test to make that true in tests too.

#### e90ea1055 db [nfc]: Simplify downgrades by using that target is latest

As explained in the parent commit, and asserted just above the
_dropAndCreateAll call site, this fact always holds.  That lets
us simplify how downgrades work, and as a bonus cut one step
from the checklist for making schema updates.

This reverts some of the changes made in 601936da7.

#### 4d8a667fb db: Cut transaction wrappers within migrations

No transactions here will do any good if the changes aren't in a
transaction with the `PRAGMA user_version =` statements that update
the database's record of what version the schema is at, and therefore
of which migrations might be needed.

And if Drift is setting up for us such a transaction, it'll
necessarily enclose the whole migration anyway.

#### f03630805 db: Fix downgrades so they match creating from scratch

Fixes #1427.

Schema downgrades fail (from future schema versions)
In #1416, we should have adjusted `AppDatabase._dropAndCreateAll` to reflect the change that PR made to the `onCreate` in `migration`, making it insert the GlobalSettings row.

(Originally noticed this at https://github.com/zulip/zulip-flutter/issues/1421#issuecomment-2737882493 .)

Without that adjustment, if you switch to a branch (like #1421) with a later schema version, then back to schema version 5 — only really possible as a developer — you get this error at startup:
```
E/flutter (15620): [ERROR:flutter/runtime/dart_vm_initializer.cc(40)] Unhandled Exception: Bad state: No element
E/flutter (15620): #0      List.single (dart:core-patch/growable_array.dart:358:22)
E/flutter (15620): #1      Selectable.getSingle (package:drift/src/runtime/query_builder/statements/query.dart:239:26)
E/flutter (15620): <asynchronous suspension>
E/flutter (15620): #2      AppDatabase.getGlobalSettings (package:zulip/model/database.dart:186:12)
E/flutter (15620): <asynchronous suspension>
E/flutter (15620): #3      LiveGlobalStore.load (package:zulip/model/store.dart:886:28)
E/flutter (15620): <asynchronous suspension>
E/flutter (15620): #4      _GlobalStoreWidgetState.initState.<anonymous closure> (package:zulip/widgets/store.dart:89:21)
E/flutter (15620): <asynchronous suspension>
```
and the app fails to get past the loading screen.

### Workaround

To unwedge from that state, you can switch to a version before schema version 5 (e.g., to this week's v0.0.27 release) and then forward again; or uninstall the app and reinstall.

flutter test test/model/database_test.dart --reporter expanded 2>&1

diff --git a/lib/model/database.dart b/lib/model/database.dart
index 6393294438..ed6cba5f74 100644
--- a/lib/model/database.dart
+++ b/lib/model/database.dart
@@ -68,65 +68,44 @@ class UriConverter extends TypeConverter<Uri, String> {
   @override Uri fromSql(String fromDb) => Uri.parse(fromDb);
 }

-// TODO(drift): generate this
-VersionedSchema _getSchema({
-  required DatabaseConnectionUser database,
-  required int schemaVersion,
-}) {
-  switch (schemaVersion) {
-    case 2:
-      return Schema2(database: database);
-    case 3:
-      return Schema3(database: database);
-    case 4:
-      return Schema4(database: database);
-    case 5:
-      return Schema5(database: database);
-    default:
-      throw Exception('unknown schema version: $schemaVersion');
-  }
-}
-
 @DriftDatabase(tables: [GlobalSettings, Accounts])
 class AppDatabase extends _$AppDatabase {
   AppDatabase(super.e);

   // When updating the schema:
-  //  * Make the change in the table classes, and bump schemaVersion.
+  //  * Make the change in the table classes, and bump latestSchemaVersion.
   //  * Export the new schema and generate test migrations with drift:
   //    $ tools/check --fix drift
   //    and generate database code with build_runner.
   //    See ../../README.md#generated-files for more
   //    information on using the build_runner.
-  //  * Update [_getSchema] to handle the new schemaVersion.
   //  * Write a migration in `_migrationSteps` below.
   //  * Write tests.
+  static const int latestSchemaVersion = 5; // See note.
+
   @override
-  int get schemaVersion => 5; // See note.
-
-  static Future<void> _dropAndCreateAll(Migrator m, {
-    required int schemaVersion,
-  }) async {
-    await m.database.transaction(() async {
-      final query = m.database.customSelect(
-        "SELECT name FROM sqlite_master WHERE type='table'");
-      for (final row in await query.get()) {
-        final data = row.data;
-        final tableName = data['name'] as String;
-        // Skip sqlite-internal tables.  See for comparison:
-        //   https://www.sqlite.org/fileformat2.html#intschema
-        //   https://github.com/simolus3/drift/blob/0901c984a/drift_dev/lib/src/services/schema/verifier_common.dart#L9-L22
-        if (tableName.startsWith('sqlite_')) continue;
-        // No need to worry about SQL injection; this table name
-        // was already a table name in the database, not something
-        // that should be affected by user data.
-        await m.database.customStatement('DROP TABLE $tableName');
-      }
-      final schema = _getSchema(database: m.database, schemaVersion: schemaVersion);
-      for (final entity in schema.entities) {
-        await m.create(entity);
-      }
-    });
+  int get schemaVersion => latestSchemaVersion;
+
+  /// Drop all tables, indexes, etc., in the database.
+  ///
+  /// This includes tables that aren't known to the schema, for example because
+  /// they were defined by a future (perhaps experimental) version of the app
+  /// before switching back to the version currently running.
+  static Future<void> _dropAll(Migrator m) async {
+    final query = m.database.customSelect(
+      "SELECT name FROM sqlite_master WHERE type='table'");
+    for (final row in await query.get()) {
+      final data = row.data;
+      final tableName = data['name'] as String;
+      // Skip sqlite-internal tables.  See for comparison:
+      //   https://www.sqlite.org/fileformat2.html#intschema
+      //   https://github.com/simolus3/drift/blob/0901c984a/drift_dev/lib/src/services/schema/verifier_common.dart#L9-L22
+      if (tableName.startsWith('sqlite_')) continue;
+      // No need to worry about SQL injection; this table name
+      // was already a table name in the database, not something
+      // that should be affected by user data.
+      await m.database.customStatement('DROP TABLE $tableName');
+    }
   }

   static final MigrationStepWithVersion _migrationSteps = migrationSteps(
@@ -145,37 +124,45 @@ class AppDatabase extends _$AppDatabase {
       // This migration ensures there is a row in GlobalSettings.
       // (If the app already ran at schema 3 or 4, there will be;
       // if not, there won't be before this point.)
-      await m.database.transaction(() async {
-        final rows = await m.database.select(schema.globalSettings).get();
-        if (rows.isEmpty) {
-          await m.database.into(schema.globalSettings).insert(
-            // No field values; just use the defaults for both fields.
-            // (This is like `GlobalSettingsCompanion.insert()`, but
-            // without dependence on the current schema.)
-            RawValuesInsertable({}));
-        }
-      });
+      final rows = await m.database.select(schema.globalSettings).get();
+      if (rows.isEmpty) {
+        await m.database.into(schema.globalSettings).insert(
+          // No field values; just use the defaults for both fields.
+          // (This is like `GlobalSettingsCompanion.insert()`, but
+          // without dependence on the current schema.)
+          RawValuesInsertable({}));
+      }
     },
   );

+  Future<void> _createLatestSchema(Migrator m) async {
+    await m.createAll();
+    // Corresponds to `from4to5` above.
+    await into(globalSettings).insert(GlobalSettingsCompanion());
+  }
+
   @override
   MigrationStrategy get migration {
     return MigrationStrategy(
-      onCreate: (Migrator m) async {
-        await m.createAll();
-        // Corresponds to `from4to5` above.
-        await into(globalSettings).insert(GlobalSettingsCompanion());
-      },
+      onCreate: _createLatestSchema,
       onUpgrade: (Migrator m, int from, int to) async {
         if (from > to) {
           // This should only ever happen in dev.  As a dev convenience,
           // drop everything from the database and start over.
           // TODO(log): log schema downgrade as an error
           assert(debugLog('Downgrading schema from v$from to v$to.'));
-          await _dropAndCreateAll(m, schemaVersion: to);
+
+          // In the actual app, the target schema version is always
+          // the latest version as of the code that's being run.
+          // Migrating to earlier versions is useful only for isolating steps
+          // in migration tests; we can forego that for testing downgrades.
+          assert(to == latestSchemaVersion);
+
+          await _dropAll(m);
+          await _createLatestSchema(m);
           return;
         }
-        assert(1 <= from && from <= to && to <= schemaVersion);
+        assert(1 <= from && from <= to && to <= latestSchemaVersion);

         await m.runMigrationSteps(from: from, to: to, steps: _migrationSteps);
       });

diff --git a/test/model/database_test.dart b/test/model/database_test.dart
index c6245bc5cc..3d7e8f170d 100644
--- a/test/model/database_test.dart
+++ b/test/model/database_test.dart
@@ -127,7 +127,8 @@ void main() {
     });

     test('downgrading', () async {
-      final schema = await verifier.schemaAt(2);
+      final toVersion = AppDatabase.latestSchemaVersion;
+      final schema = await verifier.schemaAt(toVersion);

       // This simulates the scenario during development when running the app
       // with a future schema version that has additional tables and columns.
@@ -135,17 +136,19 @@ void main() {
       await before.customStatement('CREATE TABLE test_extra (num int)');
       await before.customStatement('ALTER TABLE accounts ADD extra_column int');
       await check(verifier.migrateAndValidate(
-        before, 2, validateDropped: true)).throws<SchemaMismatch>();
+        before, toVersion, validateDropped: true)).throws<SchemaMismatch>();
       // Override the schema version by modifying the underlying value
       // drift internally keeps track of in the database.
       // TODO(drift): Expose a better interface for testing this.
-      await before.customStatement('PRAGMA user_version = 999;');
+      await before.customStatement('PRAGMA user_version = ${toVersion + 1};');
       await before.close();

       // Simulate starting up the app, with an older schema version that
       // does not have the extra tables and columns.
       final after = AppDatabase(schema.newConnection());
-      await verifier.migrateAndValidate(after, 2, validateDropped: true);
+      await verifier.migrateAndValidate(after, toVersion, validateDropped: true);
+      // Check that a custom migration/setup step of ours got run too.
+      check(await after.getGlobalSettings()).themeSetting.isNull();
       await after.close();
     });

@@ -153,15 +156,16 @@ void main() {
       const versions = GeneratedHelper.versions;
       final latestVersion = versions.last;

-      int fromVersion = versions.first;
+      int prev = versions.first;
       for (final toVersion in versions.skip(1)) {
+        final fromVersion = prev;
         test('from v$fromVersion to v$toVersion', () async {
           final connection = await verifier.startAt(fromVersion);
           final db = AppDatabase(connection);
           await verifier.migrateAndValidate(db, toVersion);
           await db.close();
         });
-        fromVersion = toVersion;
+        prev = toVersion;
       }

       for (final fromVersion in versions) {


Support setting browser preference

Fixes #1228.

This is taken from #1167, addressing the reviews there.

Offer "Open links with in-app browser" setting
Like we do in the legacy apps.

An instance of:
- #97

flutter test test/model/database_test.dart test/model/settings_test.dart test/widgets/content_test.dart test/widgets/profile_test.dart test/widgets/settings_test.dart --reporter expanded 2>&1

diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 7f37a00dad..1973f5fab7 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -811,6 +811,10 @@
   "@themeSettingSystem": {
     "description": "Label for system theme setting."
   },
+  "openLinksWithInAppBrowser": "Open links with in-app browser",
+  "@openLinksWithInAppBrowser": {
+    "description": "Label for toggling setting to open links with in-app browser"
+  },
   "pollWidgetQuestionMissing": "No question.",
   "@pollWidgetQuestionMissing": {
     "description": "Text to display for a poll when the question is missing"
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 2f03458e05..d4bdd2db47 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -1185,6 +1185,12 @@ abstract class ZulipLocalizations {
   /// **'System'**
   String get themeSettingSystem;

+  /// Label for toggling setting to open links with in-app browser
+  ///
+  /// In en, this message translates to:
+  /// **'Open links with in-app browser'**
+  String get openLinksWithInAppBrowser;
+
   /// Text to display for a poll when the question is missing
   ///
   /// In en, this message translates to:
diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart
index fd7905924b..5d76c0bfae 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -634,6 +634,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
   @override
   String get themeSettingSystem => 'System';

+  @override
+  String get openLinksWithInAppBrowser => 'Open links with in-app browser';
+
   @override
   String get pollWidgetQuestionMissing => 'No question.';

diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index 1d19cfc7b0..17df08bfd5 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -634,6 +634,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
   @override
   String get themeSettingSystem => 'System';

+  @override
+  String get openLinksWithInAppBrowser => 'Open links with in-app browser';
+
   @override
   String get pollWidgetQuestionMissing => 'No question.';

diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 58a4a1de59..9baef2dbae 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -634,6 +634,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
   @override
   String get themeSettingSystem => 'System';

+  @override
+  String get openLinksWithInAppBrowser => 'Open links with in-app browser';
+
   @override
   String get pollWidgetQuestionMissing => 'No question.';

diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index a5bba71bd1..fb3ca10833 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -634,6 +634,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
   @override
   String get themeSettingSystem => 'System';

+  @override
+  String get openLinksWithInAppBrowser => 'Open links with in-app browser';
+
   @override
   String get pollWidgetQuestionMissing => 'No question.';

diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index e78e60dcb2..45ad091f43 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -634,6 +634,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
   @override
   String get themeSettingSystem => 'System';

+  @override
+  String get openLinksWithInAppBrowser => 'Open links with in-app browser';
+
   @override
   String get pollWidgetQuestionMissing => 'Brak pytania.';

diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index e4bd460f72..946fe8c231 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -634,6 +634,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
   @override
   String get themeSettingSystem => 'System';

+  @override
+  String get openLinksWithInAppBrowser => 'Open links with in-app browser';
+
   @override
   String get pollWidgetQuestionMissing => 'Нет вопроса.';

diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 9ec68077ae..90d71704f9 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -634,6 +634,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
   @override
   String get themeSettingSystem => 'System';

+  @override
+  String get openLinksWithInAppBrowser => 'Open links with in-app browser';
+
   @override
   String get pollWidgetQuestionMissing => 'Bez otázky.';

diff --git a/lib/model/database.dart b/lib/model/database.dart
index 263c1ab989..027cec9958 100644
--- a/lib/model/database.dart
+++ b/lib/model/database.dart
@@ -18,6 +18,9 @@ part 'database.g.dart';
 class GlobalSettings extends Table {
   Column<String> get themeSetting => textEnum<ThemeSetting>()
     .nullable()();
+
+  Column<String> get browserPreference => textEnum<BrowserPreference>()
+    .nullable()();
 }

 /// The table of [Account] records in the app's database.
@@ -73,6 +76,8 @@ VersionedSchema _getSchema({
       return Schema2(database: database);
     case 3:
       return Schema3(database: database);
+    case 4:
+      return Schema4(database: database);
     default:
       throw Exception('unknown schema version: $schemaVersion');
   }
@@ -93,7 +98,7 @@ class AppDatabase extends _$AppDatabase {
   //  * Write a migration in `onUpgrade` below.
   //  * Write tests.
   @override
-  int get schemaVersion => 3; // See note.
+  int get schemaVersion => 4; // See note.

   Future<void> _dropAndCreateAll(Migrator m, {
     required int schemaVersion,
@@ -145,6 +150,10 @@ class AppDatabase extends _$AppDatabase {
             from2To3: (m, schema) async {
               await m.createTable(schema.globalSettings);
             },
+            from3To4: (m, schema) async {
+              await m.addColumn(
+                schema.globalSettings, schema.globalSettings.browserPreference);
+            },
           ));
       });
   }
diff --git a/lib/model/database.g.dart b/lib/model/database.g.dart
index fcb5292aa6..9c0946af5b 100644
--- a/lib/model/database.g.dart
+++ b/lib/model/database.g.dart
@@ -21,7 +21,18 @@ class $GlobalSettingsTable extends GlobalSettings
     requiredDuringInsert: false,
   ).withConverter<ThemeSetting?>($GlobalSettingsTable.$converterthemeSettingn);
   @override
-  List<GeneratedColumn> get $columns => [themeSetting];
+  late final GeneratedColumnWithTypeConverter<BrowserPreference?, String>
+  browserPreference = GeneratedColumn<String>(
+    'browser_preference',
+    aliasedName,
+    true,
+    type: DriftSqlType.string,
+    requiredDuringInsert: false,
+  ).withConverter<BrowserPreference?>(
+    $GlobalSettingsTable.$converterbrowserPreferencen,
+  );
+  @override
+  List<GeneratedColumn> get $columns => [themeSetting, browserPreference];
   @override
   String get aliasedName => _alias ?? actualTableName;
   @override
@@ -39,6 +50,13 @@ class $GlobalSettingsTable extends GlobalSettings
           data['${effectivePrefix}theme_setting'],
         ),
       ),
+      browserPreference: $GlobalSettingsTable.$converterbrowserPreferencen
+          .fromSql(
+            attachedDatabase.typeMapping.read(
+              DriftSqlType.string,
+              data['${effectivePrefix}browser_preference'],
+            ),
+          ),
     );
   }

@@ -55,12 +73,21 @@ class $GlobalSettingsTable extends GlobalSettings
   $converterthemeSettingn = JsonTypeConverter2.asNullable(
     $converterthemeSetting,
   );
+  static JsonTypeConverter2<BrowserPreference, String, String>
+  $converterbrowserPreference = const EnumNameConverter<BrowserPreference>(
+    BrowserPreference.values,
+  );
+  static JsonTypeConverter2<BrowserPreference?, String?, String?>
+  $converterbrowserPreferencen = JsonTypeConverter2.asNullable(
+    $converterbrowserPreference,
+  );
 }

 class GlobalSettingsData extends DataClass
     implements Insertable<GlobalSettingsData> {
   final ThemeSetting? themeSetting;
-  const GlobalSettingsData({this.themeSetting});
+  final BrowserPreference? browserPreference;
+  const GlobalSettingsData({this.themeSetting, this.browserPreference});
   @override
   Map<String, Expression> toColumns(bool nullToAbsent) {
     final map = <String, Expression>{};
@@ -69,6 +96,13 @@ class GlobalSettingsData extends DataClass
         $GlobalSettingsTable.$converterthemeSettingn.toSql(themeSetting),
       );
     }
+    if (!nullToAbsent || browserPreference != null) {
+      map['browser_preference'] = Variable<String>(
+        $GlobalSettingsTable.$converterbrowserPreferencen.toSql(
+          browserPreference,
+        ),
+      );
+    }
     return map;
   }

@@ -78,6 +112,10 @@ class GlobalSettingsData extends DataClass
           themeSetting == null && nullToAbsent
               ? const Value.absent()
               : Value(themeSetting),
+      browserPreference:
+          browserPreference == null && nullToAbsent
+              ? const Value.absent()
+              : Value(browserPreference),
     );
   }

@@ -90,6 +128,8 @@ class GlobalSettingsData extends DataClass
       themeSetting: $GlobalSettingsTable.$converterthemeSettingn.fromJson(
         serializer.fromJson<String?>(json['themeSetting']),
       ),
+      browserPreference: $GlobalSettingsTable.$converterbrowserPreferencen
+          .fromJson(serializer.fromJson<String?>(json['browserPreference'])),
     );
   }
   @override
@@ -99,13 +139,23 @@ class GlobalSettingsData extends DataClass
       'themeSetting': serializer.toJson<String?>(
         $GlobalSettingsTable.$converterthemeSettingn.toJson(themeSetting),
       ),
+      'browserPreference': serializer.toJson<String?>(
+        $GlobalSettingsTable.$converterbrowserPreferencen.toJson(
+          browserPreference,
+        ),
+      ),
     };
   }

   GlobalSettingsData copyWith({
     Value<ThemeSetting?> themeSetting = const Value.absent(),
+    Value<BrowserPreference?> browserPreference = const Value.absent(),
   }) => GlobalSettingsData(
     themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting,
+    browserPreference:
+        browserPreference.present
+            ? browserPreference.value
+            : this.browserPreference,
   );
   GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) {
     return GlobalSettingsData(
@@ -113,52 +163,66 @@ class GlobalSettingsData extends DataClass
           data.themeSetting.present
               ? data.themeSetting.value
               : this.themeSetting,
+      browserPreference:
+          data.browserPreference.present
+              ? data.browserPreference.value
+              : this.browserPreference,
     );
   }

   @override
   String toString() {
     return (StringBuffer('GlobalSettingsData(')
-          ..write('themeSetting: $themeSetting')
+          ..write('themeSetting: $themeSetting, ')
+          ..write('browserPreference: $browserPreference')
           ..write(')'))
         .toString();
   }

   @override
-  int get hashCode => themeSetting.hashCode;
+  int get hashCode => Object.hash(themeSetting, browserPreference);
   @override
   bool operator ==(Object other) =>
       identical(this, other) ||
-      (other is GlobalSettingsData && other.themeSetting == this.themeSetting);
+      (other is GlobalSettingsData &&
+          other.themeSetting == this.themeSetting &&
+          other.browserPreference == this.browserPreference);
 }

 class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
   final Value<ThemeSetting?> themeSetting;
+  final Value<BrowserPreference?> browserPreference;
   final Value<int> rowid;
   const GlobalSettingsCompanion({
     this.themeSetting = const Value.absent(),
+    this.browserPreference = const Value.absent(),
     this.rowid = const Value.absent(),
   });
   GlobalSettingsCompanion.insert({
     this.themeSetting = const Value.absent(),
+    this.browserPreference = const Value.absent(),
     this.rowid = const Value.absent(),
   });
   static Insertable<GlobalSettingsData> custom({
     Expression<String>? themeSetting,
+    Expression<String>? browserPreference,
     Expression<int>? rowid,
   }) {
     return RawValuesInsertable({
       if (themeSetting != null) 'theme_setting': themeSetting,
+      if (browserPreference != null) 'browser_preference': browserPreference,
       if (rowid != null) 'rowid': rowid,
     });
   }

   GlobalSettingsCompanion copyWith({
     Value<ThemeSetting?>? themeSetting,
+    Value<BrowserPreference?>? browserPreference,
     Value<int>? rowid,
   }) {
     return GlobalSettingsCompanion(
       themeSetting: themeSetting ?? this.themeSetting,
+      browserPreference: browserPreference ?? this.browserPreference,
       rowid: rowid ?? this.rowid,
     );
   }
@@ -171,6 +235,13 @@ class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
         $GlobalSettingsTable.$converterthemeSettingn.toSql(themeSetting.value),
       );
     }
+    if (browserPreference.present) {
+      map['browser_preference'] = Variable<String>(
+        $GlobalSettingsTable.$converterbrowserPreferencen.toSql(
+          browserPreference.value,
+        ),
+      );
+    }
     if (rowid.present) {
       map['rowid'] = Variable<int>(rowid.value);
     }
@@ -181,6 +252,7 @@ class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
   String toString() {
     return (StringBuffer('GlobalSettingsCompanion(')
           ..write('themeSetting: $themeSetting, ')
+          ..write('browserPreference: $browserPreference, ')
           ..write('rowid: $rowid')
           ..write(')'))
         .toString();
@@ -804,11 +876,13 @@ abstract class _$AppDatabase extends GeneratedDatabase {
 typedef $$GlobalSettingsTableCreateCompanionBuilder =
     GlobalSettingsCompanion Function({
       Value<ThemeSetting?> themeSetting,
+      Value<BrowserPreference?> browserPreference,
       Value<int> rowid,
     });
 typedef $$GlobalSettingsTableUpdateCompanionBuilder =
     GlobalSettingsCompanion Function({
       Value<ThemeSetting?> themeSetting,
+      Value<BrowserPreference?> browserPreference,
       Value<int> rowid,
     });

@@ -826,6 +900,12 @@ class $$GlobalSettingsTableFilterComposer
     column: $table.themeSetting,
     builder: (column) => ColumnWithTypeConverterFilters(column),
   );
+
+  ColumnWithTypeConverterFilters<BrowserPreference?, BrowserPreference, String>
+  get browserPreference => $composableBuilder(
+    column: $table.browserPreference,
+    builder: (column) => ColumnWithTypeConverterFilters(column),
+  );
 }

 class $$GlobalSettingsTableOrderingComposer
@@ -841,6 +921,11 @@ class $$GlobalSettingsTableOrderingComposer
     column: $table.themeSetting,
     builder: (column) => ColumnOrderings(column),
   );
+
+  ColumnOrderings<String> get browserPreference => $composableBuilder(
+    column: $table.browserPreference,
+    builder: (column) => ColumnOrderings(column),
+  );
 }

 class $$GlobalSettingsTableAnnotationComposer
@@ -857,6 +942,12 @@ class $$GlobalSettingsTableAnnotationComposer
         column: $table.themeSetting,
         builder: (column) => column,
       );
+
+  GeneratedColumnWithTypeConverter<BrowserPreference?, String>
+  get browserPreference => $composableBuilder(
+    column: $table.browserPreference,
+    builder: (column) => column,
+  );
 }

 class $$GlobalSettingsTableTableManager
@@ -901,17 +992,23 @@ class $$GlobalSettingsTableTableManager
           updateCompanionCallback:
               ({
                 Value<ThemeSetting?> themeSetting = const Value.absent(),
+                Value<BrowserPreference?> browserPreference =
+                    const Value.absent(),
                 Value<int> rowid = const Value.absent(),
               }) => GlobalSettingsCompanion(
                 themeSetting: themeSetting,
+                browserPreference: browserPreference,
                 rowid: rowid,
               ),
           createCompanionCallback:
               ({
                 Value<ThemeSetting?> themeSetting = const Value.absent(),
+                Value<BrowserPreference?> browserPreference =
+                    const Value.absent(),
                 Value<int> rowid = const Value.absent(),
               }) => GlobalSettingsCompanion.insert(
                 themeSetting: themeSetting,
+                browserPreference: browserPreference,
                 rowid: rowid,
               ),
           withReferenceMapper:
diff --git a/lib/model/schema_versions.g.dart b/lib/model/schema_versions.g.dart
index bb5a62475a..aa2dec5a0c 100644
--- a/lib/model/schema_versions.g.dart
+++ b/lib/model/schema_versions.g.dart
@@ -181,9 +181,70 @@ i1.GeneratedColumn<String> _column_9(String aliasedName) =>
       true,
       type: i1.DriftSqlType.string,
     );
+
+final class Schema4 extends i0.VersionedSchema {
+  Schema4({required super.database}) : super(version: 4);
+  @override
+  late final List<i1.DatabaseSchemaEntity> entities = [
+    globalSettings,
+    accounts,
+  ];
+  late final Shape2 globalSettings = Shape2(
+    source: i0.VersionedTable(
+      entityName: 'global_settings',
+      withoutRowId: false,
+      isStrict: false,
+      tableConstraints: [],
+      columns: [_column_9, _column_10],
+      attachedDatabase: database,
+    ),
+    alias: null,
+  );
+  late final Shape0 accounts = Shape0(
+    source: i0.VersionedTable(
+      entityName: 'accounts',
+      withoutRowId: false,
+      isStrict: false,
+      tableConstraints: [
+        'UNIQUE(realm_url, user_id)',
+        'UNIQUE(realm_url, email)',
+      ],
+      columns: [
+        _column_0,
+        _column_1,
+        _column_2,
+        _column_3,
+        _column_4,
+        _column_5,
+        _column_6,
+        _column_7,
+        _column_8,
+      ],
+      attachedDatabase: database,
+    ),
+    alias: null,
+  );
+}
+
+class Shape2 extends i0.VersionedTable {
+  Shape2({required super.source, required super.alias}) : super.aliased();
+  i1.GeneratedColumn<String> get themeSetting =>
+      columnsByName['theme_setting']! as i1.GeneratedColumn<String>;
+  i1.GeneratedColumn<String> get browserPreference =>
+      columnsByName['browser_preference']! as i1.GeneratedColumn<String>;
+}
+
+i1.GeneratedColumn<String> _column_10(String aliasedName) =>
+    i1.GeneratedColumn<String>(
+      'browser_preference',
+      aliasedName,
+      true,
+      type: i1.DriftSqlType.string,
+    );
 i0.MigrationStepWithVersion migrationSteps({
   required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
   required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
+  required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
 }) {
   return (currentVersion, database) async {
     switch (currentVersion) {
@@ -197,6 +258,11 @@ i0.MigrationStepWithVersion migrationSteps({
         final migrator = i1.Migrator(database, schema);
         await from2To3(migrator, schema);
         return 3;
+      case 3:
+        final schema = Schema4(database: database);
+        final migrator = i1.Migrator(database, schema);
+        await from3To4(migrator, schema);
+        return 4;
       default:
         throw ArgumentError.value('Unknown migration from $currentVersion');
     }
@@ -206,6 +272,11 @@ i0.MigrationStepWithVersion migrationSteps({
 i1.OnUpgrade stepByStep({
   required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
   required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
+  required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
 }) => i0.VersionedSchema.stepByStepHelper(
-  step: migrationSteps(from1To2: from1To2, from2To3: from2To3),
+  step: migrationSteps(
+    from1To2: from1To2,
+    from2To3: from2To3,
+    from3To4: from3To4,
+  ),
 );
diff --git a/lib/model/settings.dart b/lib/model/settings.dart
index 535d06aa01..928eb0e1cd 100644
--- a/lib/model/settings.dart
+++ b/lib/model/settings.dart
@@ -1,4 +1,8 @@
+import 'package:flutter/foundation.dart';
+
 import '../generated/l10n/zulip_localizations.dart';
+import 'binding.dart';
+import 'database.dart';

 /// The user's choice of visual theme for the app.
 ///
@@ -24,3 +28,54 @@ enum ThemeSetting {
     };
   }
 }
+
+/// What browser the user has set to use for opening links in messages.
+///
+/// Renaming existing enum values will invalidate the database.
+/// Write a migration if such a change is necessary.
+enum BrowserPreference {
+  /// Use the in-app browser for HTTP links.
+  ///
+  /// For other types of links (e.g. mailto) where a browser won't work,
+  /// this falls back to [UrlLaunchMode.platformDefault].
+  inApp,
+
+  /// Use the user's default browser app.
+  external,
+}
+
+extension GlobalSettingsHelpers on GlobalSettingsData {
+  BrowserPreference get effectiveBrowserPreference {
+    if (browserPreference != null) return browserPreference!;
+    return switch (defaultTargetPlatform) {
+      // On iOS we prefer UrlLaunchMode.externalApplication because (for
+      // HTTP URLs) UrlLaunchMode.platformDefault uses SFSafariViewController,
+      // which gives an awkward UX as described here:
+      //  https://chat.zulip.org/#narrow/stream/48-mobile/topic/in-app.20browser/near/1169118
+      TargetPlatform.iOS => BrowserPreference.external,
+
+      // On Android we prefer an in-app browser.  See discussion from 2021:
+      //   https://chat.zulip.org/#narrow/channel/48-mobile/topic/in-app.20browser/near/1169095
+      // That's also the `url_launcher` default (at least as of 2025).
+      _ => BrowserPreference.inApp,
+    };
+  }
+
+  UrlLaunchMode getUrlLaunchMode(Uri url) {
+    switch (effectiveBrowserPreference) {
+      case BrowserPreference.inApp:
+        if (!(url.scheme == 'https' || url.scheme == 'http')) {
+          // For URLs on non-HTTP schemes such as `mailto`,
+          // `url_launcher.launchUrl` rejects `inAppBrowserView` with an error:
+          //   https://github.com/flutter/packages/blob/9cc6f370/packages/url_launcher/url_launcher/lib/src/url_launcher_uri.dart#L46-L51
+          // TODO(upstream/url_launcher): should fall back in this case instead
+          //   (as the `launchUrl` doc already says it may do).
+          return UrlLaunchMode.platformDefault;
+        }
+        return UrlLaunchMode.inAppBrowserView;
+
+      case BrowserPreference.external:
+        return UrlLaunchMode.externalApplication;
+    }
+  }
+}
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 55b5331c3c..afafd03e8c 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -1,7 +1,6 @@
 import 'dart:async';

 import 'package:flutter/cupertino.dart';
-import 'package:flutter/foundation.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
@@ -16,6 +15,7 @@ import '../model/avatar_url.dart';
 import '../model/binding.dart';
 import '../model/content.dart';
 import '../model/internal_link.dart';
+import '../model/settings.dart';
 import 'code_block.dart';
 import 'dialog.dart';
 import 'icons.dart';
@@ -1439,18 +1439,12 @@ void _launchUrl(BuildContext context, String urlString) async {
     return;
   }

+  final globalSettings = GlobalStoreWidget.of(context).globalSettings;
   bool launched = false;
   String? errorMessage;
   try {
     launched = await ZulipBinding.instance.launchUrl(url,
-      mode: switch (defaultTargetPlatform) {
-        // On iOS we prefer LaunchMode.externalApplication because (for
-        // HTTP URLs) LaunchMode.platformDefault uses SFSafariViewController,
-        // which gives an awkward UX as described here:
-        //  https://chat.zulip.org/#narrow/stream/48-mobile/topic/in-app.20browser/near/1169118
-        TargetPlatform.iOS => UrlLaunchMode.externalApplication,
-        _ => UrlLaunchMode.platformDefault,
-      });
+      mode: globalSettings.getUrlLaunchMode(url));
   } on PlatformException catch (e) {
     errorMessage = e.message;
   }
diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart
index 8ece4d0578..7777759c4f 100644
--- a/lib/widgets/settings.dart
+++ b/lib/widgets/settings.dart
@@ -24,6 +24,7 @@ class SettingsPage extends StatelessWidget {
         title: Text(zulipLocalizations.settingsPageTitle)),
       body: Column(children: [
         const _ThemeSetting(),
+        const _BrowserPreferenceSetting(),
       ]));
   }
 }
@@ -54,3 +55,26 @@ class _ThemeSetting extends StatelessWidget {
       ]);
   }
 }
+
+class _BrowserPreferenceSetting extends StatelessWidget {
+  const _BrowserPreferenceSetting();
+
+  void _handleChange(BuildContext context, bool newOpenLinksWithInAppBrowser) {
+    GlobalStoreWidget.of(context).updateGlobalSettings(
+      GlobalSettingsCompanion(browserPreference: Value(
+        newOpenLinksWithInAppBrowser ? BrowserPreference.inApp
+                                     : BrowserPreference.external)));
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final zulipLocalizations = ZulipLocalizations.of(context);
+    final openLinksWithInAppBrowser =
+      GlobalStoreWidget.of(context).globalSettings.effectiveBrowserPreference
+      == BrowserPreference.inApp;
+    return SwitchListTile.adaptive(
+      title: Text(zulipLocalizations.openLinksWithInAppBrowser),
+      value: openLinksWithInAppBrowser,
+      onChanged: (newValue) => _handleChange(context, newValue));
+  }
+}

diff --git a/test/example_data.dart b/test/example_data.dart
index 03cabbda97..955f5dacfb 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -880,9 +880,11 @@ ChannelUpdateEvent channelUpdateEvent(

 GlobalSettingsData globalSettings({
   ThemeSetting? themeSetting,
+  BrowserPreference? browserPreference,
 }) {
   return GlobalSettingsData(
     themeSetting: themeSetting,
+    browserPreference: browserPreference,
   );
 }

diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart
index 2f3eac042f..49e402d158 100644
--- a/test/flutter_checks.dart
+++ b/test/flutter_checks.dart
@@ -176,6 +176,10 @@ extension RadioListTileChecks<T> on Subject<RadioListTile<T>> {
   Subject<bool> get checked => has((x) => x.checked, 'checked');
 }

+extension SwitchListTileChecks<T> on Subject<SwitchListTile> {
+  Subject<bool> get value => has((x) => x.value, 'value');
+}
+
 extension ThemeDataChecks on Subject<ThemeData> {
   Subject<Brightness> get brightness => has((x) => x.brightness, 'brightness');
 }
diff --git a/test/model/database_test.dart b/test/model/database_test.dart
index 90dbcbddf1..28441e13ec 100644
--- a/test/model/database_test.dart
+++ b/test/model/database_test.dart
@@ -9,6 +9,8 @@ import 'package:zulip/model/settings.dart';
 import 'schemas/schema.dart';
 import 'schemas/schema_v1.dart' as v1;
 import 'schemas/schema_v2.dart' as v2;
+import 'schemas/schema_v3.dart' as v3;
+import 'schemas/schema_v4.dart' as v4;
 import 'store_checks.dart';

 void main() {
@@ -204,6 +206,25 @@ void main() {
       });
       await after.close();
     });
+
+    test('upgrade to v4, with data', () async {
+      final schema = await verifier.schemaAt(3);
+      final before = v3.DatabaseAtV3(schema.newConnection());
+      await before.into(before.globalSettings).insert(
+        v3.GlobalSettingsCompanion.insert(
+          themeSetting: Value(ThemeSetting.light.name)));
+      await before.close();
+
+      final db = AppDatabase(schema.newConnection());
+      await verifier.migrateAndValidate(db, 4);
+      await db.close();
+
+      final after = v4.DatabaseAtV4(schema.newConnection());
+      final globalSettings = await after.select(after.globalSettings).getSingle();
+      check(globalSettings.themeSetting).equals(ThemeSetting.light.name);
+      check(globalSettings.browserPreference).isNull();
+      await after.close();
+    });
   });
 }

diff --git a/test/model/schemas/drift_schema_v4.json b/test/model/schemas/drift_schema_v4.json
new file mode 100644
index 0000000000..718222de66
--- /dev/null
+++ b/test/model/schemas/drift_schema_v4.json
@@ -0,0 +1,1 @@
+{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter<ThemeSetting>(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter<BrowserPreference>(BrowserPreference.values)","dart_type_name":"BrowserPreference"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]}
\ No newline at end of file
diff --git a/test/model/schemas/schema.dart b/test/model/schemas/schema.dart
index 209e70d788..22131b11bb 100644
--- a/test/model/schemas/schema.dart
+++ b/test/model/schemas/schema.dart
@@ -6,6 +6,7 @@ import 'package:drift/internal/migrations.dart';
 import 'schema_v1.dart' as v1;
 import 'schema_v2.dart' as v2;
 import 'schema_v3.dart' as v3;
+import 'schema_v4.dart' as v4;

 class GeneratedHelper implements SchemaInstantiationHelper {
   @override
@@ -17,10 +18,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
         return v2.DatabaseAtV2(db);
       case 3:
         return v3.DatabaseAtV3(db);
+      case 4:
+        return v4.DatabaseAtV4(db);
       default:
         throw MissingSchemaException(version, versions);
     }
   }

-  static const versions = const [1, 2, 3];
+  static const versions = const [1, 2, 3, 4];
 }
diff --git a/test/model/schemas/schema_v4.dart b/test/model/schemas/schema_v4.dart
new file mode 100644
index 0000000000..e53e4fbe2a
--- /dev/null
+++ b/test/model/schemas/schema_v4.dart
@@ -0,0 +1,700 @@
+// dart format width=80
+// GENERATED CODE, DO NOT EDIT BY HAND.
+// ignore_for_file: type=lint
+import 'package:drift/drift.dart';
+
+class GlobalSettings extends Table
+    with TableInfo<GlobalSettings, GlobalSettingsData> {
+  @override
+  final GeneratedDatabase attachedDatabase;
+  final String? _alias;
+  GlobalSettings(this.attachedDatabase, [this._alias]);
+  late final GeneratedColumn<String> themeSetting = GeneratedColumn<String>(
+    'theme_setting',
+    aliasedName,
+    true,
+    type: DriftSqlType.string,
+    requiredDuringInsert: false,
+  );
+  late final GeneratedColumn<String> browserPreference =
+      GeneratedColumn<String>(
+        'browser_preference',
+        aliasedName,
+        true,
+        type: DriftSqlType.string,
+        requiredDuringInsert: false,
+      );
+  @override
+  List<GeneratedColumn> get $columns => [themeSetting, browserPreference];
+  @override
+  String get aliasedName => _alias ?? actualTableName;
+  @override
+  String get actualTableName => $name;
+  static const String $name = 'global_settings';
+  @override
+  Set<GeneratedColumn> get $primaryKey => const {};
+  @override
+  GlobalSettingsData map(Map<String, dynamic> data, {String? tablePrefix}) {
+    final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
+    return GlobalSettingsData(
+      themeSetting: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}theme_setting'],
+      ),
+      browserPreference: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}browser_preference'],
+      ),
+    );
+  }
+
+  @override
+  GlobalSettings createAlias(String alias) {
+    return GlobalSettings(attachedDatabase, alias);
+  }
+}
+
+class GlobalSettingsData extends DataClass
+    implements Insertable<GlobalSettingsData> {
+  final String? themeSetting;
+  final String? browserPreference;
+  const GlobalSettingsData({this.themeSetting, this.browserPreference});
+  @override
+  Map<String, Expression> toColumns(bool nullToAbsent) {
+    final map = <String, Expression>{};
+    if (!nullToAbsent || themeSetting != null) {
+      map['theme_setting'] = Variable<String>(themeSetting);
+    }
+    if (!nullToAbsent || browserPreference != null) {
+      map['browser_preference'] = Variable<String>(browserPreference);
+    }
+    return map;
+  }
+
+  GlobalSettingsCompanion toCompanion(bool nullToAbsent) {
+    return GlobalSettingsCompanion(
+      themeSetting:
+          themeSetting == null && nullToAbsent
+              ? const Value.absent()
+              : Value(themeSetting),
+      browserPreference:
+          browserPreference == null && nullToAbsent
+              ? const Value.absent()
+              : Value(browserPreference),
+    );
+  }
+
+  factory GlobalSettingsData.fromJson(
+    Map<String, dynamic> json, {
+    ValueSerializer? serializer,
+  }) {
+    serializer ??= driftRuntimeOptions.defaultSerializer;
+    return GlobalSettingsData(
+      themeSetting: serializer.fromJson<String?>(json['themeSetting']),
+      browserPreference: serializer.fromJson<String?>(
+        json['browserPreference'],
+      ),
+    );
+  }
+  @override
+  Map<String, dynamic> toJson({ValueSerializer? serializer}) {
+    serializer ??= driftRuntimeOptions.defaultSerializer;
+    return <String, dynamic>{
+      'themeSetting': serializer.toJson<String?>(themeSetting),
+      'browserPreference': serializer.toJson<String?>(browserPreference),
+    };
+  }
+
+  GlobalSettingsData copyWith({
+    Value<String?> themeSetting = const Value.absent(),
+    Value<String?> browserPreference = const Value.absent(),
+  }) => GlobalSettingsData(
+    themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting,
+    browserPreference:
+        browserPreference.present
+            ? browserPreference.value
+            : this.browserPreference,
+  );
+  GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) {
+    return GlobalSettingsData(
+      themeSetting:
+          data.themeSetting.present
+              ? data.themeSetting.value
+              : this.themeSetting,
+      browserPreference:
+          data.browserPreference.present
+              ? data.browserPreference.value
+              : this.browserPreference,
+    );
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('GlobalSettingsData(')
+          ..write('themeSetting: $themeSetting, ')
+          ..write('browserPreference: $browserPreference')
+          ..write(')'))
+        .toString();
+  }
+
+  @override
+  int get hashCode => Object.hash(themeSetting, browserPreference);
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      (other is GlobalSettingsData &&
+          other.themeSetting == this.themeSetting &&
+          other.browserPreference == this.browserPreference);
+}
+
+class GlobalSettingsCompanion extends UpdateCompanion<GlobalSettingsData> {
+  final Value<String?> themeSetting;
+  final Value<String?> browserPreference;
+  final Value<int> rowid;
+  const GlobalSettingsCompanion({
+    this.themeSetting = const Value.absent(),
+    this.browserPreference = const Value.absent(),
+    this.rowid = const Value.absent(),
+  });
+  GlobalSettingsCompanion.insert({
+    this.themeSetting = const Value.absent(),
+    this.browserPreference = const Value.absent(),
+    this.rowid = const Value.absent(),
+  });
+  static Insertable<GlobalSettingsData> custom({
+    Expression<String>? themeSetting,
+    Expression<String>? browserPreference,
+    Expression<int>? rowid,
+  }) {
+    return RawValuesInsertable({
+      if (themeSetting != null) 'theme_setting': themeSetting,
+      if (browserPreference != null) 'browser_preference': browserPreference,
+      if (rowid != null) 'rowid': rowid,
+    });
+  }
+
+  GlobalSettingsCompanion copyWith({
+    Value<String?>? themeSetting,
+    Value<String?>? browserPreference,
+    Value<int>? rowid,
+  }) {
+    return GlobalSettingsCompanion(
+      themeSetting: themeSetting ?? this.themeSetting,
+      browserPreference: browserPreference ?? this.browserPreference,
+      rowid: rowid ?? this.rowid,
+    );
+  }
+
+  @override
+  Map<String, Expression> toColumns(bool nullToAbsent) {
+    final map = <String, Expression>{};
+    if (themeSetting.present) {
+      map['theme_setting'] = Variable<String>(themeSetting.value);
+    }
+    if (browserPreference.present) {
+      map['browser_preference'] = Variable<String>(browserPreference.value);
+    }
+    if (rowid.present) {
+      map['rowid'] = Variable<int>(rowid.value);
+    }
+    return map;
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('GlobalSettingsCompanion(')
+          ..write('themeSetting: $themeSetting, ')
+          ..write('browserPreference: $browserPreference, ')
+          ..write('rowid: $rowid')
+          ..write(')'))
+        .toString();
+  }
+}
+
+class Accounts extends Table with TableInfo<Accounts, AccountsData> {
+  @override
+  final GeneratedDatabase attachedDatabase;
+  final String? _alias;
+  Accounts(this.attachedDatabase, [this._alias]);
+  late final GeneratedColumn<int> id = GeneratedColumn<int>(
+    'id',
+    aliasedName,
+    false,
+    hasAutoIncrement: true,
+    type: DriftSqlType.int,
+    requiredDuringInsert: false,
+    defaultConstraints: GeneratedColumn.constraintIsAlways(
+      'PRIMARY KEY AUTOINCREMENT',
+    ),
+  );
+  late final GeneratedColumn<String> realmUrl = GeneratedColumn<String>(
+    'realm_url',
+    aliasedName,
+    false,
+    type: DriftSqlType.string,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<int> userId = GeneratedColumn<int>(
+    'user_id',
+    aliasedName,
+    false,
+    type: DriftSqlType.int,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<String> email = GeneratedColumn<String>(
+    'email',
+    aliasedName,
+    false,
+    type: DriftSqlType.string,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<String> apiKey = GeneratedColumn<String>(
+    'api_key',
+    aliasedName,
+    false,
+    type: DriftSqlType.string,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<String> zulipVersion = GeneratedColumn<String>(
+    'zulip_version',
+    aliasedName,
+    false,
+    type: DriftSqlType.string,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<String> zulipMergeBase = GeneratedColumn<String>(
+    'zulip_merge_base',
+    aliasedName,
+    true,
+    type: DriftSqlType.string,
+    requiredDuringInsert: false,
+  );
+  late final GeneratedColumn<int> zulipFeatureLevel = GeneratedColumn<int>(
+    'zulip_feature_level',
+    aliasedName,
+    false,
+    type: DriftSqlType.int,
+    requiredDuringInsert: true,
+  );
+  late final GeneratedColumn<String> ackedPushToken = GeneratedColumn<String>(
+    'acked_push_token',
+    aliasedName,
+    true,
+    type: DriftSqlType.string,
+    requiredDuringInsert: false,
+  );
+  @override
+  List<GeneratedColumn> get $columns => [
+    id,
+    realmUrl,
+    userId,
+    email,
+    apiKey,
+    zulipVersion,
+    zulipMergeBase,
+    zulipFeatureLevel,
+    ackedPushToken,
+  ];
+  @override
+  String get aliasedName => _alias ?? actualTableName;
+  @override
+  String get actualTableName => $name;
+  static const String $name = 'accounts';
+  @override
+  Set<GeneratedColumn> get $primaryKey => {id};
+  @override
+  List<Set<GeneratedColumn>> get uniqueKeys => [
+    {realmUrl, userId},
+    {realmUrl, email},
+  ];
+  @override
+  AccountsData map(Map<String, dynamic> data, {String? tablePrefix}) {
+    final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
+    return AccountsData(
+      id:
+          attachedDatabase.typeMapping.read(
+            DriftSqlType.int,
+            data['${effectivePrefix}id'],
+          )!,
+      realmUrl:
+          attachedDatabase.typeMapping.read(
+            DriftSqlType.string,
+            data['${effectivePrefix}realm_url'],
+          )!,
+      userId:
+          attachedDatabase.typeMapping.read(
+            DriftSqlType.int,
+            data['${effectivePrefix}user_id'],
+          )!,
+      email:
+          attachedDatabase.typeMapping.read(
+            DriftSqlType.string,
+            data['${effectivePrefix}email'],
+          )!,
+      apiKey:
+          attachedDatabase.typeMapping.read(
+            DriftSqlType.string,
+            data['${effectivePrefix}api_key'],
+          )!,
+      zulipVersion:
+          attachedDatabase.typeMapping.read(
+            DriftSqlType.string,
+            data['${effectivePrefix}zulip_version'],
+          )!,
+      zulipMergeBase: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}zulip_merge_base'],
+      ),
+      zulipFeatureLevel:
+          attachedDatabase.typeMapping.read(
+            DriftSqlType.int,
+            data['${effectivePrefix}zulip_feature_level'],
+          )!,
+      ackedPushToken: attachedDatabase.typeMapping.read(
+        DriftSqlType.string,
+        data['${effectivePrefix}acked_push_token'],
+      ),
+    );
+  }
+
+  @override
+  Accounts createAlias(String alias) {
+    return Accounts(attachedDatabase, alias);
+  }
+}
+
+class AccountsData extends DataClass implements Insertable<AccountsData> {
+  final int id;
+  final String realmUrl;
+  final int userId;
+  final String email;
+  final String apiKey;
+  final String zulipVersion;
+  final String? zulipMergeBase;
+  final int zulipFeatureLevel;
+  final String? ackedPushToken;
+  const AccountsData({
+    required this.id,
+    required this.realmUrl,
+    required this.userId,
+    required this.email,
+    required this.apiKey,
+    required this.zulipVersion,
+    this.zulipMergeBase,
+    required this.zulipFeatureLevel,
+    this.ackedPushToken,
+  });
+  @override
+  Map<String, Expression> toColumns(bool nullToAbsent) {
+    final map = <String, Expression>{};
+    map['id'] = Variable<int>(id);
+    map['realm_url'] = Variable<String>(realmUrl);
+    map['user_id'] = Variable<int>(userId);
+    map['email'] = Variable<String>(email);
+    map['api_key'] = Variable<String>(apiKey);
+    map['zulip_version'] = Variable<String>(zulipVersion);
+    if (!nullToAbsent || zulipMergeBase != null) {
+      map['zulip_merge_base'] = Variable<String>(zulipMergeBase);
+    }
+    map['zulip_feature_level'] = Variable<int>(zulipFeatureLevel);
+    if (!nullToAbsent || ackedPushToken != null) {
+      map['acked_push_token'] = Variable<String>(ackedPushToken);
+    }
+    return map;
+  }
+
+  AccountsCompanion toCompanion(bool nullToAbsent) {
+    return AccountsCompanion(
+      id: Value(id),
+      realmUrl: Value(realmUrl),
+      userId: Value(userId),
+      email: Value(email),
+      apiKey: Value(apiKey),
+      zulipVersion: Value(zulipVersion),
+      zulipMergeBase:
+          zulipMergeBase == null && nullToAbsent
+              ? const Value.absent()
+              : Value(zulipMergeBase),
+      zulipFeatureLevel: Value(zulipFeatureLevel),
+      ackedPushToken:
+          ackedPushToken == null && nullToAbsent
+              ? const Value.absent()
+              : Value(ackedPushToken),
+    );
+  }
+
+  factory AccountsData.fromJson(
+    Map<String, dynamic> json, {
+    ValueSerializer? serializer,
+  }) {
+    serializer ??= driftRuntimeOptions.defaultSerializer;
+    return AccountsData(
+      id: serializer.fromJson<int>(json['id']),
+      realmUrl: serializer.fromJson<String>(json['realmUrl']),
+      userId: serializer.fromJson<int>(json['userId']),
+      email: serializer.fromJson<String>(json['email']),
+      apiKey: serializer.fromJson<String>(json['apiKey']),
+      zulipVersion: serializer.fromJson<String>(json['zulipVersion']),
+      zulipMergeBase: serializer.fromJson<String?>(json['zulipMergeBase']),
+      zulipFeatureLevel: serializer.fromJson<int>(json['zulipFeatureLevel']),
+      ackedPushToken: serializer.fromJson<String?>(json['ackedPushToken']),
+    );
+  }
+  @override
+  Map<String, dynamic> toJson({ValueSerializer? serializer}) {
+    serializer ??= driftRuntimeOptions.defaultSerializer;
+    return <String, dynamic>{
+      'id': serializer.toJson<int>(id),
+      'realmUrl': serializer.toJson<String>(realmUrl),
+      'userId': serializer.toJson<int>(userId),
+      'email': serializer.toJson<String>(email),
+      'apiKey': serializer.toJson<String>(apiKey),
+      'zulipVersion': serializer.toJson<String>(zulipVersion),
+      'zulipMergeBase': serializer.toJson<String?>(zulipMergeBase),
+      'zulipFeatureLevel': serializer.toJson<int>(zulipFeatureLevel),
+      'ackedPushToken': serializer.toJson<String?>(ackedPushToken),
+    };
+  }
+
+  AccountsData copyWith({
+    int? id,
+    String? realmUrl,
+    int? userId,
+    String? email,
+    String? apiKey,
+    String? zulipVersion,
+    Value<String?> zulipMergeBase = const Value.absent(),
+    int? zulipFeatureLevel,
+    Value<String?> ackedPushToken = const Value.absent(),
+  }) => AccountsData(
+    id: id ?? this.id,
+    realmUrl: realmUrl ?? this.realmUrl,
+    userId: userId ?? this.userId,
+    email: email ?? this.email,
+    apiKey: apiKey ?? this.apiKey,
+    zulipVersion: zulipVersion ?? this.zulipVersion,
+    zulipMergeBase:
+        zulipMergeBase.present ? zulipMergeBase.value : this.zulipMergeBase,
+    zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel,
+    ackedPushToken:
+        ackedPushToken.present ? ackedPushToken.value : this.ackedPushToken,
+  );
+  AccountsData copyWithCompanion(AccountsCompanion data) {
+    return AccountsData(
+      id: data.id.present ? data.id.value : this.id,
+      realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl,
+      userId: data.userId.present ? data.userId.value : this.userId,
+      email: data.email.present ? data.email.value : this.email,
+      apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey,
+      zulipVersion:
+          data.zulipVersion.present
+              ? data.zulipVersion.value
+              : this.zulipVersion,
+      zulipMergeBase:
+          data.zulipMergeBase.present
+              ? data.zulipMergeBase.value
+              : this.zulipMergeBase,
+      zulipFeatureLevel:
+          data.zulipFeatureLevel.present
+              ? data.zulipFeatureLevel.value
+              : this.zulipFeatureLevel,
+      ackedPushToken:
+          data.ackedPushToken.present
+              ? data.ackedPushToken.value
+              : this.ackedPushToken,
+    );
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('AccountsData(')
+          ..write('id: $id, ')
+          ..write('realmUrl: $realmUrl, ')
+          ..write('userId: $userId, ')
+          ..write('email: $email, ')
+          ..write('apiKey: $apiKey, ')
+          ..write('zulipVersion: $zulipVersion, ')
+          ..write('zulipMergeBase: $zulipMergeBase, ')
+          ..write('zulipFeatureLevel: $zulipFeatureLevel, ')
+          ..write('ackedPushToken: $ackedPushToken')
+          ..write(')'))
+        .toString();
+  }
+
+  @override
+  int get hashCode => Object.hash(
+    id,
+    realmUrl,
+    userId,
+    email,
+    apiKey,
+    zulipVersion,
+    zulipMergeBase,
+    zulipFeatureLevel,
+    ackedPushToken,
+  );
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      (other is AccountsData &&
+          other.id == this.id &&
+          other.realmUrl == this.realmUrl &&
+          other.userId == this.userId &&
+          other.email == this.email &&
+          other.apiKey == this.apiKey &&
+          other.zulipVersion == this.zulipVersion &&
+          other.zulipMergeBase == this.zulipMergeBase &&
+          other.zulipFeatureLevel == this.zulipFeatureLevel &&
+          other.ackedPushToken == this.ackedPushToken);
+}
+
+class AccountsCompanion extends UpdateCompanion<AccountsData> {
+  final Value<int> id;
+  final Value<String> realmUrl;
+  final Value<int> userId;
+  final Value<String> email;
+  final Value<String> apiKey;
+  final Value<String> zulipVersion;
+  final Value<String?> zulipMergeBase;
+  final Value<int> zulipFeatureLevel;
+  final Value<String?> ackedPushToken;
+  const AccountsCompanion({
+    this.id = const Value.absent(),
+    this.realmUrl = const Value.absent(),
+    this.userId = const Value.absent(),
+    this.email = const Value.absent(),
+    this.apiKey = const Value.absent(),
+    this.zulipVersion = const Value.absent(),
+    this.zulipMergeBase = const Value.absent(),
+    this.zulipFeatureLevel = const Value.absent(),
+    this.ackedPushToken = const Value.absent(),
+  });
+  AccountsCompanion.insert({
+    this.id = const Value.absent(),
+    required String realmUrl,
+    required int userId,
+    required String email,
+    required String apiKey,
+    required String zulipVersion,
+    this.zulipMergeBase = const Value.absent(),
+    required int zulipFeatureLevel,
+    this.ackedPushToken = const Value.absent(),
+  }) : realmUrl = Value(realmUrl),
+       userId = Value(userId),
+       email = Value(email),
+       apiKey = Value(apiKey),
+       zulipVersion = Value(zulipVersion),
+       zulipFeatureLevel = Value(zulipFeatureLevel);
+  static Insertable<AccountsData> custom({
+    Expression<int>? id,
+    Expression<String>? realmUrl,
+    Expression<int>? userId,
+    Expression<String>? email,
+    Expression<String>? apiKey,
+    Expression<String>? zulipVersion,
+    Expression<String>? zulipMergeBase,
+    Expression<int>? zulipFeatureLevel,
+    Expression<String>? ackedPushToken,
+  }) {
+    return RawValuesInsertable({
+      if (id != null) 'id': id,
+      if (realmUrl != null) 'realm_url': realmUrl,
+      if (userId != null) 'user_id': userId,
+      if (email != null) 'email': email,
+      if (apiKey != null) 'api_key': apiKey,
+      if (zulipVersion != null) 'zulip_version': zulipVersion,
+      if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase,
+      if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel,
+      if (ackedPushToken != null) 'acked_push_token': ackedPushToken,
+    });
+  }
+
+  AccountsCompanion copyWith({
+    Value<int>? id,
+    Value<String>? realmUrl,
+    Value<int>? userId,
+    Value<String>? email,
+    Value<String>? apiKey,
+    Value<String>? zulipVersion,
+    Value<String?>? zulipMergeBase,
+    Value<int>? zulipFeatureLevel,
+    Value<String?>? ackedPushToken,
+  }) {
+    return AccountsCompanion(
+      id: id ?? this.id,
+      realmUrl: realmUrl ?? this.realmUrl,
+      userId: userId ?? this.userId,
+      email: email ?? this.email,
+      apiKey: apiKey ?? this.apiKey,
+      zulipVersion: zulipVersion ?? this.zulipVersion,
+      zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase,
+      zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel,
+      ackedPushToken: ackedPushToken ?? this.ackedPushToken,
+    );
+  }
+
+  @override
+  Map<String, Expression> toColumns(bool nullToAbsent) {
+    final map = <String, Expression>{};
+    if (id.present) {
+      map['id'] = Variable<int>(id.value);
+    }
+    if (realmUrl.present) {
+      map['realm_url'] = Variable<String>(realmUrl.value);
+    }
+    if (userId.present) {
+      map['user_id'] = Variable<int>(userId.value);
+    }
+    if (email.present) {
+      map['email'] = Variable<String>(email.value);
+    }
+    if (apiKey.present) {
+      map['api_key'] = Variable<String>(apiKey.value);
+    }
+    if (zulipVersion.present) {
+      map['zulip_version'] = Variable<String>(zulipVersion.value);
+    }
+    if (zulipMergeBase.present) {
+      map['zulip_merge_base'] = Variable<String>(zulipMergeBase.value);
+    }
+    if (zulipFeatureLevel.present) {
+      map['zulip_feature_level'] = Variable<int>(zulipFeatureLevel.value);
+    }
+    if (ackedPushToken.present) {
+      map['acked_push_token'] = Variable<String>(ackedPushToken.value);
+    }
+    return map;
+  }
+
+  @override
+  String toString() {
+    return (StringBuffer('AccountsCompanion(')
+          ..write('id: $id, ')
+          ..write('realmUrl: $realmUrl, ')
+          ..write('userId: $userId, ')
+          ..write('email: $email, ')
+          ..write('apiKey: $apiKey, ')
+          ..write('zulipVersion: $zulipVersion, ')
+          ..write('zulipMergeBase: $zulipMergeBase, ')
+          ..write('zulipFeatureLevel: $zulipFeatureLevel, ')
+          ..write('ackedPushToken: $ackedPushToken')
+          ..write(')'))
+        .toString();
+  }
+}
+
+class DatabaseAtV4 extends GeneratedDatabase {
+  DatabaseAtV4(QueryExecutor e) : super(e);
+  late final GlobalSettings globalSettings = GlobalSettings(this);
+  late final Accounts accounts = Accounts(this);
+  @override
+  Iterable<TableInfo<Table, Object?>> get allTables =>
+      allSchemaEntities.whereType<TableInfo<Table, Object?>>();
+  @override
+  List<DatabaseSchemaEntity> get allSchemaEntities => [
+    globalSettings,
+    accounts,
+  ];
+  @override
+  int get schemaVersion => 4;
+}
diff --git a/test/model/settings_test.dart b/test/model/settings_test.dart
new file mode 100644
index 0000000000..e4e0def398
--- /dev/null
+++ b/test/model/settings_test.dart
@@ -0,0 +1,53 @@
+import 'package:checks/checks.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/model/binding.dart';
+import 'package:zulip/model/settings.dart';
+
+import '../example_data.dart' as eg;
+import 'store_checks.dart';
+import 'store_test.dart';
+
+void main() {
+  final httpLink = Uri.parse('http://chat.zulip.org');
+  final nonHttpLink = Uri.parse('mailto:chat@zulip.org');
+
+  group('getUrlLaunchMode', () {
+    testAndroidIos('globalSettings.browserPreference is null; use our per-platform defaults for HTTP links', () {
+      final globalStore = eg.globalStore(globalSettings: eg.globalSettings(
+        browserPreference: null));
+      check(globalStore).globalSettings.getUrlLaunchMode(httpLink).equals(
+        defaultTargetPlatform == TargetPlatform.android
+          ? UrlLaunchMode.inAppBrowserView : UrlLaunchMode.externalApplication);
+    });
+
+    testAndroidIos('globalSettings.browserPreference is null; use our per-platform defaults for non-HTTP links', () {
+      final globalStore = eg.globalStore(globalSettings: eg.globalSettings(
+        browserPreference: null));
+      check(globalStore).globalSettings.getUrlLaunchMode(nonHttpLink).equals(
+        defaultTargetPlatform == TargetPlatform.android
+          ? UrlLaunchMode.platformDefault : UrlLaunchMode.externalApplication);
+    });
+
+    testAndroidIos('globalSettings.browserPreference is inApp; follow the user preference for http links', () {
+      final globalStore = eg.globalStore(globalSettings: eg.globalSettings(
+        browserPreference: BrowserPreference.inApp));
+      check(globalStore).globalSettings.getUrlLaunchMode(httpLink).equals(
+        UrlLaunchMode.inAppBrowserView);
+    });
+
+    testAndroidIos('globalSettings.browserPreference is inApp; use platform default for non-http links', () {
+      final globalStore = eg.globalStore(globalSettings: eg.globalSettings(
+        browserPreference: BrowserPreference.inApp));
+      check(globalStore).globalSettings.getUrlLaunchMode(nonHttpLink).equals(
+        UrlLaunchMode.platformDefault);
+    });
+
+    testAndroidIos('globalSettings.browserPreference is external; follow the user preference', () {
+      final globalStore = eg.globalStore(globalSettings: eg.globalSettings(
+        browserPreference: BrowserPreference.external));
+      check(globalStore).globalSettings.getUrlLaunchMode(httpLink).equals(
+        UrlLaunchMode.externalApplication);
+    });
+  });
+}
diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart
index 5b05935572..96edf05d41 100644
--- a/test/model/store_checks.dart
+++ b/test/model/store_checks.dart
@@ -3,6 +3,7 @@ import 'package:zulip/api/core.dart';
 import 'package:zulip/api/model/initial_snapshot.dart';
 import 'package:zulip/api/model/model.dart';
 import 'package:zulip/model/autocomplete.dart';
+import 'package:zulip/model/binding.dart';
 import 'package:zulip/model/database.dart';
 import 'package:zulip/model/recent_dm_conversations.dart';
 import 'package:zulip/model/settings.dart';
@@ -31,6 +32,9 @@ extension GlobalStoreChecks on Subject<GlobalStore> {

 extension GlobalSettingsDataChecks on Subject<GlobalSettingsData> {
   Subject<ThemeSetting?> get themeSetting => has((x) => x.themeSetting, 'themeSetting');
+  Subject<BrowserPreference?> get browserPreference => has((x) => x.browserPreference, 'browserPreference');
+  Subject<BrowserPreference> get effectiveBrowserPreference => has((x) => x.effectiveBrowserPreference, 'effectiveBrowserPreference');
+  Subject<UrlLaunchMode> getUrlLaunchMode(Uri url) => has((x) => x.getUrlLaunchMode(url), 'getUrlLaunchMode');
 }

 extension PerAccountStoreChecks on Subject<PerAccountStore> {
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index 88f94a002e..c2bb5fee4f 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher.dart';
 import 'package:zulip/api/core.dart';
 import 'package:zulip/model/content.dart';
 import 'package:zulip/model/narrow.dart';
+import 'package:zulip/model/settings.dart';
 import 'package:zulip/model/store.dart';
 import 'package:zulip/widgets/content.dart';
 import 'package:zulip/widgets/icons.dart';
@@ -521,7 +522,7 @@ void main() {
       final expectedLaunchUrl = expectedVideo.hrefUrl;
       await tester.tap(find.byIcon(Icons.play_arrow_rounded));
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: Uri.parse(expectedLaunchUrl), mode: LaunchMode.platformDefault));
+        .single.equals((url: Uri.parse(expectedLaunchUrl), mode: LaunchMode.inAppBrowserView));
     }

     testWidgets('video preview for youtube embed', (tester) async {
@@ -793,11 +794,23 @@ void main() {
       await tapText(tester, find.text('hello'));

       final expectedLaunchMode = defaultTargetPlatform == TargetPlatform.iOS ?
-        LaunchMode.externalApplication : LaunchMode.platformDefault;
+        LaunchMode.externalApplication : LaunchMode.inAppBrowserView;
       check(testBinding.takeLaunchUrlCalls())
         .single.equals((url: Uri.parse('https://example/'), mode: expectedLaunchMode));
     }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));

+    testWidgets('follow browser preference setting to open URL', (tester) async {
+      await testBinding.globalStore.updateGlobalSettings(
+        eg.globalSettings(
+          browserPreference: BrowserPreference.inApp).toCompanion(false));
+      await prepare(tester,
+        '<p><a href="https://example/">hello</a></p>');
+
+      await tapText(tester, find.text('hello'));
+      check(testBinding.takeLaunchUrlCalls()).single.equals((
+        url: Uri.parse('https://example/'), mode: LaunchMode.inAppBrowserView));
+    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+
     testWidgets('multiple links in paragraph', (tester) async {
       const fontSize = kBaseFontSize;

@@ -811,11 +824,11 @@ void main() {

       await tester.tapAt(base.translate(1*fontSize, 0)); // "fXo bar baz"
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.platformDefault));
+        .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.inAppBrowserView));

       await tester.tapAt(base.translate(9*fontSize, 0)); // "foo bar bXz"
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: Uri.parse('https://b/'), mode: LaunchMode.platformDefault));
+        .single.equals((url: Uri.parse('https://b/'), mode: LaunchMode.inAppBrowserView));
     });

     testWidgets('link nested in other spans', (tester) async {
@@ -823,7 +836,7 @@ void main() {
         '<p><strong><em><a href="https://a/">word</a></em></strong></p>');
       await tapText(tester, find.text('word'));
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.platformDefault));
+        .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.inAppBrowserView));
     });

     testWidgets('link containing other spans', (tester) async {
@@ -836,11 +849,11 @@ void main() {

       await tester.tapAt(base.translate(1*fontSize, 0)); // "tXo words"
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.platformDefault));
+        .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.inAppBrowserView));

       await tester.tapAt(base.translate(6*fontSize, 0)); // "two woXds"
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.platformDefault));
+        .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.inAppBrowserView));
     });

     testWidgets('relative links are resolved', (tester) async {
@@ -848,7 +861,7 @@ void main() {
         '<p><a href="/a/b?c#d">word</a></p>');
       await tapText(tester, find.text('word'));
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: Uri.parse('${eg.realmUrl}a/b?c#d'), mode: LaunchMode.platformDefault));
+        .single.equals((url: Uri.parse('${eg.realmUrl}a/b?c#d'), mode: LaunchMode.inAppBrowserView));
     });

     testWidgets('link inside HeadingNode', (tester) async {
@@ -856,7 +869,7 @@ void main() {
         '<h6><a href="https://a/">word</a></h6>');
       await tapText(tester, find.text('word'));
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.platformDefault));
+        .single.equals((url: Uri.parse('https://a/'), mode: LaunchMode.inAppBrowserView));
     });

     testWidgets('error dialog if invalid link', (tester) async {
@@ -910,7 +923,7 @@ void main() {
       await tapText(tester, find.text('invalid'));
       final expectedUrl = eg.realmUrl.resolve('/#narrow/stream/1-check/topic');
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: expectedUrl, mode: LaunchMode.platformDefault));
+        .single.equals((url: expectedUrl, mode: LaunchMode.inAppBrowserView));
       check(pushedRoutes).isEmpty();
     });
   });
@@ -1060,11 +1073,11 @@ void main() {

       await tester.tap(find.text('Zulip — organized team chat'));
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: url, mode: LaunchMode.platformDefault));
+        .single.equals((url: url, mode: LaunchMode.inAppBrowserView));

       await tester.tap(find.byType(RealmContentNetworkImage));
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: url, mode: LaunchMode.platformDefault));
+        .single.equals((url: url, mode: LaunchMode.inAppBrowserView));
       debugNetworkImageHttpClientProvider = null;
     });

@@ -1078,7 +1091,7 @@ void main() {

       await tester.tap(find.byType(RealmContentNetworkImage));
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: url, mode: LaunchMode.platformDefault));
+        .single.equals((url: url, mode: LaunchMode.inAppBrowserView));
       debugNetworkImageHttpClientProvider = null;
     });

@@ -1088,11 +1101,11 @@ void main() {

       await tester.tap(find.text('Zulip — organized team chat'));
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: url, mode: LaunchMode.platformDefault));
+        .single.equals((url: url, mode: LaunchMode.inAppBrowserView));

       await tester.tap(find.byType(RealmContentNetworkImage));
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: url, mode: LaunchMode.platformDefault));
+        .single.equals((url: url, mode: LaunchMode.inAppBrowserView));
       debugNetworkImageHttpClientProvider = null;
     });

@@ -1102,7 +1115,7 @@ void main() {

       await tester.tap(find.byType(RealmContentNetworkImage));
       check(testBinding.takeLaunchUrlCalls())
-        .single.equals((url: url, mode: LaunchMode.platformDefault));
+        .single.equals((url: url, mode: LaunchMode.inAppBrowserView));
       debugNetworkImageHttpClientProvider = null;
     });
   });
diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart
index 38f6c223e3..30f6433528 100644
--- a/test/widgets/profile_test.dart
+++ b/test/widgets/profile_test.dart
@@ -164,7 +164,7 @@ void main() {
       await tester.tap(find.text(testUrl));
       check(testBinding.takeLaunchUrlCalls()).single.equals((
         url: Uri.parse(testUrl),
-        mode: LaunchMode.platformDefault,
+        mode: LaunchMode.inAppBrowserView,
       ));
     });

@@ -191,7 +191,7 @@ void main() {
       await tester.tap(find.text('externalValue'));
       check(testBinding.takeLaunchUrlCalls()).single.equals((
         url: Uri.parse('http://example/externalValue'),
-        mode: LaunchMode.platformDefault,
+        mode: LaunchMode.inAppBrowserView,
       ));
     });

diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart
index 2eb59d2c14..65336479eb 100644
--- a/test/widgets/settings_test.dart
+++ b/test/widgets/settings_test.dart
@@ -83,4 +83,49 @@ void main() {
       debugBrightnessOverride = null;
     });
   });
+
+  group('BrowserPreference', () {
+    Finder useInAppBrowserSwitchFinder = find.ancestor(
+      of: find.text('Open links with in-app browser'),
+      matching: find.byType(SwitchListTile));
+
+    void checkSwitchAndGlobalSettings(WidgetTester tester, {
+      required bool checked,
+      required BrowserPreference? expectedBrowserPreference,
+    }) {
+      check(tester.widget<SwitchListTile>(useInAppBrowserSwitchFinder))
+        .value.equals(checked);
+      check(testBinding.globalStore)
+        .globalSettings.browserPreference.equals(expectedBrowserPreference);
+    }
+
+    testWidgets('smoke', (tester) async {
+      await testBinding.globalStore.updateGlobalSettings(
+        eg.globalSettings(
+          browserPreference: BrowserPreference.external).toCompanion(false));
+      await prepare(tester);
+      checkSwitchAndGlobalSettings(tester,
+        checked: false, expectedBrowserPreference: BrowserPreference.external);
+
+      await tester.tap(useInAppBrowserSwitchFinder);
+      await tester.pump();
+      checkSwitchAndGlobalSettings(tester,
+        checked: true, expectedBrowserPreference: BrowserPreference.inApp);
+    });
+
+    testWidgets('use our per-platform default browser preference', (tester) async {
+      await prepare(tester);
+      bool expectInApp = defaultTargetPlatform == TargetPlatform.android;
+      checkSwitchAndGlobalSettings(tester,
+        checked: expectInApp, expectedBrowserPreference: null);
+
+      await tester.tap(useInAppBrowserSwitchFinder);
+      await tester.pump();
+      expectInApp = !expectInApp;
+      checkSwitchAndGlobalSettings(tester,
+        checked: expectInApp,
+        expectedBrowserPreference: expectInApp
+          ? BrowserPreference.inApp : BrowserPreference.external);
+    }, variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+  });
 }