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
This commit is contained in:
Bryan Oltman
2023-06-20 17:11:30 -04:00
committed by GitHub
parent 318d86e13f
commit 62ed676140
5 changed files with 162 additions and 62 deletions
+25 -2
View File
@@ -36,6 +36,8 @@ class _MyHomePageState extends State<MyHomePage> {
int? _currentPatchVersion;
int? _nextPatchVersion;
bool _isCheckingForUpdate = false;
bool _isUpdateAvailable = false;
bool _isDownloadingUpdate = false;
@override
void initState() {
@@ -57,7 +59,7 @@ class _MyHomePageState extends State<MyHomePage> {
_isCheckingForUpdate = true;
});
final isUpdateAvailable = await _shorebirdCodePush.checkForUpdate();
_isUpdateAvailable = await _shorebirdCodePush.checkForUpdate();
if (!mounted) return;
@@ -68,12 +70,26 @@ class _MyHomePageState extends State<MyHomePage> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isUpdateAvailable ? 'Update available' : 'No update available',
_isUpdateAvailable ? 'Update available' : 'No update available',
),
),
);
}
Future<void> _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<MyHomePage> {
)
: const Text('Check for update'),
),
if (_isUpdateAvailable)
ElevatedButton(
onPressed: _isDownloadingUpdate ? null : _downloadUpdate,
child: Text(
_isDownloadingUpdate ? 'Downloading...' : 'Download update',
),
),
],
),
),
@@ -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;
final Updater Function() _createUpdater;
/// 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 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<bool> 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<int?> 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<int?> 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,
);
}
/// Creates an [Updater] in a separate isolate and runs the given function.
Future<T> _runInIsolate<T>(T Function(Updater updater) f) async {
return Isolate.run(() {
/// Downloads the latest patch, if available.
Future<void> 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. If
/// an error occurs, the error is logged and [fallbackValue] is returned.
Future<T> _runInIsolate<T>(
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;
}
}
}
+8 -23
View File
@@ -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();
}
@@ -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');
});
});
});
+8 -18
View File
@@ -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);
});
});
});
}