diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ae781c17d..3adbb17987 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,14 @@ jobs: # so that Flutter knows its version and sees the constraint in our # pubspec is satisfied. It's uncommon for flutter/flutter to go # more than 100 commits between tags. Fetch 1000 for good measure. + # TODO(upstream): Around 2025-05, Flutter upstream stopped making + # tags within the main/master branch. Get that fixed: + # https://github.com/zulip/zulip-flutter/issues/1710 + # Pending that, fetch more than 1000 commits. run: | - git clone --depth=1000 -b main https://github.com/flutter/flutter ~/flutter + git clone --depth=3000 -b main https://github.com/flutter/flutter ~/flutter + cd ~/flutter + git --git-dir ~/flutter/.git checkout ee089d09b21ec3ccc20d179c5be100d2a9d9f866 TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local echo ~/flutter/bin >> "$GITHUB_PATH" diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index fb676042ff..7703fd6ec1 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -29,8 +29,9 @@ jobs: # so that Flutter knows its version and sees the constraint in our # pubspec is satisfied. It's uncommon for flutter/flutter to go # more than 100 commits between tags. Fetch 1000 for good measure. + # TODO(upstream): See ci.yml for why we fetch more than 1000. run: | - git clone --depth=1000 -b main https://github.com/flutter/flutter ~/flutter + git clone --depth=3000 -b main https://github.com/flutter/flutter ~/flutter TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local echo ~/flutter/bin >> "$GITHUB_PATH" diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index b1aa763ac8..d3e729f87b 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -160,6 +160,10 @@ class _ZulipAppState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + + // On every startup is fine; the goal is to be assertive but stop short of a + // rug-pull where we just disable all the app's features. + BetaCompleteDialog.show(); } @override diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 4d269cddba..4d6d3e8675 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -1,7 +1,11 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; import 'actions.dart'; +import 'app.dart'; Widget _dialogActionText(String text) { return Text( @@ -112,3 +116,87 @@ DialogStatus showSuggestedActionDialog({ ])); return DialogStatus(future); } + +bool debugDisableBetaCompleteDialog = false; + +/// A brief dialog box saying that this beta channel has ended, +/// offering a way to get the app from prod. +/// +/// Shown on every startup. +class BetaCompleteDialog extends StatelessWidget { + const BetaCompleteDialog._(); + + static void show() async { + if (debugDisableBetaCompleteDialog) return; + + final navigator = await ZulipApp.navigator; + final context = navigator.context; + assert(context.mounted); + if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + break; + case TargetPlatform.macOS: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // Do nothing on these unsupported platforms. + return; + } + + unawaited(showDialog( + context: context, + builder: (BuildContext context) => BetaCompleteDialog._())); + } + + Widget _linkButton(BuildContext context, { + required String url, + required String label, + }) { + return TextButton( + onPressed: () { + Navigator.pop(context); + PlatformActions.launchUrl(context, + Uri.parse(url)); + }, + child: _dialogActionText(label)); + } + + @override + Widget build(BuildContext context) { + final message = 'Thanks for being a beta tester of the new Zulip app!' + ' This app became the main Zulip mobile app in June 2025,' + ' and this beta version is no longer maintained.' + ' We recommend uninstalling this beta after switching' + ' to the main Zulip app, in order to get the latest features' + ' and bug fixes.'; + + return AlertDialog( + title: Text('Time to switch to the new app'), + content: SingleChildScrollView(child: Text(message)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: _dialogActionText('Got it')), + ...(switch (defaultTargetPlatform) { + TargetPlatform.android => [ + _linkButton(context, + url: 'https://github.com/zulip/zulip-flutter/releases/latest', + label: 'Download official APKs (less common)'), + _linkButton(context, + url: 'https://play.google.com/store/apps/details?id=com.zulipmobile', + label: 'Open Google Play Store'), + ], + TargetPlatform.iOS => [ + _linkButton(context, + url: 'https://apps.apple.com/app/zulip/id1203036395', + label: 'Open App Store'), + ], + TargetPlatform.macOS || TargetPlatform.fuchsia + || TargetPlatform.linux || TargetPlatform.windows => throw UnimplementedError(), + }), + ]); + } +} diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart index a2c14ca20a..2e2eddc0b2 100644 --- a/test/notifications/open_test.dart +++ b/test/notifications/open_test.dart @@ -13,6 +13,7 @@ import 'package:zulip/model/narrow.dart'; import 'package:zulip/notifications/open.dart'; import 'package:zulip/notifications/receive.dart'; import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/dialog.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; @@ -76,6 +77,8 @@ void main() { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; Future init({bool addSelfAccount = true}) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); if (addSelfAccount) { await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); } diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index 7b1388dbec..ccbed9cd5a 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -7,6 +7,7 @@ import 'package:zulip/log.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/database.dart'; import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/dialog.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/page.dart'; @@ -27,6 +28,8 @@ void main() { late List> pushedRoutes = []; Future prepare(WidgetTester tester) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); addTearDown(testBinding.reset); pushedRoutes = []; @@ -64,6 +67,8 @@ void main() { late List> poppedRoutes; Future prepare(WidgetTester tester) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); addTearDown(testBinding.reset); pushedRoutes = []; @@ -279,6 +284,8 @@ void main() { }); testWidgets('choosing an account clears the navigator stack', (tester) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); await testBinding.globalStore.add(eg.otherAccount, eg.initialSnapshot()); @@ -391,6 +398,8 @@ void main() { }); testWidgets('reportErrorToUserBriefly with details', (tester) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); addTearDown(testBinding.reset); await tester.pumpWidget(const ZulipApp()); const message = 'test error message'; @@ -418,6 +427,8 @@ void main() { }); Future prepareSnackBarWithDetails(WidgetTester tester, String message, String details) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); addTearDown(testBinding.reset); await tester.pumpWidget(const ZulipApp()); await tester.pump(); @@ -484,6 +495,8 @@ void main() { }); testWidgets('reportErrorToUserModally', (tester) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); addTearDown(testBinding.reset); await tester.pumpWidget(const ZulipApp()); const title = 'test title'; diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 1b5c8ad8b5..86a33cb699 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -8,6 +8,7 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/about_zulip.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/app_bar.dart'; +import 'package:zulip/widgets/dialog.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/inbox.dart'; @@ -48,6 +49,8 @@ void main () { ..onPopped = ((route, prevRoute) => lastPoppedRoute = route); Future prepare(WidgetTester tester) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); addTearDown(testBinding.reset); topRoute = null; previousTopRoute = null; @@ -272,6 +275,8 @@ void main () { }); testWidgets('menu buttons dismiss the menu', (tester) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); addTearDown(testBinding.reset); topRoute = null; previousTopRoute = null; @@ -328,6 +333,8 @@ void main () { } Future prepare(WidgetTester tester) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); addTearDown(testBinding.reset); topRoute = null; previousTopRoute = null; @@ -521,6 +528,8 @@ void main () { }); testWidgets('logging out while still loading', (tester) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); // Regression test for: https://github.com/zulip/zulip-flutter/issues/1219 addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); @@ -537,6 +546,8 @@ void main () { }); testWidgets('logging out after fully loaded', (tester) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); // Regression test for: https://github.com/zulip/zulip-flutter/issues/1219 addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); diff --git a/test/widgets/login_test.dart b/test/widgets/login_test.dart index a5109ba5db..9cb646f11b 100644 --- a/test/widgets/login_test.dart +++ b/test/widgets/login_test.dart @@ -12,6 +12,7 @@ import 'package:zulip/model/binding.dart'; import 'package:zulip/model/database.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/dialog.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/login.dart'; import 'package:zulip/widgets/page.dart'; @@ -83,6 +84,8 @@ void main() { Future prepare(WidgetTester tester, GetServerSettingsResult serverSettings) async { + debugDisableBetaCompleteDialog = true; + addTearDown(() => debugDisableBetaCompleteDialog = false); addTearDown(testBinding.reset); connection = testBinding.globalStore.apiConnection(