From 62ed6761405c7696a999d976235c4f74e31532fc Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Tue, 20 Jun 2023 17:11:30 -0400 Subject: [PATCH] feat(shorebird_code_push): add download update functionality (#37) * feat: add shorebird_current_boot_patch_number function * Continue support for next_patch_version * rename, return null in case of 0 * feat(shorebird_code_push): add download update functionality * rename defaultValue to fallbackValue * add forTest constructor to ShorebirdCodePush --- shorebird_code_push/example/lib/main.dart | 27 +++++- .../lib/src/shorebird_code_push.dart | 95 +++++++++++++++---- shorebird_code_push/lib/src/updater.dart | 31 ++---- .../test/src/shorebird_code_push_test.dart | 45 ++++++++- .../test/src/updater_test.dart | 26 ++--- 5 files changed, 162 insertions(+), 62 deletions(-) diff --git a/shorebird_code_push/example/lib/main.dart b/shorebird_code_push/example/lib/main.dart index 1ccdc2ba..13da0ea4 100644 --- a/shorebird_code_push/example/lib/main.dart +++ b/shorebird_code_push/example/lib/main.dart @@ -36,6 +36,8 @@ class _MyHomePageState extends State { int? _currentPatchVersion; int? _nextPatchVersion; bool _isCheckingForUpdate = false; + bool _isUpdateAvailable = false; + bool _isDownloadingUpdate = false; @override void initState() { @@ -57,7 +59,7 @@ class _MyHomePageState extends State { _isCheckingForUpdate = true; }); - final isUpdateAvailable = await _shorebirdCodePush.checkForUpdate(); + _isUpdateAvailable = await _shorebirdCodePush.checkForUpdate(); if (!mounted) return; @@ -68,12 +70,26 @@ class _MyHomePageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - isUpdateAvailable ? 'Update available' : 'No update available', + _isUpdateAvailable ? 'Update available' : 'No update available', ), ), ); } + Future _downloadUpdate() async { + setState(() { + _isDownloadingUpdate = true; + }); + + await _shorebirdCodePush.downloadUpdate(); + _nextPatchVersion = await _shorebirdCodePush.nextPatchNumber(); + + if (!mounted) return; + setState(() { + _isDownloadingUpdate = false; + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -120,6 +136,13 @@ class _MyHomePageState extends State { ) : const Text('Check for update'), ), + if (_isUpdateAvailable) + ElevatedButton( + onPressed: _isDownloadingUpdate ? null : _downloadUpdate, + child: Text( + _isDownloadingUpdate ? 'Downloading...' : 'Download update', + ), + ), ], ), ), diff --git a/shorebird_code_push/lib/src/shorebird_code_push.dart b/shorebird_code_push/lib/src/shorebird_code_push.dart index 72fa7e97..f04115c8 100644 --- a/shorebird_code_push/lib/src/shorebird_code_push.dart +++ b/shorebird_code_push/lib/src/shorebird_code_push.dart @@ -1,49 +1,110 @@ import 'dart:isolate'; +import 'package:meta/meta.dart'; import 'package:shorebird_code_push/src/updater.dart'; +/// A logging function for errors arising from interacting with the native code. +/// +/// Used to override the default behavior of using [print]. +typedef ShorebirdLog = void Function(Object? object); + +/// A function that constructs an [Updater] instance. Used for testing. +@visibleForTesting +typedef UpdaterBuilder = Updater Function(); + /// {@template shorebird_code_push} /// Get info about your Shorebird code push app. /// {@endtemplate} class ShorebirdCodePush { /// {@macro shorebird_code_push} ShorebirdCodePush({ - Updater Function()? createUpdater, // for testing - }) : _createUpdater = createUpdater ?? Updater.new; + this.logError = print, + }) : _buildUpdater = Updater.new; + + /// A test-only constructor that allows overriding the Updater constructor. + @visibleForTesting + ShorebirdCodePush.forTest({ + required this.logError, + required UpdaterBuilder buildUpdater, + }) : _buildUpdater = buildUpdater; + + /// Logs error messages arising from interacting with the native code. + /// + /// Defaults to [print]. + final ShorebirdLog logError; - final Updater Function() _createUpdater; + final UpdaterBuilder _buildUpdater; + static const _loggingPrefix = '[ShorebirdCodePush]'; /// Checks whether a new patch is available for download. /// /// Runs in a separate isolate to avoid blocking the UI thread. Future checkForUpdate() { - return _runInIsolate((updater) => updater.checkForUpdate()); + return _runInIsolate( + (updater) => updater.checkForUpdate(), + fallbackValue: false, + ); } /// The version of the currently-installed patch. Null if no patch is /// installed (i.e., the app is running the release version). Future currentPatchNumber() { - return _runInIsolate((updater) { - final patchNumber = updater.currentPatchNumber(); - return patchNumber == 0 ? null : patchNumber; - }); + return _runInIsolate( + (updater) { + final patchNumber = updater.currentPatchNumber(); + return patchNumber == 0 ? null : patchNumber; + }, + fallbackValue: null, + ); } /// The version of the patch that will be run on the next app launch. If no /// new patch has been downloaded, this will be the same as /// [currentPatchNumber]. Future nextPatchNumber() { - return _runInIsolate((updater) { - final patchNumber = updater.nextPatchNumber(); - return patchNumber == 0 ? null : patchNumber; - }); + return _runInIsolate( + (updater) { + final patchNumber = updater.nextPatchNumber(); + return patchNumber == 0 ? null : patchNumber; + }, + fallbackValue: null, + ); + } + + /// Downloads the latest patch, if available. + Future downloadUpdate() async { + await _runInIsolate( + (updater) => updater.downloadUpdate(), + fallbackValue: null, + ); + } + + void _logError(Object error) { + final logMessage = '$_loggingPrefix $error'; + if (error is ArgumentError) { + // ffi function lookup failures manifest as ArgumentErrors. + logError( + ''' +$logMessage + This is likely because you are not running with the Shorebird Flutter engine (that is, if you ran with `flutter run` instead of `shorebird run`).''', + ); + } else { + logError(logMessage); + } } - /// Creates an [Updater] in a separate isolate and runs the given function. - Future _runInIsolate(T Function(Updater updater) f) async { - return Isolate.run(() { + /// Creates an [Updater] in a separate isolate and runs the given function. If + /// an error occurs, the error is logged and [fallbackValue] is returned. + Future _runInIsolate( + T Function(Updater updater) f, { + required T fallbackValue, + }) async { + try { // Create a new Updater in the new isolate. - return f(_createUpdater()); - }); + return await Isolate.run(() => f(_buildUpdater())); + } catch (error) { + _logError(error); + return fallbackValue; + } } } diff --git a/shorebird_code_push/lib/src/updater.dart b/shorebird_code_push/lib/src/updater.dart index 8aebc674..d9f7b9d6 100644 --- a/shorebird_code_push/lib/src/updater.dart +++ b/shorebird_code_push/lib/src/updater.dart @@ -4,8 +4,8 @@ import 'package:meta/meta.dart'; import 'package:shorebird_code_push/src/generated/updater_bindings.g.dart'; /// {@template updater} -/// A wrapper around the generated [UpdaterBindings] that translates ffi types -/// into easier to use Dart types. +/// A wrapper around the generated [UpdaterBindings] that, when necessary, +/// translates ffi types into easier to use Dart types. /// {@endtemplate} class Updater { /// Creates an [Updater] instance using the currently loaded dynamic library. @@ -18,30 +18,15 @@ class Updater { static late UpdaterBindings bindings; /// The currently active patch number. - int currentPatchNumber() { - try { - return bindings.shorebird_current_boot_patch_number(); - } catch (e) { - return 0; - } - } + int currentPatchNumber() => bindings.shorebird_current_boot_patch_number(); /// Whether a new patch is available. - bool checkForUpdate() { - try { - return bindings.shorebird_check_for_update(); - } catch (e) { - return false; - } - } + bool checkForUpdate() => bindings.shorebird_check_for_update(); /// The next patch number that will be loaded. Will be the same as /// currentPatchNumber if no new patch is available. - int nextPatchNumber() { - try { - return bindings.shorebird_next_boot_patch_number(); - } catch (e) { - return 0; - } - } + int nextPatchNumber() => bindings.shorebird_next_boot_patch_number(); + + /// Downloads the latest patch, if available. + void downloadUpdate() => bindings.shorebird_update(); } diff --git a/shorebird_code_push/test/src/shorebird_code_push_test.dart b/shorebird_code_push/test/src/shorebird_code_push_test.dart index 700f2147..6bdeade6 100644 --- a/shorebird_code_push/test/src/shorebird_code_push_test.dart +++ b/shorebird_code_push/test/src/shorebird_code_push_test.dart @@ -11,11 +11,14 @@ void main() { group('ShorebirdCodePush', () { late Updater updater; late ShorebirdCodePush shorebirdCodePush; + Object? loggedError; setUp(() { + loggedError = null; updater = _MockUpdater(); - shorebirdCodePush = ShorebirdCodePush( - createUpdater: () => updater, + shorebirdCodePush = ShorebirdCodePush.forTest( + logError: ([object]) => loggedError = object, + buildUpdater: () => updater, ); }); @@ -23,11 +26,19 @@ void main() { test('returns false if no update is available', () async { when(() => updater.checkForUpdate()).thenAnswer((_) => false); expect(await shorebirdCodePush.checkForUpdate(), isFalse); + expect(loggedError, isNull); }); test('returns true if an update is available', () async { when(() => updater.checkForUpdate()).thenAnswer((_) => true); expect(await shorebirdCodePush.checkForUpdate(), true); + expect(loggedError, isNull); + }); + + test('returns false if updater throws exception', () async { + when(() => updater.checkForUpdate()).thenThrow(Exception('oh no')); + expect(await shorebirdCodePush.checkForUpdate(), isFalse); + expect(loggedError, '[ShorebirdCodePush] Exception: oh no'); }); }); @@ -35,11 +46,19 @@ void main() { test('returns null if current patch is reported as 0', () async { when(() => updater.currentPatchNumber()).thenReturn(0); expect(await shorebirdCodePush.currentPatchNumber(), isNull); + expect(loggedError, isNull); }); test('forwards the return value of updater.currentPatchNumber', () async { when(() => updater.currentPatchNumber()).thenReturn(1); expect(await shorebirdCodePush.currentPatchNumber(), 1); + expect(loggedError, isNull); + }); + + test('returns null if updater throws exception', () async { + when(() => updater.currentPatchNumber()).thenThrow(Exception('oh no')); + expect(await shorebirdCodePush.currentPatchNumber(), isNull); + expect(loggedError, '[ShorebirdCodePush] Exception: oh no'); }); }); @@ -47,11 +66,33 @@ void main() { test('returns null if current patch is reported as 0', () async { when(() => updater.nextPatchNumber()).thenReturn(0); expect(await shorebirdCodePush.nextPatchNumber(), isNull); + expect(loggedError, isNull); }); test('forwards the return value of updater.nextPatchNumber', () async { when(() => updater.nextPatchNumber()).thenReturn(1); expect(await shorebirdCodePush.nextPatchNumber(), 1); + expect(loggedError, isNull); + }); + + test('returns null if updater throws exception', () async { + when(() => updater.nextPatchNumber()).thenThrow(Exception('oh no')); + expect(await shorebirdCodePush.nextPatchNumber(), isNull); + expect(loggedError, '[ShorebirdCodePush] Exception: oh no'); + }); + }); + + group('downloadUpdate', () { + test('forwards the return value of updater.nextPatchNumber', () async { + when(() => updater.downloadUpdate()).thenReturn(null); + await expectLater(shorebirdCodePush.downloadUpdate(), completes); + expect(loggedError, isNull); + }); + + test('logs error if updater throws exception', () async { + when(() => updater.downloadUpdate()).thenThrow(Exception('oh no')); + await expectLater(shorebirdCodePush.downloadUpdate(), completes); + expect(loggedError, '[ShorebirdCodePush] Exception: oh no'); }); }); }); diff --git a/shorebird_code_push/test/src/updater_test.dart b/shorebird_code_push/test/src/updater_test.dart index d8956998..d973172b 100644 --- a/shorebird_code_push/test/src/updater_test.dart +++ b/shorebird_code_push/test/src/updater_test.dart @@ -22,12 +22,6 @@ void main() { }); group('currentPatchNumber', () { - test('returns 0 if shorebird_next_boot_patch_number throws', () { - when(() => updaterBindings.shorebird_current_boot_patch_number()) - .thenThrow(Exception()); - expect(updater.currentPatchNumber(), 0); - }); - test('forwards the result of shorebird_next_boot_patch_number', () { when(() => updaterBindings.shorebird_current_boot_patch_number()) .thenReturn(123); @@ -37,12 +31,6 @@ void main() { }); group('checkForUpdate', () { - test('returns false if shorebird_check_for_update throws', () { - when(() => updaterBindings.shorebird_check_for_update()) - .thenThrow(Exception()); - expect(updater.checkForUpdate(), isFalse); - }); - test('forwards the result of shorebird_check_for_update', () { when(() => updaterBindings.shorebird_check_for_update()) .thenReturn(true); @@ -55,12 +43,6 @@ void main() { }); group('nextPatchNumber', () { - test('returns 0 if shorebird_next_boot_patch_number throws', () { - when(() => updaterBindings.shorebird_next_boot_patch_number()) - .thenThrow(Exception()); - expect(updater.nextPatchNumber(), 0); - }); - test('forwards the result of shorebird_next_boot_patch_number', () { when(() => updaterBindings.shorebird_next_boot_patch_number()) .thenReturn(123); @@ -68,5 +50,13 @@ void main() { expect(currentPatchNumber, 123); }); }); + + group('downloadUpdate', () { + test('calls bindings.shorebird_update', () { + when(() => updaterBindings.shorebird_update()).thenReturn(null); + updater.downloadUpdate(); + verify(() => updaterBindings.shorebird_update()).called(1); + }); + }); }); }