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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user